portal-mojo 0.1.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/LICENSE +201 -0
- package/README.md +55 -0
- package/docs/DetailView.md +173 -0
- package/docs/ModelTable.md +128 -0
- package/docs/README.md +98 -0
- package/docs/admin-assistant.md +72 -0
- package/docs/admin-bouncer.md +120 -0
- package/docs/admin-cloudwatch.md +38 -0
- package/docs/admin-credentials.md +116 -0
- package/docs/admin-dashboard.md +15 -0
- package/docs/admin-devices-geoip.md +284 -0
- package/docs/admin-dns-certificates.md +122 -0
- package/docs/admin-dns-records.md +65 -0
- package/docs/admin-dns-registrar.md +33 -0
- package/docs/admin-dns.md +80 -0
- package/docs/admin-identity-users.md +103 -0
- package/docs/admin-incidents.md +71 -0
- package/docs/admin-jobs.md +426 -0
- package/docs/admin-members.md +112 -0
- package/docs/admin-messaging.md +56 -0
- package/docs/admin-metrics-explorer.md +63 -0
- package/docs/admin-monitoring.md +99 -0
- package/docs/admin-network-security.md +393 -0
- package/docs/admin-phonehub.md +27 -0
- package/docs/admin-push.md +36 -0
- package/docs/admin-rules.md +87 -0
- package/docs/admin-security-tickets.md +40 -0
- package/docs/admin-settings.md +78 -0
- package/docs/admin-shortlinks.md +32 -0
- package/docs/admin-storage.md +141 -0
- package/docs/auth-pages.md +212 -0
- package/docs/auth.md +58 -0
- package/docs/calendar.md +122 -0
- package/docs/charts.md +303 -0
- package/docs/client.md +206 -0
- package/docs/collection-multiselect.md +117 -0
- package/docs/collection-select.md +132 -0
- package/docs/combobox.md +105 -0
- package/docs/dataview.md +226 -0
- package/docs/datepicker.md +151 -0
- package/docs/daterange.md +170 -0
- package/docs/datetimepicker.md +249 -0
- package/docs/defineModel.md +72 -0
- package/docs/detail-primitives.md +174 -0
- package/docs/feedback.md +85 -0
- package/docs/file-upload-ux.md +146 -0
- package/docs/forms.md +435 -0
- package/docs/grouping-and-fmt.md +98 -0
- package/docs/idioms.md +112 -0
- package/docs/image-editor.md +101 -0
- package/docs/loading.md +242 -0
- package/docs/location-address.md +76 -0
- package/docs/markdown.md +203 -0
- package/docs/menus-and-access.md +89 -0
- package/docs/multiselect-dropdown.md +153 -0
- package/docs/params.md +73 -0
- package/docs/popover.md +130 -0
- package/docs/realtime.md +61 -0
- package/docs/record-feed.md +238 -0
- package/docs/right-panel.md +42 -0
- package/docs/tabs-and-form-wizard.md +60 -0
- package/docs/taginput.md +120 -0
- package/docs/timepicker.md +173 -0
- package/docs/timezone-select.md +147 -0
- package/docs/worldmap.md +302 -0
- package/package.json +55 -0
- package/src/admin/assistant/AssistantFeed.tsx +29 -0
- package/src/admin/assistant/AssistantPanel.tsx +65 -0
- package/src/admin/assistant/api.ts +17 -0
- package/src/admin/assistant/data.ts +63 -0
- package/src/admin/assistant/index.ts +21 -0
- package/src/admin/assistant/launchers.tsx +33 -0
- package/src/admin/assistant/pages.tsx +78 -0
- package/src/admin/assistant/streaming.ts +238 -0
- package/src/admin/assistant/types.ts +30 -0
- package/src/admin/bouncer/devices.tsx +230 -0
- package/src/admin/bouncer/index.ts +34 -0
- package/src/admin/bouncer/models.ts +199 -0
- package/src/admin/bouncer/signals.tsx +182 -0
- package/src/admin/bouncer/signatures.tsx +220 -0
- package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
- package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
- package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
- package/src/admin/cloudwatch/data.ts +163 -0
- package/src/admin/cloudwatch/index.ts +16 -0
- package/src/admin/credentials/group-api-keys.tsx +433 -0
- package/src/admin/credentials/index.ts +4 -0
- package/src/admin/credentials/models.ts +244 -0
- package/src/admin/credentials/secret-dialog.tsx +77 -0
- package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
- package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
- package/src/admin/dashboard/data.ts +60 -0
- package/src/admin/dashboard/index.ts +2 -0
- package/src/admin/dns/CertificateDetail.tsx +115 -0
- package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
- package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
- package/src/admin/dns/CertificatesPage.tsx +85 -0
- package/src/admin/dns/DnsRecordEditor.tsx +110 -0
- package/src/admin/dns/DnsRecordsPage.tsx +36 -0
- package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
- package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
- package/src/admin/dns/DomainDetail.tsx +41 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
- package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
- package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
- package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
- package/src/admin/dns/DomainsPage.tsx +27 -0
- package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
- package/src/admin/dns/RegistrantContactPage.tsx +42 -0
- package/src/admin/dns/api.ts +515 -0
- package/src/admin/dns/certificate-data.ts +221 -0
- package/src/admin/dns/data.ts +32 -0
- package/src/admin/dns/dns-data.ts +266 -0
- package/src/admin/dns/dns-integration.ts +24 -0
- package/src/admin/dns/domain-links.ts +37 -0
- package/src/admin/dns/index.ts +86 -0
- package/src/admin/dns/models.ts +418 -0
- package/src/admin/dns/purchase-data.ts +67 -0
- package/src/admin/dns/registrant-data.ts +52 -0
- package/src/admin/identity/index.ts +5 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
- package/src/admin/identity/members/MemberDetail.tsx +226 -0
- package/src/admin/identity/members/MembersPage.tsx +144 -0
- package/src/admin/identity/members/index.ts +24 -0
- package/src/admin/identity/members/member-flows.tsx +132 -0
- package/src/admin/identity/members/models.ts +204 -0
- package/src/admin/identity/users/UserDetail.tsx +257 -0
- package/src/admin/identity/users/UsersPage.tsx +163 -0
- package/src/admin/identity/users/index.ts +27 -0
- package/src/admin/identity/users/models.ts +232 -0
- package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
- package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
- package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
- package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
- package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
- package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
- package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
- package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
- package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
- package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
- package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
- package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
- package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
- package/src/admin/identity/users/sections/actions.tsx +752 -0
- package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
- package/src/admin/identity/users/sections/queries.ts +74 -0
- package/src/admin/identity/users/sections/shared.tsx +299 -0
- package/src/admin/incidents/EventDetail.tsx +43 -0
- package/src/admin/incidents/EventsPage.tsx +52 -0
- package/src/admin/incidents/IncidentDetail.tsx +68 -0
- package/src/admin/incidents/IncidentsPage.tsx +102 -0
- package/src/admin/incidents/forensics.tsx +73 -0
- package/src/admin/incidents/index.ts +16 -0
- package/src/admin/incidents/models.ts +113 -0
- package/src/admin/incidents/sanitize.ts +71 -0
- package/src/admin/index.ts +383 -0
- package/src/admin/jobs/JobDashboardPage.tsx +111 -0
- package/src/admin/jobs/JobDetail.tsx +721 -0
- package/src/admin/jobs/JobRunnersPage.tsx +216 -0
- package/src/admin/jobs/JobsTablePage.tsx +122 -0
- package/src/admin/jobs/RunnerDetail.tsx +576 -0
- package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
- package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
- package/src/admin/jobs/columns.tsx +297 -0
- package/src/admin/jobs/control.ts +488 -0
- package/src/admin/jobs/index.ts +85 -0
- package/src/admin/jobs/models.ts +599 -0
- package/src/admin/jobs/queries.ts +272 -0
- package/src/admin/jobs/scheduled-task-form.tsx +493 -0
- package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
- package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
- package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
- package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
- package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
- package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
- package/src/admin/messaging/MailboxesPage.tsx +15 -0
- package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
- package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
- package/src/admin/messaging/SentMessagesPage.tsx +11 -0
- package/src/admin/messaging/api.ts +17 -0
- package/src/admin/messaging/data.ts +10 -0
- package/src/admin/messaging/index.ts +23 -0
- package/src/admin/messaging/models.ts +76 -0
- package/src/admin/messaging/push/PushPage.tsx +44 -0
- package/src/admin/messaging/push/api.ts +17 -0
- package/src/admin/messaging/push/index.ts +9 -0
- package/src/admin/messaging/push/models.ts +43 -0
- package/src/admin/messaging/sanitize.ts +44 -0
- package/src/admin/monitoring/LogInspector.tsx +160 -0
- package/src/admin/monitoring/LogsPage.tsx +135 -0
- package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
- package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
- package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
- package/src/admin/monitoring/index.ts +55 -0
- package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
- package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
- package/src/admin/monitoring/models.ts +155 -0
- package/src/admin/network/BlockedIPsPage.tsx +194 -0
- package/src/admin/network/BlockedIpDetail.tsx +262 -0
- package/src/admin/network/FirewallLogPage.tsx +115 -0
- package/src/admin/network/IPSetDetail.tsx +280 -0
- package/src/admin/network/IPSetEditor.tsx +191 -0
- package/src/admin/network/IPSetsPage.tsx +177 -0
- package/src/admin/network/geofence/BlocksTab.tsx +289 -0
- package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
- package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
- package/src/admin/network/geofence/PostureHeader.tsx +184 -0
- package/src/admin/network/geofence/RuleEditor.tsx +176 -0
- package/src/admin/network/geofence/RulesTab.tsx +254 -0
- package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
- package/src/admin/network/geofence/geofence-data.ts +674 -0
- package/src/admin/network/index.ts +68 -0
- package/src/admin/network/models.ts +607 -0
- package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
- package/src/admin/phonehub/api.ts +47 -0
- package/src/admin/phonehub/data.ts +6 -0
- package/src/admin/phonehub/index.ts +10 -0
- package/src/admin/phonehub/models.ts +68 -0
- package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
- package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
- package/src/admin/rules/RuleSetsPage.tsx +33 -0
- package/src/admin/rules/editors.tsx +54 -0
- package/src/admin/rules/handler-dsl.ts +213 -0
- package/src/admin/rules/index.ts +13 -0
- package/src/admin/rules/models.ts +112 -0
- package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
- package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
- package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
- package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
- package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
- package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
- package/src/admin/security/devices/index.ts +40 -0
- package/src/admin/security/devices/models.ts +470 -0
- package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
- package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
- package/src/admin/security/geoip/geoip-forms.ts +102 -0
- package/src/admin/security/geoip/index.ts +27 -0
- package/src/admin/security/geoip/models.ts +397 -0
- package/src/admin/security/index.ts +31 -0
- package/src/admin/security/models.ts +137 -0
- package/src/admin/security/tickets.tsx +562 -0
- package/src/admin/security-permissions.ts +5 -0
- package/src/admin/settings/SettingDetail.tsx +57 -0
- package/src/admin/settings/SettingEditor.tsx +157 -0
- package/src/admin/settings/SettingsPage.tsx +72 -0
- package/src/admin/settings/index.ts +19 -0
- package/src/admin/settings/model.ts +143 -0
- package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
- package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
- package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
- package/src/admin/shortlinks/index.ts +18 -0
- package/src/admin/shortlinks/models.ts +246 -0
- package/src/admin/storage/BackendsPage.tsx +43 -0
- package/src/admin/storage/BucketDetail.tsx +77 -0
- package/src/admin/storage/BucketsPage.tsx +64 -0
- package/src/admin/storage/FileManagerDetail.tsx +36 -0
- package/src/admin/storage/FilePreview.tsx +54 -0
- package/src/admin/storage/FileUploadSurface.tsx +250 -0
- package/src/admin/storage/FileView.tsx +109 -0
- package/src/admin/storage/FilesPage.tsx +53 -0
- package/src/admin/storage/file-renditions.ts +99 -0
- package/src/admin/storage/index.ts +31 -0
- package/src/admin/storage/models.ts +585 -0
- package/src/admin/storage/storage-dialogs.tsx +80 -0
- package/src/charts/CircularProgress.tsx +281 -0
- package/src/charts/KPIStrip.tsx +162 -0
- package/src/charts/KPITile.tsx +125 -0
- package/src/charts/MetricsChart.tsx +459 -0
- package/src/charts/MetricsMiniWidget.tsx +588 -0
- package/src/charts/MiniChart.tsx +283 -0
- package/src/charts/PieChart.tsx +354 -0
- package/src/charts/SeriesChart.tsx +378 -0
- package/src/charts/chart-dialogs.tsx +149 -0
- package/src/charts/exportChart.ts +144 -0
- package/src/charts/index.ts +28 -0
- package/src/charts/pie-math.ts +127 -0
- package/src/charts/stats.ts +265 -0
- package/src/charts/worldmap/WorldMap.tsx +649 -0
- package/src/charts/worldmap/countryCentroids.ts +304 -0
- package/src/charts/worldmap/geo.ts +293 -0
- package/src/charts/worldmap/use-world-land.ts +47 -0
- package/src/charts/worldmap/world-land.ts +18 -0
- package/src/charts/worldmap/worldmap-data.ts +285 -0
- package/src/client/auth.ts +737 -0
- package/src/client/client.ts +268 -0
- package/src/client/duid.ts +31 -0
- package/src/client/errors.ts +33 -0
- package/src/client/group-context.ts +28 -0
- package/src/client/group.tsx +126 -0
- package/src/client/hooks.ts +50 -0
- package/src/client/index.ts +37 -0
- package/src/client/jwt.ts +75 -0
- package/src/client/location.ts +285 -0
- package/src/client/lookups.ts +41 -0
- package/src/client/markdown-fallback.tsx +115 -0
- package/src/client/markdown-parse.ts +386 -0
- package/src/client/markdown-sanitize.ts +217 -0
- package/src/client/markdown.ts +122 -0
- package/src/client/me.ts +188 -0
- package/src/client/mock.ts +9984 -0
- package/src/client/model.ts +227 -0
- package/src/client/params.ts +292 -0
- package/src/client/realtime-mock.ts +221 -0
- package/src/client/realtime.ts +561 -0
- package/src/client/record-feed.ts +329 -0
- package/src/client/safe-export.ts +71 -0
- package/src/client/types.ts +278 -0
- package/src/client/upload.ts +421 -0
- package/src/ui/AddressField.tsx +273 -0
- package/src/ui/AttachmentQueue.tsx +92 -0
- package/src/ui/CollectionMultiSelect.tsx +622 -0
- package/src/ui/CollectionSelect.tsx +517 -0
- package/src/ui/ComboBox.tsx +336 -0
- package/src/ui/DetailView.tsx +438 -0
- package/src/ui/FileDrop.tsx +194 -0
- package/src/ui/FileField.tsx +369 -0
- package/src/ui/FilterBar.tsx +269 -0
- package/src/ui/FormFields.tsx +77 -0
- package/src/ui/FormView.tsx +360 -0
- package/src/ui/FormWizard.tsx +336 -0
- package/src/ui/GroupSwitcher.tsx +202 -0
- package/src/ui/Guarded.tsx +16 -0
- package/src/ui/MarkdownView.tsx +89 -0
- package/src/ui/ModelTable.tsx +848 -0
- package/src/ui/MultiSelectDropdown.tsx +390 -0
- package/src/ui/Popover.tsx +204 -0
- package/src/ui/RecordFeed.tsx +627 -0
- package/src/ui/RequiresGroup.tsx +32 -0
- package/src/ui/RightPanel.tsx +107 -0
- package/src/ui/SidebarNav.tsx +330 -0
- package/src/ui/Tabs.tsx +161 -0
- package/src/ui/TagInput.tsx +381 -0
- package/src/ui/ThemeProvider.tsx +52 -0
- package/src/ui/UploadQueue.tsx +603 -0
- package/src/ui/armed-button.tsx +91 -0
- package/src/ui/date/Calendar.tsx +498 -0
- package/src/ui/date/DatePicker.tsx +289 -0
- package/src/ui/date/DateRangePicker.tsx +280 -0
- package/src/ui/date/DateTimePicker.tsx +587 -0
- package/src/ui/date/PresetRail.tsx +165 -0
- package/src/ui/date/TimePicker.tsx +624 -0
- package/src/ui/date/TimezoneSelect.tsx +267 -0
- package/src/ui/date/fns.ts +679 -0
- package/src/ui/detail/DataView.tsx +692 -0
- package/src/ui/detail/FlowStrip.tsx +80 -0
- package/src/ui/detail/KnownFieldsCard.tsx +121 -0
- package/src/ui/detail/MetadataSection.tsx +219 -0
- package/src/ui/detail/StackTraceView.tsx +150 -0
- package/src/ui/detail/StatusPanel.tsx +73 -0
- package/src/ui/detail/Timeline.tsx +52 -0
- package/src/ui/detail/index.ts +12 -0
- package/src/ui/field-registry.tsx +428 -0
- package/src/ui/field-wire.ts +322 -0
- package/src/ui/form-autosave.ts +583 -0
- package/src/ui/format.ts +444 -0
- package/src/ui/grouping.ts +150 -0
- package/src/ui/image-editor/ImageEditor.tsx +484 -0
- package/src/ui/image-editor/canvas.ts +254 -0
- package/src/ui/image-editor/index.ts +3 -0
- package/src/ui/image-editor/math.ts +416 -0
- package/src/ui/index.ts +58 -0
- package/src/ui/loading.tsx +604 -0
- package/src/ui/menu-registry.ts +213 -0
- package/src/ui/modal.tsx +297 -0
- package/src/ui/password.tsx +302 -0
- package/src/ui/schema-form-core.tsx +315 -0
- package/src/ui/toast.tsx +219 -0
- package/src/ui/ui.tsx +45 -0
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "portal-mojo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React portal toolkit for django-mojo — typed client + params store, mission-control UI, dependency-free SVG charts, admin section bundles.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/NativeMojo/portal-mojo.git",
|
|
10
|
+
"directory": "packages/portal-mojo"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/NativeMojo/portal-mojo#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/NativeMojo/portal-mojo/issues"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"docs",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public",
|
|
24
|
+
"registry": "https://registry.npmjs.org/",
|
|
25
|
+
"provenance": true
|
|
26
|
+
},
|
|
27
|
+
"exports": {
|
|
28
|
+
"./client": "./src/client/index.ts",
|
|
29
|
+
"./ui": "./src/ui/index.ts",
|
|
30
|
+
"./charts": "./src/charts/index.ts",
|
|
31
|
+
"./admin": "./src/admin/index.ts"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"typecheck": "tsc --noEmit"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"zod": "^4.4.3"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@tanstack/react-query": "^5",
|
|
41
|
+
"react": "^19",
|
|
42
|
+
"react-dom": "^19",
|
|
43
|
+
"react-router-dom": "^7"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@tanstack/react-query": "^5.62.0",
|
|
47
|
+
"@types/react": "^19.2.7",
|
|
48
|
+
"@types/react-dom": "^19.2.3",
|
|
49
|
+
"react": "^19.2.0",
|
|
50
|
+
"react-dom": "^19.2.0",
|
|
51
|
+
"react-router-dom": "^7.6.1",
|
|
52
|
+
"typescript": "~5.9.3",
|
|
53
|
+
"vite": "^7.1.0"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import type { FileReference, RecordFeedItem } from '../../client/record-feed';
|
|
3
|
+
import { AttachmentQueue, RecordFeed, fmt, modal, type AttachmentQueueState } from '../../ui';
|
|
4
|
+
import { isSafeCapabilityUrl, openCapabilityUrl } from '../storage/models';
|
|
5
|
+
import type { AssistantBlock, AssistantMessage, AssistantUser } from './types';
|
|
6
|
+
|
|
7
|
+
function BlockView({ block, onAction, disabled }: { block: AssistantBlock; onAction(value: string): Promise<void>; disabled: boolean }) {
|
|
8
|
+
if (block.type === 'table') return <section className="assistant-block"><h4>{block.title ?? 'Results'}</h4><div className="assistant-table-scroll"><table><thead><tr>{block.columns.map((column) => <th key={column}>{column}</th>)}</tr></thead><tbody>{block.rows.map((row, index) => <tr key={index}>{row.map((cell, cellIndex) => <td key={cellIndex}>{String(cell ?? '—')}</td>)}</tr>)}</tbody></table></div></section>;
|
|
9
|
+
if (block.type === 'chart') return <section className="assistant-block"><h4>{block.title ?? 'Chart data'}</h4><div className="assistant-chart" role="img" aria-label={`${block.chart_type} chart: ${block.title ?? 'Assistant chart'}`}>{block.labels.map((label, index) => <div key={`${label}:${index}`}><strong>{label}</strong>{block.series.map((series) => <span key={series.name}>{series.name}: {series.values[index]}</span>)}</div>)}</div></section>;
|
|
10
|
+
if (block.type === 'stat' || block.type === 'list') return <section className={`assistant-block assistant-${block.type}`}><h4>{block.title ?? (block.type === 'stat' ? 'Summary' : 'Details')}</h4><dl>{block.items.map((item) => <div key={item.label}><dt>{item.label}</dt><dd>{String(item.value ?? '—')}</dd></div>)}</dl></section>;
|
|
11
|
+
if (block.type === 'alert') return <section className={`assistant-block assistant-alert assistant-alert-${block.level}`} role={block.level === 'error' ? 'alert' : undefined}><strong>{block.title ?? block.level}</strong><p>{block.message}</p></section>;
|
|
12
|
+
if (block.type === 'file') return <section className="assistant-block assistant-file"><i className="bi bi-file-earmark-arrow-down" aria-hidden="true" /><span><strong>{block.filename}</strong><small>{[block.format, block.size != null ? fmt.filesize(block.size) : null, block.row_count != null ? `${block.row_count} rows` : null, block.expires_in].filter(Boolean).join(' · ')}</small></span>{isSafeCapabilityUrl(block.url) ? <button type="button" className="btn btn-compact" onClick={() => openCapabilityUrl(block.url, true)}>Download</button> : <span className="storage-unsafe">Unsafe URL withheld</span>}</section>;
|
|
13
|
+
if (block.type === 'attachment') return <section className="assistant-block assistant-attachments" aria-label="Message attachments">{block.files.map((file) => <span className="assistant-attachment-chip" key={file.id}><i className="bi bi-paperclip" aria-hidden="true" /> <strong>{file.filename}</strong><small>{file.content_type}</small></span>)}</section>;
|
|
14
|
+
if (block.type === 'context') return <section className="assistant-block"><h4>References</h4><ul>{block.references.map((reference) => <li key={`${reference.model}:${reference.pk}`}><code>{reference.model} #{reference.pk}</code>{reference.label ? ` — ${reference.label}` : ''}</li>)}</ul></section>;
|
|
15
|
+
return <section className="assistant-block assistant-action"><h4>{block.title ?? 'Confirm action'}</h4>{block.description && <p>{block.description}</p>}<div className="assistant-action-row">{block.actions.map((action) => <button key={`${block.action_id ?? block.title}:${action.value}`} type="button" className="btn btn-compact" disabled={disabled} onClick={async () => { const confirmed = await modal.confirm({ title: `${action.label}?`, message: `Send “${action.value}” to the Assistant as your response?`, confirmText: action.label }); if (confirmed) await onAction(action.value); }}>{action.label}</button>)}</div></section>;
|
|
16
|
+
}
|
|
17
|
+
export function AssistantFeed({ messages, user, onSend, responding, disabled = false, attachmentKey = 'new' }: { messages: AssistantMessage[]; user: AssistantUser; onSend(text: string, attachments?: readonly FileReference[]): Promise<void>; responding: boolean; disabled?: boolean; attachmentKey?: number | string }) {
|
|
18
|
+
const actionFlight = useRef(false);
|
|
19
|
+
const [actionError, setActionError] = useState('');
|
|
20
|
+
const [attachmentState, setAttachmentState] = useState<AttachmentQueueState>({ files: [], busy: false });
|
|
21
|
+
const [attachmentReset, setAttachmentReset] = useState(0);
|
|
22
|
+
const attachmentChange = useCallback((state: AttachmentQueueState) => setAttachmentState(state), []);
|
|
23
|
+
useEffect(() => { if (disabled) setAttachmentState({ files: [], busy: false }); }, [disabled]);
|
|
24
|
+
const items = useMemo<RecordFeedItem[]>(() => messages.map((message) => ({ id: message.id, kind: message.role === 'assistant' ? 'assistant' : 'comment', created: message.created, content: message.content, author: message.role === 'assistant' ? { id: null, name: 'AI Agent' } : { id: user.id, name: user.display_name }, metadata: {}, raw: { blocks: message.blocks } })), [messages, user.id, user.display_name]);
|
|
25
|
+
const sendAction = async (value: string) => { if (actionFlight.current || responding || disabled) return; actionFlight.current = true; setActionError(''); try { await onSend(value, []); } catch (error) { setActionError(error instanceof Error ? error.message : 'Action response failed'); } finally { actionFlight.current = false; } };
|
|
26
|
+
const send = async (text: string) => { await onSend(text, attachmentState.files); setAttachmentReset((value) => value + 1); };
|
|
27
|
+
const composerAddon = disabled ? null : <AttachmentQueue key={`assistant:${user.id}:${attachmentKey}:personal`} consumerKey={`assistant:${user.id}:${attachmentKey}:personal`} capacity={5} destination={{}} expectedGroupId={null} disabled={responding} resetKey={attachmentReset} onChange={attachmentChange} />;
|
|
28
|
+
return <RecordFeed items={items} onSend={send} isSending={responding} disabled={disabled} submitBlocked={attachmentState.busy} composerAddon={composerAddon} currentUserId={user.id} variant="bubbles" placeholder={disabled ? 'Only the owner can continue this conversation' : 'Message Assistant…'} sendLabel="Send" emptyLabel="Ask the Assistant about this deployment." ariaLabel="Assistant conversation" pending={responding ? <>Responding…</> : null} error={actionError || undefined} renderAddon={(item) => { const blocks = Array.isArray(item.raw.blocks) ? item.raw.blocks as AssistantBlock[] : []; return blocks.length ? <div className="assistant-blocks">{blocks.map((entry, index) => <BlockView key={`${item.id}:${index}`} block={entry} onAction={sendAction} disabled={responding || disabled || actionFlight.current} />)}</div> : null; }} />;
|
|
29
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useCan, useMe, useRealtime, useRealtimeStatus } from '../../client';
|
|
3
|
+
import type { RightPanelRenderContext } from '../../ui';
|
|
4
|
+
import { AssistantFeed } from './AssistantFeed';
|
|
5
|
+
import { sendAssistantMessage } from './api';
|
|
6
|
+
import type { AssistantConversation, AssistantMessage } from './types';
|
|
7
|
+
import type { FileReference } from '../../client/record-feed';
|
|
8
|
+
import { AssistantOutcomeUnknownError, chooseAssistantTransport, startAssistantRealtimeTurn, type AssistantRealtimeProgress, type AssistantRealtimeTurn } from './streaming';
|
|
9
|
+
|
|
10
|
+
export const ASSISTANT_PERMISSIONS = ['sys.view_admin', 'sys.assistant'];
|
|
11
|
+
|
|
12
|
+
export function AssistantPanel({ context, initialConversation = null }: { context: RightPanelRenderContext; initialConversation?: AssistantConversation | null }) {
|
|
13
|
+
const { can } = useCan(ASSISTANT_PERMISSIONS);
|
|
14
|
+
const { can: canViewAdmin } = useCan('sys.view_admin');
|
|
15
|
+
const { data: me } = useMe();
|
|
16
|
+
const realtime = useRealtime();
|
|
17
|
+
const realtimeStatus = useRealtimeStatus();
|
|
18
|
+
const [conversation, setConversation] = useState(initialConversation);
|
|
19
|
+
const [messages, setMessages] = useState<AssistantMessage[]>(initialConversation?.messages ?? []);
|
|
20
|
+
const [responding, setResponding] = useState(false);
|
|
21
|
+
const [error, setError] = useState('');
|
|
22
|
+
const [streamProgress, setStreamProgress] = useState<AssistantRealtimeProgress | null>(null);
|
|
23
|
+
const [streamingActive, setStreamingActive] = useState(false);
|
|
24
|
+
const flight = useRef(false);
|
|
25
|
+
const stream = useRef<AssistantRealtimeTurn | null>(null);
|
|
26
|
+
const seenMessageIds = useRef(new Set<number | string>());
|
|
27
|
+
useEffect(() => { if (!can) { stream.current?.cancel(); stream.current = null; setStreamProgress(null); setStreamingActive(false); setMessages(conversation?.messages ?? []); context.close(); } }, [can, context, conversation]);
|
|
28
|
+
useEffect(() => { if (!canViewAdmin && stream.current) { stream.current.cancel(); stream.current = null; setStreamProgress(null); setStreamingActive(false); setMessages(conversation?.messages ?? []); } }, [canViewAdmin, conversation]);
|
|
29
|
+
useEffect(() => { setConversation(initialConversation); setMessages(initialConversation?.messages ?? []); seenMessageIds.current = new Set(initialConversation?.messages.map((message) => message.id) ?? []); setStreamProgress(null); setError(''); }, [initialConversation?.id]);
|
|
30
|
+
useEffect(() => () => { stream.current?.cancel(); stream.current = null; }, []);
|
|
31
|
+
if (!can || !me) return null;
|
|
32
|
+
const owner = conversation == null || conversation.user.id === me.id;
|
|
33
|
+
const send = async (text: string, attachments: readonly FileReference[] = []) => {
|
|
34
|
+
if (flight.current || !owner) return;
|
|
35
|
+
flight.current = true; setResponding(true); setError('');
|
|
36
|
+
const temporary: AssistantMessage = { id: `local-${Date.now()}`, role: 'user', content: text, created: Math.floor(Date.now() / 1000), blocks: attachments.length ? [{ type: 'attachment', files: [...attachments] }] : [] };
|
|
37
|
+
setMessages((current) => [...current, temporary]);
|
|
38
|
+
try {
|
|
39
|
+
const transport = chooseAssistantTransport({ owner, textOnly: attachments.length === 0, hasViewAdmin: canViewAdmin, realtimeStatus: realtimeStatus.status });
|
|
40
|
+
if (transport === 'websocket') {
|
|
41
|
+
setStreamingActive(true);
|
|
42
|
+
const turn = startAssistantRealtimeTurn(realtime, { message: text, conversationId: conversation?.id, seenMessageIds: seenMessageIds.current }, {
|
|
43
|
+
onConversation: (id) => setConversation((current) => current ?? { id, title: text.slice(0, 80), created: temporary.created, modified: temporary.created, user: { id: me.id, display_name: me.display_name ?? 'You' }, messages: [] }),
|
|
44
|
+
onText: (message) => setMessages((current) => [...current, message]),
|
|
45
|
+
onProgress: setStreamProgress,
|
|
46
|
+
onResponse: (message) => setMessages((current) => current.some((item) => item.id === message.id) ? current : [...current, message]),
|
|
47
|
+
onReconcile: (next) => { setConversation(next); setMessages(next.messages); },
|
|
48
|
+
onUnknown: () => { window.dispatchEvent(new CustomEvent('portal-mojo:assistant-list-refresh')); },
|
|
49
|
+
});
|
|
50
|
+
stream.current = turn;
|
|
51
|
+
await turn.promise;
|
|
52
|
+
stream.current = null;
|
|
53
|
+
} else {
|
|
54
|
+
const reply = await sendAssistantMessage(text, conversation?.id, attachments);
|
|
55
|
+
const assistant: AssistantMessage = { id: `local-assistant-${Date.now()}`, role: 'assistant', content: reply.response, created: Math.floor(Date.now() / 1000), blocks: reply.blocks };
|
|
56
|
+
setMessages((current) => [...current, assistant]);
|
|
57
|
+
setConversation((current) => current ?? { id: reply.conversation_id, title: text.slice(0, 80), created: temporary.created, modified: assistant.created, user: { id: me.id, display_name: me.display_name ?? 'You' }, messages: [] });
|
|
58
|
+
}
|
|
59
|
+
} catch (cause) {
|
|
60
|
+
if (!(cause instanceof AssistantOutcomeUnknownError)) setMessages((current) => current.filter((message) => message.id !== temporary.id));
|
|
61
|
+
const message = cause instanceof Error ? cause.message : 'Assistant request failed'; setError(message); throw cause;
|
|
62
|
+
} finally { stream.current = null; setStreamProgress(null); setStreamingActive(false); flight.current = false; setResponding(false); }
|
|
63
|
+
};
|
|
64
|
+
return <div className="assistant-panel"><div className="assistant-panel-meta"><span>{conversation ? conversation.title : 'New conversation'}</span>{conversation && !owner && <span className="chip chip-muted">Inspect only</span>}{responding && <span>{streamingActive ? streamProgress?.tools.length ? `${streamProgress.tools.reduce((sum, tool) => sum + tool.count, 0)} tool call${streamProgress.tools.reduce((sum, tool) => sum + tool.count, 0) === 1 ? '' : 's'}` : realtimeStatus.status === 'ready' ? 'Streaming…' : 'Reconciling…' : 'Responding…'}</span>}</div>{streamProgress?.tools.length ? <div className="assistant-stream-tools" aria-label="Assistant tool progress">{streamProgress.tools.map((tool) => <span className="chip chip-muted" key={tool.name}>{tool.name} · {tool.status}{tool.count > 1 ? ` ×${tool.count}` : ''}</span>)}</div> : null}{error && <div className="form-alert" role="alert">{error}</div>}<AssistantFeed messages={messages} user={{ id: me.id, display_name: me.display_name ?? 'You' }} onSend={send} responding={responding} disabled={!owner} attachmentKey={conversation?.id ?? 'new'} /></div>;
|
|
65
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { mojoCall, mojoDelete, mojoList, type Params } from '../../client';
|
|
2
|
+
import { projectConversation, projectConversationSummary, projectReply, projectSkill } from './data';
|
|
3
|
+
import type { AssistantConversation, AssistantConversationSummary, AssistantReply, AssistantSkill } from './types';
|
|
4
|
+
import type { FileReference } from '../../client/record-feed';
|
|
5
|
+
|
|
6
|
+
const malformed = (label: string): never => { throw new Error(`${label} returned an invalid response`); };
|
|
7
|
+
export async function sendAssistantMessage(message: string, conversationId?: number, attachments: readonly FileReference[] = []): Promise<AssistantReply> { const ids = attachments.map((file) => file.id); if (ids.length > 5 || new Set(ids).size !== ids.length || ids.some((id) => !Number.isSafeInteger(id) || id <= 0)) throw new Error('Assistant attachments must be 1–5 unique completed File references'); const body = await mojoCall('/api/assistant', { method: 'POST', body: { message, ...(conversationId != null ? { conversation_id: conversationId } : {}), ...(ids.length ? { attachments: ids } : {}) } }); return projectReply(body.data) ?? malformed('Assistant'); }
|
|
8
|
+
export async function createAssistantContext(model: 'incident.Incident' | 'incident.Ticket', pk: number): Promise<AssistantConversation> { const body = await mojoCall('/api/assistant/context', { method: 'POST', body: { model, pk } }); const data = body.data as Record<string, unknown> | undefined; const id = typeof data?.conversation_id === 'number' ? data.conversation_id : null; if (id == null) return malformed('Assistant context'); return getAssistantConversation(id); }
|
|
9
|
+
export async function getAssistantConversation(id: number): Promise<AssistantConversation> { const body = await mojoCall(`/api/assistant/conversation/${id}`, { params: { graph: 'detail' } }); return projectConversation(body.data) ?? malformed('Conversation'); }
|
|
10
|
+
export async function listAssistantConversations(params: Params = {}): Promise<{ rows: AssistantConversationSummary[]; count: number }> { const page = await mojoList<unknown>('/api/assistant/conversation', { start: 0, size: 100, sort: '-modified', ...params }); return { rows: page.rows.map(projectConversationSummary).filter((v): v is AssistantConversationSummary => v != null), count: page.count }; }
|
|
11
|
+
export const deleteAssistantConversation = (id: number): Promise<void> => mojoDelete('/api/assistant/conversation', id);
|
|
12
|
+
export async function getAssistantSkill(id: number): Promise<AssistantSkill> { const body = await mojoCall(`/api/assistant/skill/${id}`, { params: { graph: 'detail' } }); return projectSkill(body.data) ?? malformed('Skill'); }
|
|
13
|
+
export async function listAssistantSkills(params: Params = {}): Promise<{ rows: AssistantSkill[]; count: number }> { const page = await mojoList<unknown>('/api/assistant/skill', { start: 0, size: 100, sort: 'name', ...params }); return { rows: page.rows.map(projectSkill).filter((v): v is AssistantSkill => v != null), count: page.count }; }
|
|
14
|
+
export const deleteAssistantSkill = (id: number): Promise<void> => mojoDelete('/api/assistant/skill', id);
|
|
15
|
+
export async function getAssistantMemory(tier: 'global' | 'user' | 'group', group?: number): Promise<Record<string, unknown>> { const body = await mojoCall(`/api/assistant/memory/${tier}`, { params: group == null ? {} : { group } }); return body.data && typeof body.data === 'object' && !Array.isArray(body.data) ? body.data as Record<string, unknown> : {}; }
|
|
16
|
+
export async function saveAssistantMemory(tier: 'global' | 'user' | 'group', key: string, value: string, group?: number): Promise<void> { await mojoCall(`/api/assistant/memory/${tier}`, { method: 'POST', params: group == null ? {} : { group }, body: { key, value } }); }
|
|
17
|
+
export async function deleteAssistantMemory(tier: 'global' | 'user' | 'group', key: string, group?: number): Promise<void> { await mojoCall(`/api/assistant/memory/${tier}/${encodeURIComponent(key)}`, { method: 'DELETE', params: group == null ? {} : { group } }); }
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { AssistantBlock, AssistantConversation, AssistantConversationSummary, AssistantMessage, AssistantReply, AssistantScalar, AssistantSkill, AssistantUser } from './types';
|
|
2
|
+
import { safeFileReference } from '../../client/record-feed';
|
|
3
|
+
|
|
4
|
+
const MAX_BLOCKS = 8;
|
|
5
|
+
const MAX_TEXT = 4_000;
|
|
6
|
+
const MAX_ITEMS = 24;
|
|
7
|
+
const MAX_COLUMNS = 12;
|
|
8
|
+
const MAX_ROWS = 50;
|
|
9
|
+
const obj = (value: unknown): Record<string, unknown> | null => value != null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
10
|
+
const text = (value: unknown, cap = MAX_TEXT): string => typeof value === 'string' ? value.slice(0, cap) : '';
|
|
11
|
+
const id = (value: unknown): number | string | null => typeof value === 'number' || typeof value === 'string' ? value : null;
|
|
12
|
+
const number = (value: unknown): number | null => typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
13
|
+
const scalar = (value: unknown): AssistantScalar | null => value === null || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value)) ? value : typeof value === 'string' ? value.slice(0, 500) : null;
|
|
14
|
+
|
|
15
|
+
function pairItems(value: unknown): Array<{ label: string; value: AssistantScalar }> | null {
|
|
16
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_ITEMS) return null;
|
|
17
|
+
const out = value.map(obj).map((item) => item && text(item.label, 120) && scalar(item.value) !== null ? { label: text(item.label, 120), value: scalar(item.value)! } : null);
|
|
18
|
+
return out.every(Boolean) ? out as Array<{ label: string; value: AssistantScalar }> : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function block(value: unknown, allowAttachment = false): AssistantBlock | null {
|
|
22
|
+
const raw = obj(value); if (!raw || typeof raw.type !== 'string') return null;
|
|
23
|
+
const title = text(raw.title, 200) || undefined;
|
|
24
|
+
if (raw.type === 'table') {
|
|
25
|
+
if (!Array.isArray(raw.columns) || !Array.isArray(raw.rows) || raw.columns.length === 0 || raw.columns.length > MAX_COLUMNS || raw.rows.length > MAX_ROWS) return null;
|
|
26
|
+
const columns = raw.columns.map((v) => text(v, 120)); if (columns.some((v) => !v)) return null;
|
|
27
|
+
const rows = raw.rows.map((row) => Array.isArray(row) && row.length === columns.length ? row.map(scalar) : null);
|
|
28
|
+
if (rows.some((row) => !row || row.some((v) => v === null))) return null;
|
|
29
|
+
return { type: 'table', title, columns, rows: rows as AssistantScalar[][] };
|
|
30
|
+
}
|
|
31
|
+
if (raw.type === 'chart') {
|
|
32
|
+
if (!['line', 'bar', 'pie', 'area'].includes(String(raw.chart_type)) || !Array.isArray(raw.labels) || raw.labels.length === 0 || raw.labels.length > MAX_ROWS || !Array.isArray(raw.series) || raw.series.length === 0 || raw.series.length > 8) return null;
|
|
33
|
+
const labels = raw.labels.map((v) => text(v, 80));
|
|
34
|
+
const series = raw.series.map(obj).map((s) => s && text(s.name, 80) && Array.isArray(s.values) && s.values.length === labels.length && s.values.every((v) => number(v) !== null) ? { name: text(s.name, 80), values: s.values as number[] } : null);
|
|
35
|
+
if (labels.some((v) => !v) || series.some((v) => !v)) return null;
|
|
36
|
+
return { type: 'chart', title, chart_type: raw.chart_type as 'line' | 'bar' | 'pie' | 'area', labels, series: series as Array<{ name: string; values: number[] }> };
|
|
37
|
+
}
|
|
38
|
+
if (raw.type === 'stat' || raw.type === 'list') { const items = pairItems(raw.items); return items ? { type: raw.type, title, items } : null; }
|
|
39
|
+
if (raw.type === 'action') {
|
|
40
|
+
if (!Array.isArray(raw.actions) || raw.actions.length === 0 || raw.actions.length > 6) return null;
|
|
41
|
+
const actions = raw.actions.map(obj).map((a) => a && text(a.label, 80) && text(a.value, 200) ? { label: text(a.label, 80), value: text(a.value, 200) } : null);
|
|
42
|
+
return actions.every(Boolean) ? { type: 'action', title, description: text(raw.description, 500) || undefined, action_id: text(raw.action_id, 120) || undefined, actions: actions as Array<{ label: string; value: string }> } : null;
|
|
43
|
+
}
|
|
44
|
+
if (raw.type === 'alert' && ['info', 'success', 'warning', 'error'].includes(String(raw.level)) && text(raw.message)) return { type: 'alert', level: raw.level as 'info' | 'success' | 'warning' | 'error', title, message: text(raw.message) };
|
|
45
|
+
if (raw.type === 'file' && text(raw.filename, 255) && text(raw.url, 2_000)) return { type: 'file', filename: text(raw.filename, 255), url: text(raw.url, 2_000), ...(number(raw.size) != null ? { size: number(raw.size)! } : {}), ...(text(raw.format, 32) ? { format: text(raw.format, 32) } : {}), ...(number(raw.row_count) != null ? { row_count: number(raw.row_count)! } : {}), ...(text(raw.expires_in, 80) ? { expires_in: text(raw.expires_in, 80) } : {}) };
|
|
46
|
+
if (allowAttachment && raw.type === 'attachment' && Array.isArray(raw.files)) {
|
|
47
|
+
const files = raw.files.slice(0, 5).map(safeFileReference).filter((file): file is NonNullable<typeof file> => file != null);
|
|
48
|
+
if (files.length && new Set(files.map((file) => file.id)).size === files.length) return { type: 'attachment', files };
|
|
49
|
+
}
|
|
50
|
+
if (raw.type === 'context' && Array.isArray(raw.references) && raw.references.length > 0 && raw.references.length <= MAX_ITEMS) {
|
|
51
|
+
const references = raw.references.map(obj).map((r) => r && (r.model === 'incident.Incident' || r.model === 'incident.Ticket') && id(r.pk) != null ? { model: r.model, pk: id(r.pk)!, ...(text(r.label, 160) ? { label: text(r.label, 160) } : {}) } : null);
|
|
52
|
+
return references.every(Boolean) ? { type: 'context', references: references as Array<{ model: 'incident.Incident' | 'incident.Ticket'; pk: number | string; label?: string }> } : null;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function projectBlocks(value: unknown, allowAttachment = false): AssistantBlock[] { return Array.isArray(value) ? value.slice(0, MAX_BLOCKS).map((entry) => block(entry, allowAttachment)).filter((v): v is AssistantBlock => v != null) : []; }
|
|
58
|
+
function projectUser(value: unknown): AssistantUser | null { const raw = obj(value); const userId = raw && number(raw.id); return raw && userId != null ? { id: userId, display_name: text(raw.display_name, 160) || `User #${userId}` } : null; }
|
|
59
|
+
export function projectMessage(value: unknown): AssistantMessage | null { const raw = obj(value); const messageId = raw && id(raw.id); if (!raw || messageId == null || (raw.role !== 'user' && raw.role !== 'assistant')) return null; const blocks = projectBlocks(raw.blocks, raw.role === 'user'); return { id: messageId, role: raw.role, content: text(raw.content, 20_000), created: typeof raw.created === 'number' || typeof raw.created === 'string' ? raw.created : 0, blocks: raw.role === 'user' ? blocks.filter((entry) => entry.type === 'attachment') : blocks }; }
|
|
60
|
+
export function projectConversation(value: unknown): AssistantConversation | null { const raw = obj(value); const conversationId = raw && number(raw.id); const user = raw && projectUser(raw.user); if (!raw || conversationId == null || !user) return null; return { id: conversationId, title: text(raw.title, 255) || `Conversation #${conversationId}`, created: typeof raw.created === 'number' || typeof raw.created === 'string' ? raw.created : 0, modified: typeof raw.modified === 'number' || typeof raw.modified === 'string' ? raw.modified : 0, user, messages: Array.isArray(raw.messages) ? raw.messages.map(projectMessage).filter((v): v is AssistantMessage => v != null) : [] }; }
|
|
61
|
+
export function projectConversationSummary(value: unknown): AssistantConversationSummary | null { const conversation = projectConversation(value); if (!conversation) return null; const { messages: _messages, ...summary } = conversation; return summary; }
|
|
62
|
+
export function projectSkill(value: unknown): AssistantSkill | null { const raw = obj(value); const skillId = raw && number(raw.id); if (!raw || skillId == null || !['global', 'user', 'group'].includes(String(raw.tier))) return null; const steps = Array.isArray(raw.steps) ? raw.steps.slice(0, MAX_ITEMS).map(obj).map((step) => step && text(step.tool, 160) && text(step.description, 500) ? { tool: text(step.tool, 160), description: text(step.description, 500), ...(text(step.condition, 500) ? { condition: text(step.condition, 500) } : {}) } : null).filter((step): step is { tool: string; description: string; condition?: string } => step != null) : []; return { id: skillId, tier: raw.tier as 'global' | 'user' | 'group', name: text(raw.name, 160) || `Skill #${skillId}`, description: text(raw.description, 2_000), auto_execute: raw.auto_execute === true, is_active: raw.is_active === true, created: typeof raw.created === 'number' || typeof raw.created === 'string' ? raw.created : 0, modified: typeof raw.modified === 'number' || typeof raw.modified === 'string' ? raw.modified : 0, user: projectUser(raw.user), triggers: Array.isArray(raw.triggers) ? raw.triggers.slice(0, 10).map((v) => text(v, 200)).filter(Boolean) : [], steps }; }
|
|
63
|
+
export function projectReply(value: unknown): AssistantReply | null { const raw = obj(value); const conversationId = raw && number(raw.conversation_id); if (!raw || conversationId == null || typeof raw.response !== 'string') return null; const toolCount = Array.isArray(raw.tool_calls_made) ? Math.min(raw.tool_calls_made.length, 32) : Math.min(Math.max(0, number(raw.tool_calls_made) ?? 0), 32); return { response: text(raw.response, 20_000), conversation_id: conversationId, tool_calls_made: toolCount, duration_ms: number(raw.duration_ms) ?? 0, blocks: projectBlocks(raw.blocks) }; }
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AdminSection } from '../index';
|
|
2
|
+
import { ASSISTANT_PERMISSIONS } from './AssistantPanel';
|
|
3
|
+
import { ConversationsPage, MemoriesPage, SkillsPage } from './pages';
|
|
4
|
+
|
|
5
|
+
export * from './types';
|
|
6
|
+
export * from './data';
|
|
7
|
+
export * from './api';
|
|
8
|
+
export * from './AssistantFeed';
|
|
9
|
+
export * from './AssistantPanel';
|
|
10
|
+
export * from './launchers';
|
|
11
|
+
export * from './pages';
|
|
12
|
+
export * from './streaming';
|
|
13
|
+
|
|
14
|
+
export const ASSISTANT_ADMIN_SECTION: AdminSection = {
|
|
15
|
+
id: 'assistant', basePath: 'assistant', title: 'Assistant', icon: 'bi-stars', navigationGroup: 'assistant', permissions: ASSISTANT_PERMISSIONS,
|
|
16
|
+
routes: [
|
|
17
|
+
{ path: 'conversations', label: 'Conversations', component: ConversationsPage, permissions: ASSISTANT_PERMISSIONS },
|
|
18
|
+
{ path: 'skills', label: 'Skills', component: SkillsPage, permissions: ASSISTANT_PERMISSIONS },
|
|
19
|
+
{ path: 'memories', label: 'Memory', component: MemoriesPage, permissions: ['sys.assistant'] },
|
|
20
|
+
],
|
|
21
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type MouseEvent } from 'react';
|
|
2
|
+
import { useCan } from '../../client';
|
|
3
|
+
import { useRightPanel } from '../../ui';
|
|
4
|
+
import { SECURITY_VIEW_PERMS } from '../security-permissions';
|
|
5
|
+
import { AssistantPanel, ASSISTANT_PERMISSIONS } from './AssistantPanel';
|
|
6
|
+
import { createAssistantContext } from './api';
|
|
7
|
+
|
|
8
|
+
export function AssistantLauncher() {
|
|
9
|
+
const { can } = useCan(ASSISTANT_PERMISSIONS);
|
|
10
|
+
const panel = useRightPanel();
|
|
11
|
+
useEffect(() => { if (!can && panel.descriptor?.key.startsWith('assistant:')) panel.close(); }, [can, panel]);
|
|
12
|
+
if (!can) return null;
|
|
13
|
+
return <button type="button" className="btn-icon" title="Open Assistant" aria-label="Open Assistant" aria-pressed={panel.descriptor?.key === 'assistant:new'} onClick={(event) => panel.open({ key: 'assistant:new', title: 'Assistant', render: (context) => <AssistantPanel context={context} /> }, event.currentTarget)}><i className="bi bi-stars" aria-hidden="true" /></button>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function AssistantContextLauncher({ model, pk, label = 'Ask Assistant' }: { model: 'incident.Incident' | 'incident.Ticket'; pk: number; label?: string }) {
|
|
17
|
+
const assistant = useCan(ASSISTANT_PERMISSIONS);
|
|
18
|
+
const security = useCan(SECURITY_VIEW_PERMS);
|
|
19
|
+
const panel = useRightPanel();
|
|
20
|
+
const generation = useRef(0);
|
|
21
|
+
const [loading, setLoading] = useState(false);
|
|
22
|
+
const [error, setError] = useState('');
|
|
23
|
+
useEffect(() => () => { generation.current += 1; }, []);
|
|
24
|
+
useEffect(() => { generation.current += 1; setLoading(false); setError(''); }, [model, pk]);
|
|
25
|
+
if (!assistant.can || !security.can) return null;
|
|
26
|
+
const launch = async (event: MouseEvent<HTMLButtonElement>) => {
|
|
27
|
+
const launcher = event.currentTarget; const run = ++generation.current; setLoading(true); setError('');
|
|
28
|
+
try { const conversation = await createAssistantContext(model, pk); if (generation.current !== run) return; panel.open({ key: `assistant:context:${model}:${pk}:${conversation.id}`, title: `Assistant · ${model === 'incident.Incident' ? 'Incident' : 'Ticket'} #${pk}`, render: (context) => <AssistantPanel context={context} initialConversation={conversation} /> }, launcher); }
|
|
29
|
+
catch (cause) { if (generation.current === run) setError(cause instanceof Error ? cause.message : 'Could not create context'); }
|
|
30
|
+
finally { if (generation.current === run) setLoading(false); }
|
|
31
|
+
};
|
|
32
|
+
return <span className="assistant-context-launcher"><button type="button" className="btn btn-compact" disabled={loading} onClick={(event) => void launch(event)}><i className="bi bi-stars" aria-hidden="true" /> {loading ? 'Opening…' : label}</button>{error && <span className="text-bad" role="alert">{error}</span>}</span>;
|
|
33
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { mojoList, useCan, useMe, useRealtime, useRealtimeStatus } from '../../client';
|
|
3
|
+
import { Badge, fmt, modal, toast } from '../../ui';
|
|
4
|
+
import { AssistantFeed } from './AssistantFeed';
|
|
5
|
+
import { deleteAssistantConversation, deleteAssistantMemory, deleteAssistantSkill, getAssistantConversation, getAssistantMemory, getAssistantSkill, listAssistantConversations, listAssistantSkills, saveAssistantMemory, sendAssistantMessage } from './api';
|
|
6
|
+
import type { AssistantConversation, AssistantConversationSummary, AssistantSkill } from './types';
|
|
7
|
+
import type { FileReference } from '../../client/record-feed';
|
|
8
|
+
import { ASSISTANT_PERMISSIONS } from './AssistantPanel';
|
|
9
|
+
import { AssistantOutcomeUnknownError, chooseAssistantTransport, startAssistantRealtimeTurn, type AssistantRealtimeTurn } from './streaming';
|
|
10
|
+
|
|
11
|
+
function PageState({ loading, error, retry }: { loading: boolean; error: string; retry(): void }) { if (loading) return <div className="panel panel-pad dim">Loading…</div>; if (error) return <div className="panel panel-pad"><div className="form-alert" role="alert">{error}</div><button type="button" className="btn" onClick={retry}>Retry</button></div>; return null; }
|
|
12
|
+
function Heading({ eyebrow, title, description }: { eyebrow: string; title: string; description: string }) { return <header className="admin-page-heading"><div><div className="eyebrow">{eyebrow}</div><h1>{title}</h1><p>{description}</p></div></header>; }
|
|
13
|
+
|
|
14
|
+
function ConversationInspect({ id, close }: { id: number; close(): void }) {
|
|
15
|
+
const { data: me } = useMe(); const { can: canAssistant } = useCan(ASSISTANT_PERMISSIONS); const { can: canViewAdmin } = useCan('sys.view_admin');
|
|
16
|
+
const realtime = useRealtime(); const realtimeStatus = useRealtimeStatus();
|
|
17
|
+
const [data, setData] = useState<AssistantConversation | null>(null); const [loading, setLoading] = useState(true); const [responding, setResponding] = useState(false); const [error, setError] = useState('');
|
|
18
|
+
const stream = useRef<AssistantRealtimeTurn | null>(null); const seenMessageIds = useRef(new Set<number | string>());
|
|
19
|
+
const load = useCallback(async () => { setLoading(true); setError(''); try { const next = await getAssistantConversation(id); seenMessageIds.current = new Set(next.messages.map((message) => message.id)); setData(next); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Conversation unavailable'); } finally { setLoading(false); } }, [id]);
|
|
20
|
+
useEffect(() => { void load(); }, [load]);
|
|
21
|
+
useEffect(() => () => { stream.current?.cancel(); stream.current = null; }, []);
|
|
22
|
+
useEffect(() => { if (!canAssistant) { stream.current?.cancel(); stream.current = null; setResponding(false); close(); } }, [canAssistant, close]);
|
|
23
|
+
useEffect(() => { if (!canViewAdmin && stream.current) { stream.current.cancel(); stream.current = null; setResponding(false); void load(); } }, [canViewAdmin, load]);
|
|
24
|
+
if (loading || error || !data || !me) return <div className="modal-pad"><PageState loading={loading} error={error || (!data ? 'Conversation unavailable' : '')} retry={() => void load()} /><button type="button" className="btn" onClick={close}>Close</button></div>;
|
|
25
|
+
const owner = data.user.id === me.id;
|
|
26
|
+
const send = async (message: string, attachments: readonly FileReference[] = []) => {
|
|
27
|
+
if (!owner || responding) return;
|
|
28
|
+
setResponding(true); const now = Math.floor(Date.now() / 1000);
|
|
29
|
+
const userMessage = { id: `inspect-user-${Date.now()}`, role: 'user' as const, content: message, created: now, blocks: attachments.length ? [{ type: 'attachment' as const, files: [...attachments] }] : [] };
|
|
30
|
+
setData((current) => current ? { ...current, messages: [...current.messages, userMessage] } : current);
|
|
31
|
+
try {
|
|
32
|
+
const transport = chooseAssistantTransport({ owner, textOnly: attachments.length === 0, hasViewAdmin: canViewAdmin, realtimeStatus: realtimeStatus.status });
|
|
33
|
+
if (transport === 'websocket') {
|
|
34
|
+
const turn = startAssistantRealtimeTurn(realtime, { message, conversationId: data.id, seenMessageIds: seenMessageIds.current }, {
|
|
35
|
+
onConversation: () => {},
|
|
36
|
+
onText: (next) => setData((current) => current ? { ...current, messages: [...current.messages, next] } : current),
|
|
37
|
+
onProgress: () => {},
|
|
38
|
+
onResponse: (next) => setData((current) => current ? { ...current, modified: now, messages: current.messages.some((item) => item.id === next.id) ? current.messages : [...current.messages, next] } : current),
|
|
39
|
+
onReconcile: setData,
|
|
40
|
+
onUnknown: () => { window.dispatchEvent(new CustomEvent('portal-mojo:assistant-list-refresh')); },
|
|
41
|
+
});
|
|
42
|
+
stream.current = turn; await turn.promise; stream.current = null;
|
|
43
|
+
} else {
|
|
44
|
+
const reply = await sendAssistantMessage(message, data.id, attachments);
|
|
45
|
+
setData((current) => current ? { ...current, modified: now, messages: [...current.messages, { id: `inspect-assistant-${Date.now()}`, role: 'assistant', content: reply.response, created: now, blocks: reply.blocks }] } : current);
|
|
46
|
+
}
|
|
47
|
+
} catch (cause) {
|
|
48
|
+
if (!(cause instanceof AssistantOutcomeUnknownError)) setData((current) => current ? { ...current, messages: current.messages.filter((item) => item.id !== userMessage.id) } : current);
|
|
49
|
+
throw cause;
|
|
50
|
+
} finally { stream.current = null; setResponding(false); }
|
|
51
|
+
};
|
|
52
|
+
return <div className="modal-pad assistant-inspect"><header><div><div className="eyebrow">Conversation #{data.id}</div><h2>{data.title}</h2><p className="dim">Owner: {data.user.display_name} · updated {fmt.datetime(data.modified)}</p></div><button className="btn-icon" type="button" onClick={close} aria-label="Close"><i className="bi bi-x-lg" /></button></header><AssistantFeed messages={data.messages} user={{ id: me.id, display_name: me.display_name ?? 'You' }} onSend={send} responding={responding} disabled={!owner} attachmentKey={data.id} /></div>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function ConversationsPage() {
|
|
56
|
+
const { data: me } = useMe(); const { can: isAdmin } = useCan('sys.view_admin'); const [rows, setRows] = useState<AssistantConversationSummary[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState('');
|
|
57
|
+
const load = useCallback(async () => { setLoading(true); setError(''); try { setRows((await listAssistantConversations()).rows); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Conversations unavailable'); } finally { setLoading(false); } }, []);
|
|
58
|
+
useEffect(() => { void load(); }, [load]);
|
|
59
|
+
useEffect(() => { const refresh = () => { void load(); }; window.addEventListener('portal-mojo:assistant-list-refresh', refresh); return () => window.removeEventListener('portal-mojo:assistant-list-refresh', refresh); }, [load]);
|
|
60
|
+
const remove = async (row: AssistantConversationSummary) => { const confirmed = await modal.confirm({ title: 'Delete conversation?', message: `Delete “${row.title}”? This cannot be undone.`, confirmText: 'Delete', danger: true }); if (!confirmed) return; try { await deleteAssistantConversation(row.id); setRows((current) => current.filter((item) => item.id !== row.id)); toast.success('Conversation deleted'); } catch (cause) { toast.error(cause instanceof Error ? cause.message : 'Delete failed'); } };
|
|
61
|
+
return <div><Heading eyebrow="Assistant" title="Conversations" description="Inspect Assistant history. Only the owner may continue a conversation." /><PageState loading={loading} error={error} retry={() => void load()} />{!loading && !error && <div className="panel assistant-list"><table><thead><tr><th>Conversation</th><th>Owner</th><th>Updated</th><th /></tr></thead><tbody>{rows.map((row) => <tr key={row.id}><td><button className="link-button" type="button" onClick={() => void modal.detail((close) => <ConversationInspect id={row.id} close={() => close(null)} />)}>{row.title}</button><small>#{row.id}</small></td><td>{row.user.display_name}{row.user.id === me?.id && <Badge tone="info">You</Badge>}</td><td>{fmt.datetime(row.modified)}</td><td><button className="btn-icon" type="button" aria-label={`Delete ${row.title}`} disabled={!isAdmin && row.user.id !== me?.id} onClick={() => void remove(row)}><i className="bi bi-trash" /></button></td></tr>)}</tbody></table>{rows.length === 0 && <p className="empty">No conversations.</p>}</div>}</div>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function SkillInspect({ id, close }: { id: number; close(): void }) { const [skill, setSkill] = useState<AssistantSkill | null>(null); const [error, setError] = useState(''); useEffect(() => { void getAssistantSkill(id).then(setSkill, (cause) => setError(cause instanceof Error ? cause.message : 'Skill unavailable')); }, [id]); return <div className="modal-pad assistant-skill-detail"><header><h2>{skill?.name ?? 'Skill'}</h2><button className="btn-icon" onClick={close} aria-label="Close"><i className="bi bi-x-lg" /></button></header>{error ? <div className="form-alert">{error}</div> : !skill ? <p className="dim">Loading…</p> : <><p>{skill.description || 'No description.'}</p><dl><div><dt>Tier</dt><dd>{skill.tier}</dd></div><div><dt>Active</dt><dd>{skill.is_active ? 'Yes' : 'No'}</dd></div><div><dt>Auto execute</dt><dd>{skill.auto_execute ? 'Yes' : 'No'}</dd></div></dl><h3>Triggers</h3>{skill.triggers.length ? <ul>{skill.triggers.map((trigger) => <li key={trigger}>{trigger}</li>)}</ul> : <p className="dim">No triggers.</p>}<h3>Steps</h3>{skill.steps.length ? <ol>{skill.steps.map((step, index) => <li key={index}><code>{JSON.stringify(step)}</code></li>)}</ol> : <p className="dim">No steps.</p>}</>}</div>; }
|
|
65
|
+
|
|
66
|
+
export function SkillsPage() { const [rows, setRows] = useState<AssistantSkill[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const load = useCallback(async () => { setLoading(true); setError(''); try { setRows((await listAssistantSkills()).rows); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Skills unavailable'); } finally { setLoading(false); } }, []); useEffect(() => { void load(); }, [load]); const remove = async (row: AssistantSkill) => { const confirmed = await modal.confirm({ title: 'Delete skill?', message: `Delete “${row.name}”?`, confirmText: 'Delete', danger: true }); if (!confirmed) return; try { await deleteAssistantSkill(row.id); setRows((current) => current.filter((item) => item.id !== row.id)); toast.success('Skill deleted'); } catch (cause) { toast.error(cause instanceof Error ? cause.message : 'Delete failed'); } }; return <div><Heading eyebrow="Assistant" title="Skills" description="Inspect the bounded skill catalog exposed by django-mojo." /><PageState loading={loading} error={error} retry={() => void load()} />{!loading && !error && <div className="panel assistant-list"><table><thead><tr><th>Skill</th><th>Tier</th><th>Status</th><th /></tr></thead><tbody>{rows.map((row) => <tr key={row.id}><td><button className="link-button" onClick={() => void modal.detail((close) => <SkillInspect id={row.id} close={() => close(null)} />)}>{row.name}</button><small>{row.description}</small></td><td>{row.tier}</td><td><Badge tone={row.is_active ? 'success' : 'muted'}>{row.is_active ? 'Active' : 'Inactive'}</Badge></td><td><button className="btn-icon" aria-label={`Delete ${row.name}`} onClick={() => void remove(row)}><i className="bi bi-trash" /></button></td></tr>)}</tbody></table>{rows.length === 0 && <p className="empty">No skills.</p>}</div>}</div>; }
|
|
67
|
+
|
|
68
|
+
interface GroupChoice { id: number; name: string }
|
|
69
|
+
type MemoryTier = 'global' | 'user' | 'group';
|
|
70
|
+
export function MemoriesPage() {
|
|
71
|
+
const [tier, setTier] = useState<MemoryTier>('user'); const [groups, setGroups] = useState<GroupChoice[]>([]); const [group, setGroup] = useState<number | null>(null); const [entries, setEntries] = useState<Record<string, unknown>>({}); const [key, setKey] = useState(''); const [value, setValue] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState('');
|
|
72
|
+
useEffect(() => { void mojoList<GroupChoice>('/api/group', { start: 0, size: 50, sort: 'name' }).then((page) => { setGroups(page.rows.map((row) => ({ id: Number(row.id), name: String(row.name) }))); setGroup((current) => current ?? page.rows[0]?.id ?? null); }, () => setGroups([])); }, []);
|
|
73
|
+
const load = useCallback(async () => { if (tier === 'group' && group == null) { setEntries({}); return; } setLoading(true); setError(''); try { setEntries(await getAssistantMemory(tier, tier === 'group' ? group! : undefined)); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Memory unavailable'); } finally { setLoading(false); } }, [tier, group]); useEffect(() => { void load(); }, [load]);
|
|
74
|
+
const validKey = /^[a-z0-9:_-]{1,64}$/.test(key); const validValue = value.length > 0 && value.length <= 500;
|
|
75
|
+
const save = async () => { if (!validKey || !validValue || (tier === 'group' && group == null)) return; try { await saveAssistantMemory(tier, key, value, tier === 'group' ? group! : undefined); setKey(''); setValue(''); await load(); toast.success('Memory saved'); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Save failed'); } };
|
|
76
|
+
const remove = async (entryKey: string) => { const confirmed = await modal.confirm({ title: 'Delete memory?', message: `Delete “${entryKey}” from ${tier} memory?`, confirmText: 'Delete', danger: true }); if (!confirmed) return; try { await deleteAssistantMemory(tier, entryKey, tier === 'group' ? group! : undefined); await load(); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Delete failed'); } };
|
|
77
|
+
return <div><Heading eyebrow="Assistant" title="Memory" description="Manage global, personal, or explicitly selected group memory without ambient group state." /><div className="panel panel-pad assistant-memory-controls"><div className="seg">{(['global', 'user', 'group'] as MemoryTier[]).map((item) => <button key={item} className={`seg-btn${tier === item ? ' seg-active' : ''}`} onClick={() => setTier(item)}>{item}</button>)}</div>{tier === 'group' && <label className="field"><span className="field-label">Group (bounded first 50)</span><select className="input" value={group ?? ''} onChange={(event) => setGroup(event.target.value ? Number(event.target.value) : null)}><option value="">Choose a group</option>{groups.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label>}<div className="assistant-memory-form"><label className="field"><span className="field-label">Key</span><input className="input" maxLength={64} value={key} onChange={(event) => setKey(event.target.value)} placeholder="lowercase:key" /></label><label className="field"><span className="field-label">Value</span><textarea className="input" maxLength={500} rows={2} value={value} onChange={(event) => setValue(event.target.value)} /></label><button className="btn btn-primary" disabled={!validKey || !validValue || loading || (tier === 'group' && group == null)} onClick={() => void save()}>Save</button></div>{key && !validKey && <p className="field-error">Use 1–64 lowercase letters, numbers, colon, underscore, or hyphen.</p>}{error && <div className="form-alert" role="alert">{error}</div>}</div><div className="panel assistant-list">{loading ? <p className="empty">Loading…</p> : Object.entries(entries).length ? <table><thead><tr><th>Key</th><th>Value</th><th /></tr></thead><tbody>{Object.entries(entries).slice(0, 100).map(([entryKey, entryValue]) => <tr key={entryKey}><td><code>{entryKey}</code></td><td>{String(entryValue).slice(0, 500)}</td><td><button className="btn-icon" aria-label={`Delete ${entryKey}`} onClick={() => void remove(entryKey)}><i className="bi bi-trash" /></button></td></tr>)}</tbody></table> : <p className="empty">No {tier} memory.</p>}</div></div>;
|
|
78
|
+
}
|