@object-ui/app-shell 6.1.0 → 6.2.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/CHANGELOG.md +110 -0
- package/README.md +10 -1
- package/dist/console/AppContent.js +24 -2
- package/dist/console/ai/AiChatPage.d.ts +8 -0
- package/dist/console/ai/AiChatPage.js +188 -0
- package/dist/console/ai/ConversationsSidebar.d.ts +7 -0
- package/dist/console/ai/ConversationsSidebar.js +111 -0
- package/dist/console/auth/LoginPage.js +19 -2
- package/dist/console/auth/RegisterPage.js +30 -1
- package/dist/console/marketplace/MarketplaceAccessDenied.js +3 -1
- package/dist/console/marketplace/MarketplaceInstalledPage.js +11 -3
- package/dist/console/marketplace/MarketplacePackagePage.js +57 -19
- package/dist/console/marketplace/MarketplacePage.js +55 -18
- package/dist/console/marketplace/marketplaceApi.d.ts +20 -0
- package/dist/console/marketplace/usePackageL10n.d.ts +38 -0
- package/dist/console/marketplace/usePackageL10n.js +110 -0
- package/dist/console/organizations/CreateWorkspaceDialog.js +29 -1
- package/dist/console/organizations/OrganizationsPage.js +24 -3
- package/dist/context/FavoritesProvider.d.ts +40 -2
- package/dist/context/FavoritesProvider.js +201 -20
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/useChatConversation.d.ts +7 -0
- package/dist/hooks/useChatConversation.js +37 -5
- package/dist/hooks/useConversationList.d.ts +25 -0
- package/dist/hooks/useConversationList.js +131 -0
- package/dist/hooks/useNavPins.d.ts +11 -4
- package/dist/hooks/useNavPins.js +104 -53
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/layout/AppHeader.js +2 -2
- package/dist/layout/AppSidebar.js +20 -1
- package/dist/layout/UnifiedSidebar.js +1 -1
- package/dist/providers/ExpressionProvider.d.ts +11 -1
- package/dist/providers/ExpressionProvider.js +11 -6
- package/dist/services/builtinComponents.d.ts +1 -0
- package/dist/services/builtinComponents.js +166 -0
- package/dist/services/componentRegistry.d.ts +63 -0
- package/dist/services/componentRegistry.js +36 -0
- package/dist/views/ComponentNavView.d.ts +6 -0
- package/dist/views/ComponentNavView.js +26 -0
- package/dist/views/RecordDetailView.js +66 -6
- package/dist/views/RecordFormPage.js +15 -3
- package/dist/views/SearchResultsPage.js +4 -0
- package/dist/views/metadata-admin/DesignerEditorWrapper.d.ts +58 -0
- package/dist/views/metadata-admin/DesignerEditorWrapper.js +140 -0
- package/dist/views/metadata-admin/DirectoryPage.d.ts +1 -0
- package/dist/views/metadata-admin/DirectoryPage.js +135 -0
- package/dist/views/metadata-admin/LayeredDiff.d.ts +6 -0
- package/dist/views/metadata-admin/LayeredDiff.js +26 -0
- package/dist/views/metadata-admin/PageShell.d.ts +34 -0
- package/dist/views/metadata-admin/PageShell.js +33 -0
- package/dist/views/metadata-admin/PermissionMatrixEditor.d.ts +5 -0
- package/dist/views/metadata-admin/PermissionMatrixEditor.js +288 -0
- package/dist/views/metadata-admin/QuickFind.d.ts +5 -0
- package/dist/views/metadata-admin/QuickFind.js +152 -0
- package/dist/views/metadata-admin/ResourceEditPage.d.ts +7 -0
- package/dist/views/metadata-admin/ResourceEditPage.js +256 -0
- package/dist/views/metadata-admin/ResourceHistoryPage.d.ts +5 -0
- package/dist/views/metadata-admin/ResourceHistoryPage.js +97 -0
- package/dist/views/metadata-admin/ResourceListPage.d.ts +4 -0
- package/dist/views/metadata-admin/ResourceListPage.js +144 -0
- package/dist/views/metadata-admin/ResourceRouter.d.ts +10 -0
- package/dist/views/metadata-admin/ResourceRouter.js +47 -0
- package/dist/views/metadata-admin/SchemaForm.d.ts +99 -0
- package/dist/views/metadata-admin/SchemaForm.js +556 -0
- package/dist/views/metadata-admin/default-schemas.d.ts +6 -0
- package/dist/views/metadata-admin/default-schemas.js +207 -0
- package/dist/views/metadata-admin/i18n.d.ts +33 -0
- package/dist/views/metadata-admin/i18n.js +303 -0
- package/dist/views/metadata-admin/index.d.ts +31 -0
- package/dist/views/metadata-admin/index.js +33 -0
- package/dist/views/metadata-admin/predicate.d.ts +31 -0
- package/dist/views/metadata-admin/predicate.js +150 -0
- package/dist/views/metadata-admin/registry.d.ts +125 -0
- package/dist/views/metadata-admin/registry.js +48 -0
- package/dist/views/metadata-admin/useMetadata.d.ts +37 -0
- package/dist/views/metadata-admin/useMetadata.js +96 -0
- package/dist/views/metadata-admin/widgets.d.ts +68 -0
- package/dist/views/metadata-admin/widgets.js +287 -0
- package/package.json +27 -26
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,115 @@
|
|
|
1
1
|
# @object-ui/app-shell — Changelog
|
|
2
2
|
|
|
3
|
+
## 6.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- fe3c1d3: Metadata Admin engine — unified UI for all 27 metadata types.
|
|
8
|
+
|
|
9
|
+
A generic, schema-driven admin shell that replaces the old per-type
|
|
10
|
+
bespoke pages with a single registry-driven engine. Admins can now browse,
|
|
11
|
+
create, override, diff, and roll back every registered metadata type from
|
|
12
|
+
the Setup app → _All Metadata Types_.
|
|
13
|
+
|
|
14
|
+
### New: `@object-ui/app-shell` views/metadata-admin
|
|
15
|
+
- **`MetadataDirectoryPage`** — auto-grouped tile directory by domain, with
|
|
16
|
+
free-text search, domain chips, and a _Writable only_ filter.
|
|
17
|
+
- **`MetadataResourceListPage` / `MetadataResourceEditPage` / `…CreatePage` / `…HistoryPage`** —
|
|
18
|
+
generic CRUD shell. Uses the new `/meta/types` schema field to render
|
|
19
|
+
SchemaForm; uses `?layers=code,overlay,effective` to power a 3-state diff
|
|
20
|
+
tab; uses `/references` to warn before destructive deletes.
|
|
21
|
+
- **`MetadataQuickFind`** — Cmd+Shift+M palette searching across types and
|
|
22
|
+
items.
|
|
23
|
+
- **`PermissionMatrixEditor`** — Salesforce-style matrix custom editor for
|
|
24
|
+
`type=permission`. Objects × CRUD/VAMA/lifecycle columns with cascade
|
|
25
|
+
rules (viewAllRecords ⟹ allowRead, etc.), expandable per-object field
|
|
26
|
+
R/W subtable, bulk-set (R / CRUD / All / None), filter, _only granted_
|
|
27
|
+
toggle, destructive-change confirmation, profile switch.
|
|
28
|
+
- **`DesignerEditorWrapper`** — generic load–edit–save shell that hosts any
|
|
29
|
+
bespoke designer (`ObjectViewConfigurator`, `DashboardEditor`,
|
|
30
|
+
`PageCanvasEditor`, …). Handles dirty tracking, Save / Reset / Refresh /
|
|
31
|
+
History buttons, and the read-only fallback when `allowOrgOverride` is
|
|
32
|
+
false.
|
|
33
|
+
- **`i18n.ts`** — bilingual (`en-US`, `zh-CN`) bundle for built-in type
|
|
34
|
+
labels, domain labels, and engine UI strings, with `detectLocale()` and a
|
|
35
|
+
`t(key)` helper.
|
|
36
|
+
|
|
37
|
+
### New routing variant
|
|
38
|
+
- App nav now supports `{ type: 'component', componentRef, params? }` items.
|
|
39
|
+
`AppContent` resolves them through the existing `ComponentRegistry`.
|
|
40
|
+
- Built-in components registered: `metadata:directory`, `metadata:resource`,
|
|
41
|
+
`metadata:object/edit` (FieldsPage), `metadata:permission/edit`
|
|
42
|
+
(PermissionMatrixEditor), and lazy designer wrappers for view / dashboard
|
|
43
|
+
/ page.
|
|
44
|
+
|
|
45
|
+
### Plugin-designer
|
|
46
|
+
- Lazy-exported `ObjectManager`, `FieldDesigner`, `ObjectViewConfigurator`,
|
|
47
|
+
`DashboardEditor`, `PageCanvasEditor`, `MetadataObjectsPage`, and
|
|
48
|
+
`MetadataFieldsPage` so the engine can mount them on demand.
|
|
49
|
+
|
|
50
|
+
The temporary `/dev/meta` route is removed. Setup app navigation flows
|
|
51
|
+
through the new component routes.
|
|
52
|
+
|
|
53
|
+
- ca685ab: Add ChatGPT-style AI chat history surface at `/ai` and `/ai/:conversationId`.
|
|
54
|
+
- New `DefaultAiChatPage` with conversations sidebar (list, create, select, delete) and chat pane on the right.
|
|
55
|
+
- New `ConversationsSidebar` component and `useConversationList` hook for listing and managing `ai_conversations`.
|
|
56
|
+
- `useChatConversation` now accepts an optional `activeId` to hydrate a specific conversation (bypassing the localStorage cache), and guards against duplicate conversation creation when sibling state (e.g. selected agent / scope) changes during the same visit.
|
|
57
|
+
- Deleting the active conversation navigates back to `/ai` so the URL doesn't reference a stale id.
|
|
58
|
+
- Auto-title new conversations from the first user message (truncated to 40 chars) via `PATCH /api/v1/ai/conversations/:id`; resumed conversations are left alone.
|
|
59
|
+
- Manual rename in the sidebar: pencil icon opens an inline editor with optimistic update and rollback on server error.
|
|
60
|
+
- Client-side search input filters the sidebar by title/preview substring.
|
|
61
|
+
|
|
62
|
+
- 0335ec4: Polish the AI chat surface based on real-world dogfooding feedback.
|
|
63
|
+
|
|
64
|
+
**`@object-ui/plugin-chatbot`** — new display helpers shared by `ChatbotEnhanced`:
|
|
65
|
+
- `unwrapToolResult(value)` peels the MCP-style `{ type: 'text', value: '<json>' }`
|
|
66
|
+
envelope that backend tools emit (`@objectstack/service-ai`'s data/metadata
|
|
67
|
+
tools, in particular), and JSON-parses the inner payload. The result panel
|
|
68
|
+
now renders a structured object tree instead of a doubly-escaped wall of
|
|
69
|
+
`\\\"objects\\\":[…]`.
|
|
70
|
+
- `humanizeToolName(name)` converts snake_case / kebab-case / camelCase tool
|
|
71
|
+
ids into sentence case ("list_objects" → "List objects"), preserving known
|
|
72
|
+
acronyms (API, ID, SQL, …). Tool-call cards now show the friendly title with
|
|
73
|
+
the raw id as a small monospace badge for power users.
|
|
74
|
+
- `summarizeChatError(err)` strips the AI SDK's
|
|
75
|
+
`"Failed after N attempts. Last error: "` prefix and keeps the first
|
|
76
|
+
sentence as a headline; the full text is exposed via an optional `details`
|
|
77
|
+
field so the new error banner can render a "Details" disclosure plus a
|
|
78
|
+
prominent Retry button instead of a 300-character single-line wall.
|
|
79
|
+
|
|
80
|
+
A new `⌘⏎ to send` hint is shown in the prompt footer (hidden on narrow
|
|
81
|
+
screens). `ToolHeader.title` now accepts `ReactNode` (previously `string`)
|
|
82
|
+
so wrappers can compose richer titles.
|
|
83
|
+
|
|
84
|
+
**`@object-ui/app-shell`** — `AiChatPage`:
|
|
85
|
+
- Removes the fake "Hello! I'm X" assistant welcome bubble so the empty-state
|
|
86
|
+
suggestion chips can actually render.
|
|
87
|
+
- Adds per-agent default suggestion sets (`data_chat`, `metadata_assistant`)
|
|
88
|
+
with a generic fallback. New conversations open with three actionable
|
|
89
|
+
starter prompts tailored to the selected agent.
|
|
90
|
+
- Surfaces agent-fetch failures as an inline warning on the agent picker
|
|
91
|
+
instead of hijacking the welcome message.
|
|
92
|
+
- Placeholder text now hints at the first suggestion (e.g. `Ask Data
|
|
93
|
+
Assistant… (try "系统里有多少个用户?")`).
|
|
94
|
+
|
|
95
|
+
### Patch Changes
|
|
96
|
+
|
|
97
|
+
- Updated dependencies [fe3c1d3]
|
|
98
|
+
- Updated dependencies [ec8dcde]
|
|
99
|
+
- @object-ui/data-objectstack@6.2.0
|
|
100
|
+
- @object-ui/react@6.2.0
|
|
101
|
+
- @object-ui/components@6.2.0
|
|
102
|
+
- @object-ui/fields@6.2.0
|
|
103
|
+
- @object-ui/layout@6.2.0
|
|
104
|
+
- @object-ui/plugin-editor@6.2.0
|
|
105
|
+
- @object-ui/types@6.2.0
|
|
106
|
+
- @object-ui/core@6.2.0
|
|
107
|
+
- @object-ui/i18n@6.2.0
|
|
108
|
+
- @object-ui/auth@6.2.0
|
|
109
|
+
- @object-ui/permissions@6.2.0
|
|
110
|
+
- @object-ui/collaboration@6.2.0
|
|
111
|
+
- @object-ui/providers@6.2.0
|
|
112
|
+
|
|
3
113
|
## 6.1.0
|
|
4
114
|
|
|
5
115
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -241,12 +241,21 @@ The official ObjectStack adapter lives in `@object-ui/data-objectstack`
|
|
|
241
241
|
(`createObjectStackUserStateAdapter`).
|
|
242
242
|
|
|
243
243
|
```tsx
|
|
244
|
-
import { useFavorites, useRecentItems } from '@object-ui/app-shell';
|
|
244
|
+
import { useFavorites, useRecentItems, useNavPins } from '@object-ui/app-shell';
|
|
245
245
|
|
|
246
246
|
const { favorites, toggleFavorite, isFavorite } = useFavorites();
|
|
247
247
|
const { recentItems, addRecentItem } = useRecentItems();
|
|
248
|
+
// Sidebar pins live in the same store as Favorites — synced to the backend
|
|
249
|
+
// via the same `UserDataAdapter<FavoriteItem>` when one is attached.
|
|
250
|
+
const { pinnedIds, togglePin, isPinned, applyPins } = useNavPins();
|
|
248
251
|
```
|
|
249
252
|
|
|
253
|
+
Nav pins and Favorites share a single `favorites` collection. `FavoriteItem`
|
|
254
|
+
carries optional `type: 'nav'`, `pinned`, and `navId` fields so a single
|
|
255
|
+
adapter syncs both flows. The legacy `objectui-nav-pins` localStorage key is
|
|
256
|
+
migrated on first mount and then removed. Content favorites (20) and nav
|
|
257
|
+
pins (20) each have an independent cap. See the guide below for details.
|
|
258
|
+
|
|
250
259
|
See [User-Scoped State Persistence](../../content/docs/guide/user-state-persistence.md)
|
|
251
260
|
for the adapter contract, backend schema, and how to plug in your own backend.
|
|
252
261
|
|
|
@@ -39,6 +39,7 @@ const PageView = lazy(() => import('../views/PageView').then(m => ({ default: m.
|
|
|
39
39
|
const ReportView = lazy(() => import('../views/ReportView').then(m => ({ default: m.ReportView })));
|
|
40
40
|
const SearchResultsPage = lazy(() => import('../views/SearchResultsPage').then(m => ({ default: m.SearchResultsPage })));
|
|
41
41
|
const RecordFormPage = lazy(() => import('../views/RecordFormPage').then(m => ({ default: m.RecordFormPage })));
|
|
42
|
+
const ComponentNavView = lazy(() => import('../views/ComponentNavView').then(m => ({ default: m.ComponentNavView })));
|
|
42
43
|
// Designer pages — sourced from @object-ui/plugin-designer so third-party hosts
|
|
43
44
|
// can opt out by not registering these routes.
|
|
44
45
|
const CreateAppPage = lazy(() => import('@object-ui/plugin-designer').then(m => ({ default: m.CreateAppPage })));
|
|
@@ -52,8 +53,29 @@ const MarketplacePackagePage = lazy(() => import('./marketplace/MarketplacePacka
|
|
|
52
53
|
const MarketplaceInstalledPage = lazy(() => import('./marketplace/MarketplaceInstalledPage').then(m => ({ default: m.MarketplaceInstalledPage })));
|
|
53
54
|
export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
|
|
54
55
|
const [connectionState, setConnectionState] = useState('disconnected');
|
|
55
|
-
const { user } = useAuth();
|
|
56
|
+
const { user, getAuthConfig } = useAuth();
|
|
56
57
|
const dataSource = useAdapter();
|
|
58
|
+
// Deployment-level feature flags from `/api/v1/auth/config`. Used by
|
|
59
|
+
// CEL predicates on metadata actions (e.g. `sys_organization`'s
|
|
60
|
+
// create button is hidden when `multiOrgEnabled === false`). We keep
|
|
61
|
+
// it empty until the fetch resolves so predicates default to "visible"
|
|
62
|
+
// and we don't briefly hide UI on slow networks.
|
|
63
|
+
const [features, setFeatures] = useState({});
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
let cancelled = false;
|
|
66
|
+
getAuthConfig()
|
|
67
|
+
.then((cfg) => {
|
|
68
|
+
if (cancelled)
|
|
69
|
+
return;
|
|
70
|
+
setFeatures(cfg?.features ?? {});
|
|
71
|
+
})
|
|
72
|
+
.catch(() => {
|
|
73
|
+
/* leave empty — predicates default to visible */
|
|
74
|
+
});
|
|
75
|
+
return () => {
|
|
76
|
+
cancelled = true;
|
|
77
|
+
};
|
|
78
|
+
}, [getAuthConfig]);
|
|
57
79
|
const navigate = useNavigate();
|
|
58
80
|
const location = useLocation();
|
|
59
81
|
const { appName } = useParams();
|
|
@@ -237,7 +259,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
|
|
|
237
259
|
const expressionUser = user
|
|
238
260
|
? { name: user.name, email: user.email, role: user.role ?? 'user' }
|
|
239
261
|
: { name: 'Anonymous', email: '', role: 'guest' };
|
|
240
|
-
return (_jsxs(ExpressionProvider, { user: expressionUser, app: activeApp, data: {}, children: [_jsx(NavigationSyncEffect, {}), _jsxs(ConsoleLayout, { activeAppName: activeApp.name, activeApp: activeApp, onAppChange: handleAppChange, objects: allObjects, connectionState: connectionState, userId: user?.id, children: [_jsx(CommandPalette, { apps: apps, activeApp: activeApp, objects: allObjects, onAppChange: handleAppChange, dataSource: dataSource }), _jsx(KeyboardShortcutsDialog, {}), _jsx(OnboardingWalkthrough, {}), _jsx(ErrorBoundary, { children: _jsx(Suspense, { fallback: _jsx(LoadingScreen, {}), children: _jsx(RouteFader, { children: _jsxs(Routes, { children: [_jsx(Route, { path: "/", element: _jsx(Navigate, { to: resolveLandingRoute(activeApp), replace: true }) }), _jsx(Route, { path: ":objectName", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit, externalRefreshKey: refreshKey }) }), _jsx(Route, { path: ":objectName/new", element: _jsx(RecordFormPage, { mode: "create" }) }), _jsx(Route, { path: ":objectName/view/:viewId", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit, externalRefreshKey: refreshKey }) }), _jsx(Route, { path: ":objectName/record/:recordId", element: _jsx(RecordDetailView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit }, refreshKey) }), _jsx(Route, { path: ":objectName/record/:recordId/edit", element: _jsx(RecordFormPage, { mode: "edit" }) }), _jsx(Route, { path: "dashboard/:dashboardName", element: _jsx(DashboardView, { dataSource: dataSource }) }), _jsx(Route, { path: "report/:reportName", element: _jsx(ReportView, { dataSource: dataSource }) }), _jsx(Route, { path: "page/:pageName", element: _jsx(PageView, {}) }), _jsx(Route, { path: "design/page/:pageName", element: _jsx(PageDesignPage, {}) }), _jsx(Route, { path: "design/dashboard/:dashboardName", element: _jsx(DashboardDesignPage, {}) }), _jsx(Route, { path: "search", element: _jsx(SearchResultsPage, {}) }), _jsx(Route, { path: "create-app", element: _jsx(CreateAppPage, {}) }), _jsx(Route, { path: "edit-app/:editAppName", element: _jsx(EditAppPage, {}) }), _jsx(Route, { path: "system/marketplace", element: _jsx(MarketplacePage, {}) }), _jsx(Route, { path: "system/marketplace/installed", element: _jsx(MarketplaceInstalledPage, {}) }), _jsx(Route, { path: "system/marketplace/:packageId", element: _jsx(MarketplacePackagePage, {}) }), extraRoutes, _jsx(Route, { path: ":objectName/:maybeRecordId", element: _jsx(ShorthandRecordRedirect, {}) }), _jsx(Route, { path: "*", element: _jsx(RouteNotFound, {}) })] }) }) }) }), currentObjectDef && (_jsx(ModalForm, { schema: {
|
|
262
|
+
return (_jsxs(ExpressionProvider, { user: expressionUser, app: activeApp, data: {}, features: features, children: [_jsx(NavigationSyncEffect, {}), _jsxs(ConsoleLayout, { activeAppName: activeApp.name, activeApp: activeApp, onAppChange: handleAppChange, objects: allObjects, connectionState: connectionState, userId: user?.id, children: [_jsx(CommandPalette, { apps: apps, activeApp: activeApp, objects: allObjects, onAppChange: handleAppChange, dataSource: dataSource }), _jsx(KeyboardShortcutsDialog, {}), _jsx(OnboardingWalkthrough, {}), _jsx(ErrorBoundary, { children: _jsx(Suspense, { fallback: _jsx(LoadingScreen, {}), children: _jsx(RouteFader, { children: _jsxs(Routes, { children: [_jsx(Route, { path: "/", element: _jsx(Navigate, { to: resolveLandingRoute(activeApp), replace: true }) }), _jsx(Route, { path: ":objectName", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit, externalRefreshKey: refreshKey }) }), _jsx(Route, { path: ":objectName/new", element: _jsx(RecordFormPage, { mode: "create" }) }), _jsx(Route, { path: ":objectName/view/:viewId", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit, externalRefreshKey: refreshKey }) }), _jsx(Route, { path: ":objectName/record/:recordId", element: _jsx(RecordDetailView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit }, refreshKey) }), _jsx(Route, { path: ":objectName/record/:recordId/edit", element: _jsx(RecordFormPage, { mode: "edit" }) }), _jsx(Route, { path: "dashboard/:dashboardName", element: _jsx(DashboardView, { dataSource: dataSource }) }), _jsx(Route, { path: "report/:reportName", element: _jsx(ReportView, { dataSource: dataSource }) }), _jsx(Route, { path: "page/:pageName", element: _jsx(PageView, {}) }), _jsx(Route, { path: "component/:ns/:name/*", element: _jsx(ComponentNavView, {}) }), _jsx(Route, { path: "design/page/:pageName", element: _jsx(PageDesignPage, {}) }), _jsx(Route, { path: "design/dashboard/:dashboardName", element: _jsx(DashboardDesignPage, {}) }), _jsx(Route, { path: "search", element: _jsx(SearchResultsPage, {}) }), _jsx(Route, { path: "create-app", element: _jsx(CreateAppPage, {}) }), _jsx(Route, { path: "edit-app/:editAppName", element: _jsx(EditAppPage, {}) }), _jsx(Route, { path: "system/marketplace", element: _jsx(MarketplacePage, {}) }), _jsx(Route, { path: "system/marketplace/installed", element: _jsx(MarketplaceInstalledPage, {}) }), _jsx(Route, { path: "system/marketplace/:packageId", element: _jsx(MarketplacePackagePage, {}) }), extraRoutes, _jsx(Route, { path: ":objectName/:maybeRecordId", element: _jsx(ShorthandRecordRedirect, {}) }), _jsx(Route, { path: "*", element: _jsx(RouteNotFound, {}) })] }) }) }) }), currentObjectDef && (_jsx(ModalForm, { schema: {
|
|
241
263
|
type: 'object-form',
|
|
242
264
|
formType: 'modal',
|
|
243
265
|
objectName: currentObjectDef.name,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface AiChatPageProps {
|
|
2
|
+
/** Override the resolved AI service base URL. */
|
|
3
|
+
apiBase?: string;
|
|
4
|
+
/** Default agent to select on first render. */
|
|
5
|
+
defaultAgent?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentProp }?: AiChatPageProps): import("react/jsx-runtime").JSX.Element;
|
|
8
|
+
export default AiChatPage;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
3
|
+
/**
|
|
4
|
+
* AiChatPage — full-page ChatGPT-style AI surface.
|
|
5
|
+
*
|
|
6
|
+
* Mounted at `/ai` (new chat) and `/ai/:conversationId` (resume an existing
|
|
7
|
+
* conversation). Left rail lists the signed-in user's `ai_conversations`;
|
|
8
|
+
* right pane embeds `ChatbotEnhanced` wired to
|
|
9
|
+
* `POST /api/v1/ai/agents/:name/chat`.
|
|
10
|
+
*
|
|
11
|
+
* Auto-persist is handled server-side in `@objectstack/service-ai`: as long
|
|
12
|
+
* as the request body carries `conversationId`, the user + assistant + tool
|
|
13
|
+
* turns are appended to `ai_messages` automatically.
|
|
14
|
+
*/
|
|
15
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
16
|
+
import { useNavigate, useParams } from 'react-router-dom';
|
|
17
|
+
import { useAuth } from '@object-ui/auth';
|
|
18
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@object-ui/components';
|
|
19
|
+
import { ChatbotEnhanced, useAgents, useObjectChat, useHitlInChat, } from '@object-ui/plugin-chatbot';
|
|
20
|
+
import { AppHeader } from '../../layout/AppHeader';
|
|
21
|
+
import { useNavigationContext } from '../../context/NavigationContext';
|
|
22
|
+
import { useChatConversation } from '../../hooks/useChatConversation';
|
|
23
|
+
import { ConversationsSidebar } from './ConversationsSidebar';
|
|
24
|
+
const DEFAULT_AI_PATH = '/api/v1/ai';
|
|
25
|
+
function resolveApiBase(explicit) {
|
|
26
|
+
if (explicit)
|
|
27
|
+
return explicit.replace(/\/$/, '');
|
|
28
|
+
const env = import.meta.env ?? {};
|
|
29
|
+
const fromEnv = env.VITE_AI_BASE_URL;
|
|
30
|
+
if (fromEnv)
|
|
31
|
+
return fromEnv.replace(/\/$/, '');
|
|
32
|
+
const serverUrl = env.VITE_SERVER_URL ?? '';
|
|
33
|
+
return `${serverUrl.replace(/\/$/, '')}${DEFAULT_AI_PATH}`;
|
|
34
|
+
}
|
|
35
|
+
export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentProp } = {}) {
|
|
36
|
+
const { user } = useAuth();
|
|
37
|
+
const userId = user?.id;
|
|
38
|
+
const { conversationId: urlConversationId } = useParams();
|
|
39
|
+
const navigate = useNavigate();
|
|
40
|
+
const { setContext } = useNavigationContext();
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
setContext('home');
|
|
43
|
+
}, [setContext]);
|
|
44
|
+
const apiBase = useMemo(() => resolveApiBase(apiBaseProp), [apiBaseProp]);
|
|
45
|
+
const env = import.meta.env ?? {};
|
|
46
|
+
const envDefaultAgent = env.VITE_AI_DEFAULT_AGENT;
|
|
47
|
+
const { agents, isLoading: agentsLoading, error: agentsError } = useAgents({ apiBase });
|
|
48
|
+
const [activeAgent, setActiveAgent] = useState(undefined);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!activeAgent && agents.length > 0) {
|
|
51
|
+
const preferred = defaultAgentProp ?? envDefaultAgent;
|
|
52
|
+
const match = preferred ? agents.find((a) => a.name === preferred) : undefined;
|
|
53
|
+
setActiveAgent((match ?? agents[0]).name);
|
|
54
|
+
}
|
|
55
|
+
}, [agents, activeAgent, defaultAgentProp, envDefaultAgent]);
|
|
56
|
+
const chatApi = activeAgent
|
|
57
|
+
? `${apiBase}/agents/${encodeURIComponent(activeAgent)}/chat`
|
|
58
|
+
: undefined;
|
|
59
|
+
const { conversationId, initialMessages, isLoading: convoLoading } = useChatConversation({
|
|
60
|
+
userId,
|
|
61
|
+
scope: activeAgent,
|
|
62
|
+
apiBase,
|
|
63
|
+
activeId: urlConversationId,
|
|
64
|
+
});
|
|
65
|
+
const [refreshKey, setRefreshKey] = useState(0);
|
|
66
|
+
// After the hook resolves a real id for a fresh `/ai` visit, mirror it into
|
|
67
|
+
// the URL so the sidebar's active-row + share/refresh both work.
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!urlConversationId && conversationId) {
|
|
70
|
+
navigate(`/ai/${conversationId}`, { replace: true });
|
|
71
|
+
}
|
|
72
|
+
}, [urlConversationId, conversationId, navigate]);
|
|
73
|
+
const titledRef = useRef(new Set());
|
|
74
|
+
// A resumed conversation already has history; treat it as already-titled
|
|
75
|
+
// so we don't clobber the original title on the next user turn.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (conversationId && initialMessages.length > 0) {
|
|
78
|
+
titledRef.current.add(conversationId);
|
|
79
|
+
}
|
|
80
|
+
}, [conversationId, initialMessages.length]);
|
|
81
|
+
const handleSent = useCallback((firstUserMessage) => {
|
|
82
|
+
// New user turn → bump sidebar list so the row's preview/timestamp refreshes.
|
|
83
|
+
setRefreshKey((k) => k + 1);
|
|
84
|
+
// Auto-title the conversation from the first user message. Server only
|
|
85
|
+
// tracks `title` when we PATCH it; without this every row shows the same
|
|
86
|
+
// "New conversation" placeholder. We mark `conversationId` as titled in a
|
|
87
|
+
// ref so subsequent turns don't re-rename and clobber a manual rename.
|
|
88
|
+
if (!firstUserMessage || !conversationId)
|
|
89
|
+
return;
|
|
90
|
+
if (titledRef.current.has(conversationId))
|
|
91
|
+
return;
|
|
92
|
+
titledRef.current.add(conversationId);
|
|
93
|
+
const text = firstUserMessage.trim();
|
|
94
|
+
if (!text)
|
|
95
|
+
return;
|
|
96
|
+
const truncated = text.length > 40 ? `${text.slice(0, 40)}…` : text;
|
|
97
|
+
fetch(`${apiBase}/conversations/${encodeURIComponent(conversationId)}`, {
|
|
98
|
+
method: 'PATCH',
|
|
99
|
+
credentials: 'include',
|
|
100
|
+
headers: { 'Content-Type': 'application/json' },
|
|
101
|
+
body: JSON.stringify({ title: truncated }),
|
|
102
|
+
})
|
|
103
|
+
.then(() => setRefreshKey((k) => k + 1))
|
|
104
|
+
.catch(() => {
|
|
105
|
+
// Leave it untitled; user can rename manually.
|
|
106
|
+
titledRef.current.delete(conversationId);
|
|
107
|
+
});
|
|
108
|
+
}, [apiBase, conversationId]);
|
|
109
|
+
return (_jsxs("div", { className: "flex h-svh w-full flex-col bg-background", "data-testid": "ai-chat-page", children: [_jsx("header", { className: "sticky top-0 z-30 flex h-14 w-full shrink-0 items-center gap-2 border-b bg-background px-2 sm:px-4", children: _jsx(AppHeader, { variant: "home" }) }), _jsxs("div", { className: "flex flex-1 min-h-0 w-full", children: [_jsx(ConversationsSidebar, { userId: userId, apiBase: apiBase, refreshKey: refreshKey, className: "hidden w-72 shrink-0 border-r md:flex" }), _jsx("main", { className: "flex flex-1 min-w-0 flex-col", children: _jsx(ChatPane, { agents: agents, agentsLoading: agentsLoading, agentsError: agentsError, activeAgent: activeAgent, onAgentChange: setActiveAgent, chatApi: chatApi, apiBase: apiBase, conversationId: conversationId, initialMessages: initialMessages, hydrating: convoLoading, onSent: handleSent }, `${chatApi ?? 'local'}:${conversationId ?? 'pending'}`) })] })] }));
|
|
110
|
+
}
|
|
111
|
+
function ChatPane({ agents, agentsLoading, agentsError, activeAgent, onAgentChange, chatApi, apiBase, conversationId, initialMessages, hydrating, onSent, }) {
|
|
112
|
+
const activeAgentLabel = useMemo(() => {
|
|
113
|
+
const found = agents.find((a) => a.name === activeAgent);
|
|
114
|
+
return found?.label ?? activeAgent ?? 'Assistant';
|
|
115
|
+
}, [agents, activeAgent]);
|
|
116
|
+
const hydrated = useMemo(() => {
|
|
117
|
+
return initialMessages.map((m) => ({
|
|
118
|
+
id: m.id,
|
|
119
|
+
role: m.role,
|
|
120
|
+
content: m.parts.map((p) => p.text).join(''),
|
|
121
|
+
}));
|
|
122
|
+
}, [initialMessages]);
|
|
123
|
+
const suggestions = useMemo(() => {
|
|
124
|
+
if (hydrated.length > 0)
|
|
125
|
+
return undefined;
|
|
126
|
+
return buildAgentSuggestions(activeAgent, activeAgentLabel);
|
|
127
|
+
}, [hydrated.length, activeAgent, activeAgentLabel]);
|
|
128
|
+
const { messages, isLoading, error, sendMessage, stop, reload, clear, } = useObjectChat({
|
|
129
|
+
api: chatApi,
|
|
130
|
+
conversationId,
|
|
131
|
+
body: {
|
|
132
|
+
context: {
|
|
133
|
+
activeApp: 'AI',
|
|
134
|
+
agentName: activeAgent,
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
initialMessages: hydrated,
|
|
138
|
+
autoResponse: !chatApi,
|
|
139
|
+
autoResponseText: "Thanks for your message! I'm here to help.",
|
|
140
|
+
autoResponseDelay: 600,
|
|
141
|
+
});
|
|
142
|
+
const hitl = useHitlInChat({
|
|
143
|
+
messages: messages,
|
|
144
|
+
apiBase,
|
|
145
|
+
continueConversation: (prompt) => {
|
|
146
|
+
sendMessage(prompt);
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
const handleSend = useCallback((content, files) => {
|
|
150
|
+
sendMessage(content, files);
|
|
151
|
+
onSent(content);
|
|
152
|
+
}, [sendMessage, onSent]);
|
|
153
|
+
const headerSlot = agents.length > 0 ? (_jsxs("div", { className: "flex flex-wrap items-center gap-2 px-4 py-2", children: [_jsx("span", { className: "text-xs text-muted-foreground", children: "Agent:" }), _jsxs(Select, { value: activeAgent, onValueChange: onAgentChange, disabled: agentsLoading, children: [_jsx(SelectTrigger, { className: "h-7 w-[220px] text-xs", "data-testid": "ai-chat-agent-picker", children: _jsx(SelectValue, { placeholder: "Choose agent..." }) }), _jsx(SelectContent, { align: "start", children: agents.map((agent) => (_jsxs(SelectItem, { value: agent.name, className: "text-xs", children: [_jsx("span", { className: "font-medium", children: agent.label }), agent.description ? (_jsx("span", { className: "block text-muted-foreground text-[10px] truncate max-w-[260px]", children: agent.description })) : null] }, agent.name))) })] }), hydrating ? (_jsx("span", { className: "text-[10px] text-muted-foreground", children: "Loading history\u2026" })) : null, agentsError ? (_jsx("span", { className: "text-[10px] text-amber-700 dark:text-amber-400", title: agentsError.message, children: "\u26A0 Offline demo mode \u2014 agent list unavailable" })) : null] })) : null;
|
|
154
|
+
return (_jsx(ChatbotEnhanced, { className: "flex-1 min-h-0 rounded-none border-0", maxHeight: "100%", headerSlot: headerSlot, messages: messages, placeholder: activeAgent
|
|
155
|
+
? `Ask ${activeAgentLabel}… (try “${(suggestions?.[0]) ?? 'How can you help?'}”)`
|
|
156
|
+
: agentsLoading
|
|
157
|
+
? 'Loading agents…'
|
|
158
|
+
: 'Ask anything…', suggestions: suggestions, onSendMessage: handleSend, onClear: clear, onStop: isLoading ? stop : undefined, onReload: reload, isLoading: isLoading, error: error, enableMarkdown: true, onToolApprove: hitl.decide, toolDecisions: hitl.decisions, toolApproveLabel: "Approve & run", toolDenyLabel: "Reject", toolDenyReason: "Operator rejected from chat", "data-testid": "ai-chat-panel" }));
|
|
159
|
+
}
|
|
160
|
+
const AGENT_SUGGESTIONS = {
|
|
161
|
+
data_chat: [
|
|
162
|
+
'系统里有多少个用户?列出他们的邮箱。',
|
|
163
|
+
'帮我列出最近创建的 5 条记录。',
|
|
164
|
+
'统计每个对象的记录数。',
|
|
165
|
+
],
|
|
166
|
+
metadata_assistant: [
|
|
167
|
+
'系统里注册了哪些对象类型?',
|
|
168
|
+
'sys_user 对象有哪些字段?',
|
|
169
|
+
'描述一下用户相关的对象关系。',
|
|
170
|
+
],
|
|
171
|
+
};
|
|
172
|
+
const GENERIC_SUGGESTIONS = [
|
|
173
|
+
'What can you help me with?',
|
|
174
|
+
'List the available data objects.',
|
|
175
|
+
'Summarize my recent activity.',
|
|
176
|
+
];
|
|
177
|
+
function buildAgentSuggestions(agentName, agentLabel) {
|
|
178
|
+
if (agentName && AGENT_SUGGESTIONS[agentName]) {
|
|
179
|
+
return AGENT_SUGGESTIONS[agentName];
|
|
180
|
+
}
|
|
181
|
+
const lower = (agentName ?? agentLabel).toLowerCase();
|
|
182
|
+
if (lower.includes('data'))
|
|
183
|
+
return AGENT_SUGGESTIONS.data_chat;
|
|
184
|
+
if (lower.includes('metadata'))
|
|
185
|
+
return AGENT_SUGGESTIONS.metadata_assistant;
|
|
186
|
+
return GENERIC_SUGGESTIONS;
|
|
187
|
+
}
|
|
188
|
+
export default AiChatPage;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface ConversationsSidebarProps {
|
|
2
|
+
userId: string | undefined;
|
|
3
|
+
apiBase: string;
|
|
4
|
+
className?: string;
|
|
5
|
+
refreshKey?: number | string;
|
|
6
|
+
}
|
|
7
|
+
export declare function ConversationsSidebar({ userId, apiBase, className, refreshKey, }: ConversationsSidebarProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
3
|
+
/**
|
|
4
|
+
* Left sidebar listing the signed-in user's AI conversations. Active row is
|
|
5
|
+
* derived from `useParams<{ conversationId }>()`; clicking a row navigates to
|
|
6
|
+
* `/ai/:id`, the "New chat" button navigates to `/ai`.
|
|
7
|
+
*/
|
|
8
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
9
|
+
import { useNavigate, useParams } from 'react-router-dom';
|
|
10
|
+
import { Plus, Trash2, Pencil, MessageSquare, Search, Check, X } from 'lucide-react';
|
|
11
|
+
import { Button, Input, ScrollArea, Empty, EmptyTitle, EmptyDescription, cn, } from '@object-ui/components';
|
|
12
|
+
import { useConversationList } from '../../hooks/useConversationList';
|
|
13
|
+
function formatTimestamp(iso) {
|
|
14
|
+
if (!iso)
|
|
15
|
+
return '';
|
|
16
|
+
const d = new Date(iso);
|
|
17
|
+
if (Number.isNaN(d.getTime()))
|
|
18
|
+
return '';
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
const diff = now - d.getTime();
|
|
21
|
+
const min = 60 * 1000;
|
|
22
|
+
const hour = 60 * min;
|
|
23
|
+
const day = 24 * hour;
|
|
24
|
+
if (diff < min)
|
|
25
|
+
return 'just now';
|
|
26
|
+
if (diff < hour)
|
|
27
|
+
return `${Math.floor(diff / min)}m ago`;
|
|
28
|
+
if (diff < day)
|
|
29
|
+
return `${Math.floor(diff / hour)}h ago`;
|
|
30
|
+
if (diff < 7 * day)
|
|
31
|
+
return `${Math.floor(diff / day)}d ago`;
|
|
32
|
+
return d.toLocaleDateString();
|
|
33
|
+
}
|
|
34
|
+
export function ConversationsSidebar({ userId, apiBase, className, refreshKey, }) {
|
|
35
|
+
const navigate = useNavigate();
|
|
36
|
+
const { conversationId: activeId } = useParams();
|
|
37
|
+
const { conversations, isLoading, error, remove, rename } = useConversationList({
|
|
38
|
+
userId,
|
|
39
|
+
apiBase,
|
|
40
|
+
refreshKey,
|
|
41
|
+
});
|
|
42
|
+
const [filter, setFilter] = useState('');
|
|
43
|
+
const [renamingId, setRenamingId] = useState(undefined);
|
|
44
|
+
const visible = useMemo(() => {
|
|
45
|
+
const q = filter.trim().toLowerCase();
|
|
46
|
+
if (!q)
|
|
47
|
+
return conversations;
|
|
48
|
+
return conversations.filter((c) => {
|
|
49
|
+
const hay = `${c.title ?? ''} ${c.preview ?? ''}`.toLowerCase();
|
|
50
|
+
return hay.includes(q);
|
|
51
|
+
});
|
|
52
|
+
}, [conversations, filter]);
|
|
53
|
+
const handleNew = useCallback(() => navigate('/ai'), [navigate]);
|
|
54
|
+
const handleDelete = useCallback(async (e, id) => {
|
|
55
|
+
e.stopPropagation();
|
|
56
|
+
await remove(id);
|
|
57
|
+
if (id === activeId)
|
|
58
|
+
navigate('/ai', { replace: true });
|
|
59
|
+
}, [remove, activeId, navigate]);
|
|
60
|
+
const handleRenameSubmit = useCallback(async (id, title) => {
|
|
61
|
+
setRenamingId(undefined);
|
|
62
|
+
try {
|
|
63
|
+
await rename(id, title);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// optimistic update already rolled back via refetch in the hook
|
|
67
|
+
}
|
|
68
|
+
}, [rename]);
|
|
69
|
+
return (_jsxs("aside", { className: cn('flex h-full min-h-0 flex-col bg-muted/30', className), "data-testid": "ai-conversations-sidebar", children: [_jsxs("div", { className: "flex shrink-0 flex-col gap-2 border-b px-3 py-2", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-sm font-medium", children: "Chats" }), _jsxs(Button, { size: "sm", variant: "outline", onClick: handleNew, "data-testid": "ai-new-chat", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), "New"] })] }), _jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { value: filter, onChange: (e) => setFilter(e.target.value), placeholder: "Search chats...", className: "h-7 pl-7 text-xs", "data-testid": "ai-conversations-search" })] })] }), _jsx(ScrollArea, { className: "flex-1 min-h-0", children: isLoading && conversations.length === 0 ? (_jsx("div", { className: "px-3 py-4 text-xs text-muted-foreground", children: "Loading\u2026" })) : error ? (_jsx("div", { className: "px-3 py-4 text-xs text-destructive", children: error.message })) : conversations.length === 0 ? (_jsxs(Empty, { className: "px-3 py-8", children: [_jsx(MessageSquare, { className: "h-8 w-8 text-muted-foreground" }), _jsx(EmptyTitle, { children: "No chats yet" }), _jsx(EmptyDescription, { children: "Start a new conversation to see it here." })] })) : visible.length === 0 ? (_jsx("div", { className: "px-3 py-4 text-xs text-muted-foreground", children: "No matching chats." })) : (_jsx("ul", { className: "flex flex-col py-1", children: visible.map((c) => (_jsx(ConversationRow, { conversation: c, active: c.id === activeId, renaming: c.id === renamingId, onSelect: () => navigate(`/ai/${c.id}`), onDelete: (e) => handleDelete(e, c.id), onStartRename: () => setRenamingId(c.id), onCancelRename: () => setRenamingId(undefined), onSubmitRename: (title) => handleRenameSubmit(c.id, title) }, c.id))) })) })] }));
|
|
70
|
+
}
|
|
71
|
+
function ConversationRow({ conversation, active, renaming, onSelect, onDelete, onStartRename, onCancelRename, onSubmitRename, }) {
|
|
72
|
+
const title = conversation.title?.trim() || conversation.preview?.trim() || 'New conversation';
|
|
73
|
+
const [draft, setDraft] = useState(title);
|
|
74
|
+
const inputRef = useRef(null);
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (renaming) {
|
|
77
|
+
setDraft(conversation.title?.trim() || conversation.preview?.trim() || '');
|
|
78
|
+
// Focus the input on next paint so the click that opened it doesn't blur immediately.
|
|
79
|
+
setTimeout(() => inputRef.current?.select(), 0);
|
|
80
|
+
}
|
|
81
|
+
}, [renaming, conversation.title, conversation.preview]);
|
|
82
|
+
if (renaming) {
|
|
83
|
+
return (_jsx("li", { children: _jsxs("div", { className: cn('flex w-full items-center gap-1 border-l-2 px-3 py-2', active ? 'border-primary bg-accent' : 'border-transparent'), "data-testid": `ai-conversation-rename-row-${conversation.id}`, children: [_jsx(Input, { ref: inputRef, value: draft, onChange: (e) => setDraft(e.target.value), onKeyDown: (e) => {
|
|
84
|
+
if (e.key === 'Enter') {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
onSubmitRename(draft);
|
|
87
|
+
}
|
|
88
|
+
else if (e.key === 'Escape') {
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
onCancelRename();
|
|
91
|
+
}
|
|
92
|
+
}, className: "h-7 flex-1 text-sm", "data-testid": `ai-conversation-rename-input-${conversation.id}`, "aria-label": "Rename conversation" }), _jsx(Button, { size: "sm", variant: "ghost", className: "h-7 w-7 p-0", onClick: () => onSubmitRename(draft), "data-testid": `ai-conversation-rename-confirm-${conversation.id}`, "aria-label": "Save rename", children: _jsx(Check, { className: "h-3.5 w-3.5" }) }), _jsx(Button, { size: "sm", variant: "ghost", className: "h-7 w-7 p-0", onClick: onCancelRename, "aria-label": "Cancel rename", children: _jsx(X, { className: "h-3.5 w-3.5" }) })] }) }));
|
|
93
|
+
}
|
|
94
|
+
return (_jsx("li", { children: _jsxs("button", { type: "button", onClick: onSelect, className: cn('group flex w-full flex-col gap-0.5 border-l-2 border-transparent px-3 py-2 text-left text-sm transition-colors hover:bg-accent/50', active && 'border-primary bg-accent'), "data-testid": `ai-conversation-row-${conversation.id}`, children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsx("span", { className: "line-clamp-1 flex-1 font-medium", children: title }), _jsxs("div", { className: "flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100", children: [_jsx("span", { role: "button", tabIndex: 0, onClick: (e) => {
|
|
95
|
+
e.stopPropagation();
|
|
96
|
+
onStartRename();
|
|
97
|
+
}, onKeyDown: (e) => {
|
|
98
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
99
|
+
e.stopPropagation();
|
|
100
|
+
onStartRename();
|
|
101
|
+
}
|
|
102
|
+
}, className: "hover:text-primary", "data-testid": `ai-conversation-rename-${conversation.id}`, "aria-label": "Rename conversation", children: _jsx(Pencil, { className: "h-3.5 w-3.5" }) }), _jsx("span", { role: "button", tabIndex: 0, onClick: (e) => {
|
|
103
|
+
e.stopPropagation();
|
|
104
|
+
onDelete(e);
|
|
105
|
+
}, onKeyDown: (e) => {
|
|
106
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
107
|
+
e.stopPropagation();
|
|
108
|
+
onDelete(e);
|
|
109
|
+
}
|
|
110
|
+
}, className: "hover:text-destructive", "data-testid": `ai-conversation-delete-${conversation.id}`, "aria-label": "Delete conversation", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) })] })] }), conversation.preview && conversation.preview !== title ? (_jsx("span", { className: "line-clamp-1 text-xs text-muted-foreground", children: conversation.preview })) : null, _jsx("span", { className: "text-[10px] text-muted-foreground", children: formatTimestamp(conversation.updatedAt ?? conversation.createdAt) })] }) }));
|
|
111
|
+
}
|
|
@@ -2,15 +2,32 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
/**
|
|
3
3
|
* Login Page for ObjectStack Console
|
|
4
4
|
*/
|
|
5
|
+
import { useEffect, useState } from 'react';
|
|
5
6
|
import { useNavigate, Link } from 'react-router-dom';
|
|
6
|
-
import { LoginForm } from '@object-ui/auth';
|
|
7
|
+
import { LoginForm, useAuth } from '@object-ui/auth';
|
|
7
8
|
import { useObjectTranslation } from '@object-ui/i18n';
|
|
8
9
|
import { AuthPageLayout } from './AuthPageLayout';
|
|
9
10
|
const RouterLink = ({ href, className, children }) => (_jsx(Link, { to: href, className: className, children: children }));
|
|
10
11
|
export function LoginPage() {
|
|
11
12
|
const navigate = useNavigate();
|
|
12
13
|
const { t } = useObjectTranslation();
|
|
13
|
-
|
|
14
|
+
const { getAuthConfig } = useAuth();
|
|
15
|
+
// Hide the "Sign up" link when the deployment has disabled
|
|
16
|
+
// self-service registration (env `OS_DISABLE_SIGNUP=true` or
|
|
17
|
+
// `emailAndPassword.disableSignUp` in objectstack.config.ts). We start
|
|
18
|
+
// undefined so we don't flicker the link on first paint, and pass
|
|
19
|
+
// `undefined` (LoginForm hides the link) once we know signup is off.
|
|
20
|
+
const [signUpDisabled, setSignUpDisabled] = useState(undefined);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
let cancelled = false;
|
|
23
|
+
getAuthConfig()
|
|
24
|
+
.then(cfg => { if (!cancelled)
|
|
25
|
+
setSignUpDisabled(cfg?.emailPassword?.disableSignUp === true); })
|
|
26
|
+
.catch(() => { if (!cancelled)
|
|
27
|
+
setSignUpDisabled(false); });
|
|
28
|
+
return () => { cancelled = true; };
|
|
29
|
+
}, [getAuthConfig]);
|
|
30
|
+
return (_jsx(AuthPageLayout, { children: _jsx(LoginForm, { onSuccess: () => navigate('/'), registerUrl: signUpDisabled ? undefined : '/register', forgotPasswordUrl: "/forgot-password", title: t('auth.login.title'), description: t('auth.login.description'), linkComponent: RouterLink, labels: {
|
|
14
31
|
emailLabel: t('auth.login.emailLabel'),
|
|
15
32
|
emailPlaceholder: t('auth.login.emailPlaceholder'),
|
|
16
33
|
passwordLabel: t('auth.login.passwordLabel'),
|
|
@@ -2,14 +2,43 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
/**
|
|
3
3
|
* Register Page for ObjectStack Console
|
|
4
4
|
*/
|
|
5
|
+
import { useEffect, useState } from 'react';
|
|
5
6
|
import { useNavigate, Link } from 'react-router-dom';
|
|
6
|
-
import { RegisterForm } from '@object-ui/auth';
|
|
7
|
+
import { RegisterForm, useAuth } from '@object-ui/auth';
|
|
7
8
|
import { useObjectTranslation } from '@object-ui/i18n';
|
|
8
9
|
import { AuthPageLayout } from './AuthPageLayout';
|
|
9
10
|
const RouterLink = ({ href, className, children }) => (_jsx(Link, { to: href, className: className, children: children }));
|
|
10
11
|
export function RegisterPage() {
|
|
11
12
|
const navigate = useNavigate();
|
|
12
13
|
const { t } = useObjectTranslation();
|
|
14
|
+
const { getAuthConfig } = useAuth();
|
|
15
|
+
// Defense-in-depth: even if a user lands on /register directly when
|
|
16
|
+
// signup is disabled, bounce them to /login. The server-side
|
|
17
|
+
// `disableSignUp` (set by env `OS_DISABLE_SIGNUP=true` or the
|
|
18
|
+
// `emailAndPassword.disableSignUp` config option) will still 403 any
|
|
19
|
+
// submission, but redirecting here avoids a confusing form.
|
|
20
|
+
const [allowed, setAllowed] = useState(undefined);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
let cancelled = false;
|
|
23
|
+
getAuthConfig()
|
|
24
|
+
.then(cfg => {
|
|
25
|
+
if (cancelled)
|
|
26
|
+
return;
|
|
27
|
+
if (cfg?.emailPassword?.disableSignUp === true) {
|
|
28
|
+
navigate('/login', { replace: true });
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
setAllowed(true);
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
.catch(() => { if (!cancelled)
|
|
35
|
+
setAllowed(true); });
|
|
36
|
+
return () => { cancelled = true; };
|
|
37
|
+
}, [getAuthConfig, navigate]);
|
|
38
|
+
if (allowed !== true) {
|
|
39
|
+
// Render nothing until we know the flag — prevents a flash of the form.
|
|
40
|
+
return _jsx(AuthPageLayout, { children: null });
|
|
41
|
+
}
|
|
13
42
|
return (_jsx(AuthPageLayout, { children: _jsx(RegisterForm, { onSuccess: () => navigate('/'), loginUrl: "/login", title: t('auth.register.title'), description: t('auth.register.description'), linkComponent: RouterLink, labels: {
|
|
14
43
|
nameLabel: t('auth.register.nameLabel'),
|
|
15
44
|
namePlaceholder: t('auth.register.namePlaceholder'),
|
|
@@ -10,9 +10,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
10
10
|
import { Card, CardContent, Button } from '@object-ui/components';
|
|
11
11
|
import { Lock } from 'lucide-react';
|
|
12
12
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
13
|
+
import { useObjectTranslation } from '@object-ui/i18n';
|
|
13
14
|
export function MarketplaceAccessDenied() {
|
|
14
15
|
const navigate = useNavigate();
|
|
15
16
|
const { appName } = useParams();
|
|
17
|
+
const { t } = useObjectTranslation();
|
|
16
18
|
const home = appName ? `/apps/${appName}` : '/';
|
|
17
|
-
return (_jsx("div", { className: "container mx-auto max-w-2xl px-6 py-16", children: _jsx(Card, { children: _jsxs(CardContent, { className: "flex flex-col items-center gap-4 py-12 text-center", children: [_jsx("div", { className: "rounded-full bg-muted p-3", children: _jsx(Lock, { className: "h-6 w-6 text-muted-foreground" }) }), _jsxs("div", { children: [_jsx("h2", { className: "text-lg font-semibold", children:
|
|
19
|
+
return (_jsx("div", { className: "container mx-auto max-w-2xl px-6 py-16", children: _jsx(Card, { children: _jsxs(CardContent, { className: "flex flex-col items-center gap-4 py-12 text-center", children: [_jsx("div", { className: "rounded-full bg-muted p-3", children: _jsx(Lock, { className: "h-6 w-6 text-muted-foreground" }) }), _jsxs("div", { children: [_jsx("h2", { className: "text-lg font-semibold", children: t('marketplace.accessDenied.title') }), _jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: t('marketplace.accessDenied.description') })] }), _jsx(Button, { variant: "outline", onClick: () => navigate(home), children: t('marketplace.action.backHome') })] }) }) }));
|
|
18
20
|
}
|