feat(console2): Models, Applications, Stores, Chat admin surfaces (@hanzo/gui /v1)

Mirror the Providers pattern: product-module + list/edit views on @hanzo/gui +
typed /v1 API modules, registered in the nav. tsc 0 errors, next build green.
This commit is contained in:
Antje Worring
2026-06-21 15:58:05 -07:00
parent 7a3dd59128
commit cf5d3094ae
21 changed files with 1437 additions and 149 deletions
+15 -2
View File
@@ -68,13 +68,26 @@ src/
DashboardShell.tsx sidebar (from registry) + topbar (adapts dashboard-shell recipe)
AuthGate.tsx gate authenticated routes
SignInForm.tsx adapts sign-in-form recipe; IAM redirect
ui/ PageHeader, DataTable, ResourceList, Field*
ui/ PageHeader, DataTable, Field*
products/
ProvidersModule.tsx FULL surface: list + view/edit
providers/ logic.ts (pure cascade/visibility), List/Edit views
Models|Applications|Stores|ChatModule.tsx list surfaces
ModelsModule.tsx routes list <-> new/edit
models/ logic.ts (newModelRoute), ModelRoute List/Edit views
ApplicationsModule.tsx routes list <-> edit
applications/ logic.ts (newApplication), List/Edit (deploy/undeploy)
StoresModule.tsx routes list <-> edit
stores/ logic.ts (newStore), List (refresh-vectors)/Edit views
ChatModule.tsx routes list <-> read-only chat view
chat/ ChatListView + ChatView (message thread)
```
Each product module mirrors Providers: a router module (`<X>Module.tsx`), a
list view + an edit/view, and a pure `logic.ts` (new-record templates / option
lists). Every module declares a `''` (list) and `:name` (edit/view) route in the
registry; Models also handles `:name === 'new'` for create (model routes are
keyed by `owner/modelName`, so modelName is form-entered, not generated).
## /v1 backend client
One `request()` in `lib/api/client.ts`: always `credentials: 'include'` (the
+26 -18
View File
@@ -1,25 +1,33 @@
'use client'
import { ApplicationApi, type Application } from '~/lib/api'
import { ResourceList } from '~/components/ui/ResourceList'
import { useSession } from '~/lib/auth/session'
/**
* Applications admin — list + edit with deploy/undeploy, native on @hanzo/gui.
*
* Logic ported from ApplicationListPage.js + ApplicationEditPage.js +
* backend/ApplicationBackend.js: the new-application template, the field set
* (template/namespace/parameters/status), and the deploy/undeploy lifecycle. UI
* rebuilt clean on GUI primitives (no antd).
*
* Routing: `/applications` lists; `/applications/<name>` edits one.
*/
import { useRouter } from 'next/navigation'
export function ApplicationsModule(_props: { params: Record<string, string> }) {
const { account } = useSession()
const owner = account?.name ?? 'admin'
import { ApplicationListView } from './applications/ApplicationListView'
import { ApplicationEditView } from './applications/ApplicationEditView'
export function ApplicationsModule({ params }: { params: Record<string, string> }) {
const router = useRouter()
const name = params.name
if (name) {
return (
<ApplicationEditView
name={decodeURIComponent(name)}
onDone={() => router.push('/applications')}
/>
)
}
return (
<ResourceList<Application>
title="Applications"
subtitle="Deployed applications."
rowKey={(r) => `${r.owner}/${r.name}`}
columns={[
{ key: 'name', header: 'Name' },
{ key: 'displayName', header: 'Display name' },
{ key: 'category', header: 'Category', width: 140 },
{ key: 'state', header: 'State', width: 120 },
]}
load={async () => (await ApplicationApi.list({ owner })).rows}
/>
<ApplicationListView onOpen={(a) => router.push(`/applications/${encodeURIComponent(a.name)}`)} />
)
}
+21 -21
View File
@@ -1,26 +1,26 @@
'use client'
import { ChatApi, type Chat } from '~/lib/api'
import { ResourceList } from '~/components/ui/ResourceList'
import { useSession } from '~/lib/auth/session'
/**
* Chat admin — session list + read-only chat view, native on @hanzo/gui.
*
* Logic ported from ChatListPage.js + ChatPage.js + backend/ChatBackend.js (and
* MessageBackend.js for the thread): list chats (`get-chats`), open one to read
* its message thread (`get-messages?owner&chat`), delete (`delete-chat`). UI
* rebuilt clean on GUI primitives (no antd).
*
* Routing: `/chat` lists; `/chat/<name>` views one session.
*/
import { useRouter } from 'next/navigation'
export function ChatModule(_props: { params: Record<string, string> }) {
const { account } = useSession()
const user = account?.name ?? 'admin'
import { ChatListView } from './chat/ChatListView'
import { ChatView } from './chat/ChatView'
return (
<ResourceList<Chat>
title="Chat"
subtitle="Chat sessions and history."
rowKey={(r) => `${r.owner}/${r.name}`}
columns={[
{ key: 'name', header: 'Name' },
{ key: 'displayName', header: 'Display name' },
{ key: 'user', header: 'User' },
{ key: 'store', header: 'Store' },
{ key: 'messageCount', header: 'Messages', width: 110 },
]}
load={async () => (await ChatApi.list({ user })).rows}
/>
)
export function ChatModule({ params }: { params: Record<string, string> }) {
const router = useRouter()
const name = params.name
if (name) {
return <ChatView name={decodeURIComponent(name)} onDone={() => router.push('/chat')} />
}
return <ChatListView onOpen={(c) => router.push(`/chat/${encodeURIComponent(c.name)}`)} />
}
+31 -16
View File
@@ -1,24 +1,39 @@
'use client'
import { ModelRouteApi, type ModelRoute } from '~/lib/api'
import { ResourceList } from '~/components/ui/ResourceList'
import { useSession } from '~/lib/auth/session'
/**
* Models admin — model-route list + create/edit, native on @hanzo/gui.
*
* Logic ported from ModelRouteListPage.js + ModelRouteEditPage.js +
* backend/ModelRouteBackend.js: the route field set (provider/upstream/fallbacks/
* pricing/flags), the new-route template, and the add/update/delete calls. UI
* rebuilt clean on GUI primitives (no antd).
*
* Routing: `/models` lists; `/models/new` creates; `/models/<modelName>` edits.
*/
import { useRouter } from 'next/navigation'
export function ModelsModule(_props: { params: Record<string, string> }) {
const { account } = useSession()
const owner = account?.name ?? 'admin'
import { ModelRouteListView } from './models/ModelRouteListView'
import { ModelRouteEditView } from './models/ModelRouteEditView'
export function ModelsModule({ params }: { params: Record<string, string> }) {
const router = useRouter()
const name = params.name
if (name === 'new') {
return <ModelRouteEditView modelName={null} onDone={() => router.push('/models')} />
}
if (name) {
return (
<ModelRouteEditView
modelName={decodeURIComponent(name)}
onDone={() => router.push('/models')}
/>
)
}
return (
<ResourceList<ModelRoute>
title="Models"
subtitle="Model routes and routing policy."
rowKey={(r) => `${r.owner}/${r.name}`}
columns={[
{ key: 'name', header: 'Name' },
{ key: 'modelName', header: 'Model' },
{ key: 'state', header: 'State', width: 120 },
]}
load={async () => (await ModelRouteApi.list({ owner })).rows}
<ModelRouteListView
onOpen={(r) => router.push(`/models/${encodeURIComponent(r.modelName ?? '')}`)}
onNew={() => router.push('/models/new')}
/>
)
}
+20 -20
View File
@@ -1,25 +1,25 @@
'use client'
import { StoreApi, type Store } from '~/lib/api'
import { ResourceList } from '~/components/ui/ResourceList'
import { useSession } from '~/lib/auth/session'
/**
* Stores admin — list + edit, native on @hanzo/gui.
*
* Logic ported from StoreListPage.js + StoreEditPage.js + backend/StoreBackend.js:
* the new-store template, the provider/chunking/chat field set, and the add/update/
* delete + refresh-vectors calls. UI rebuilt clean on GUI primitives (no antd).
*
* Routing: `/stores` lists; `/stores/<name>` edits one.
*/
import { useRouter } from 'next/navigation'
export function StoresModule(_props: { params: Record<string, string> }) {
const { account } = useSession()
const owner = account?.name ?? 'admin'
import { StoreListView } from './stores/StoreListView'
import { StoreEditView } from './stores/StoreEditView'
return (
<ResourceList<Store>
title="Stores"
subtitle="Knowledge stores and vectors."
rowKey={(r) => `${r.owner}/${r.name}`}
columns={[
{ key: 'name', header: 'Name' },
{ key: 'displayName', header: 'Display name' },
{ key: 'modelProvider', header: 'Model provider' },
{ key: 'embeddingProvider', header: 'Embedding provider' },
]}
load={async () => await StoreApi.list(owner)}
/>
)
export function StoresModule({ params }: { params: Record<string, string> }) {
const router = useRouter()
const name = params.name
if (name) {
return <StoreEditView name={decodeURIComponent(name)} onDone={() => router.push('/stores')} />
}
return <StoreListView onOpen={(s) => router.push(`/stores/${encodeURIComponent(s.name)}`)} />
}
@@ -0,0 +1,166 @@
'use client'
import { useEffect, useState } from 'react'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { ApiError, ApplicationApi, type Application } from '~/lib/api'
import { FieldRow, FieldText, FieldTextArea } from '~/components/ui/Field'
import { PageHeader } from '~/components/ui/PageHeader'
import { isUndeployed } from './logic'
/** Application editor. Loaded by owner/name; supports deploy/undeploy. */
export function ApplicationEditView({ name, onDone }: { name: string; onDone: () => void }) {
const [app, setApp] = useState<Application | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const reload = (n: string) =>
ApplicationApi.get('admin', n)
.then((a) => {
setApp(a)
setError(null)
})
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : 'Failed to load application'))
useEffect(() => {
let live = true
setLoading(true)
ApplicationApi.get('admin', name)
.then((a) => {
if (live) {
setApp(a)
setError(null)
}
})
.catch((e: unknown) => {
if (live) setError(e instanceof ApiError ? e.message : 'Failed to load application')
})
.finally(() => {
if (live) setLoading(false)
})
return () => {
live = false
}
}, [name])
if (loading) {
return (
<XStack p="$6" justify="center">
<Spinner size="large" color="$color11" />
</XStack>
)
}
if (error && !app) {
return (
<YStack gap="$3">
<Text color="$red10">{error}</Text>
<Button self="flex-start" onPress={onDone}>
Back
</Button>
</YStack>
)
}
if (!app) return null
const a = app
const set = (patch: Partial<Application>) => setApp({ ...a, ...patch })
const save = async (exit: boolean) => {
setSaving(true)
setError(null)
try {
await ApplicationApi.update(a.owner, name, a)
if (exit) onDone()
} catch (e) {
setError(e instanceof ApiError ? e.message : 'Failed to save application')
} finally {
setSaving(false)
}
}
const deploy = async () => {
setSaving(true)
setError(null)
try {
await ApplicationApi.deploy(a)
await reload(name)
} catch (e) {
setError(e instanceof ApiError ? e.message : 'Failed to deploy application')
} finally {
setSaving(false)
}
}
const undeploy = async () => {
if (typeof window !== 'undefined' && !window.confirm(`Undeploy "${a.name}"?`)) return
setSaving(true)
setError(null)
try {
await ApplicationApi.undeploy(a.owner, name)
await reload(name)
} catch (e) {
setError(e instanceof ApiError ? e.message : 'Failed to undeploy application')
} finally {
setSaving(false)
}
}
return (
<>
<PageHeader
title="Edit application"
subtitle={a.name}
actions={
<XStack gap="$2">
<Button onPress={onDone}>Back</Button>
<Button disabled={saving} onPress={() => void save(false)}>
Save
</Button>
<Button theme="blue" disabled={saving} onPress={() => void save(true)}>
Save & Exit
</Button>
</XStack>
}
/>
{error ? <Text color="$red10">{error}</Text> : null}
<Card p="$4" gap="$3.5" borderWidth={1} borderColor="$borderColor">
<FieldRow label="Name">
<FieldText value={a.name} onChange={(v) => set({ name: v })} disabled />
</FieldRow>
<FieldRow label="Display name">
<FieldText value={a.displayName ?? ''} onChange={(v) => set({ displayName: v })} />
</FieldRow>
<FieldRow label="Description">
<FieldText value={a.description ?? ''} onChange={(v) => set({ description: v })} />
</FieldRow>
<FieldRow label="Template">
<FieldText value={a.template ?? ''} onChange={(v) => set({ template: v })} />
</FieldRow>
<FieldRow label="Namespace">
<FieldText value={a.namespace ?? ''} onChange={(v) => set({ namespace: v })} />
</FieldRow>
<FieldRow label="Parameters">
<FieldTextArea value={a.parameters ?? ''} onChange={(v) => set({ parameters: v })} rows={5} />
</FieldRow>
<FieldRow label="Status">
<XStack gap="$3" items="center">
<Text fontSize="$3" color="$color11">
{a.status ?? 'Not Deployed'}
</Text>
{isUndeployed(a) ? (
<Button size="$2" theme="green" disabled={saving} onPress={() => void deploy()}>
Deploy
</Button>
) : (
<Button size="$2" theme="red" disabled={saving} onPress={() => void undeploy()}>
Undeploy
</Button>
)}
</XStack>
</FieldRow>
</Card>
</>
)
}
@@ -0,0 +1,124 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Button, Text, XStack } from '@hanzo/gui'
import { Plus, Trash } from '@hanzogui/lucide-icons-2'
import { ApiError, ApplicationApi, type Application } from '~/lib/api'
import { useSession } from '~/lib/auth/session'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { newApplication } from './logic'
const StatusTag = ({ status }: { status?: string }) => {
const running = status && status !== 'Not Deployed' && status !== 'Failed'
return (
<Text
fontSize="$1"
px="$2"
py="$1"
rounded="$2"
bg={running ? '$green5' : status === 'Failed' ? '$red5' : '$color3'}
color={running ? '$green11' : status === 'Failed' ? '$red11' : '$color11'}
>
{status ?? 'Not Deployed'}
</Text>
)
}
export function ApplicationListView({ onOpen }: { onOpen: (a: Application) => void }) {
const { account } = useSession()
const owner = account?.name ?? 'admin'
const [rows, setRows] = useState<Application[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const { rows } = await ApplicationApi.list({ owner })
setRows(rows ?? [])
setError(null)
} catch (e) {
setError(e instanceof ApiError ? e.message : 'Failed to load applications')
} finally {
setLoading(false)
}
}, [owner])
useEffect(() => {
void load()
}, [load])
const onAdd = async () => {
try {
const a = newApplication(owner)
await ApplicationApi.add(a)
onOpen(a)
} catch (e) {
setError(e instanceof ApiError ? e.message : 'Failed to add application')
}
}
const onDelete = async (a: Application) => {
if (typeof window !== 'undefined' && !window.confirm(`Delete application "${a.name}"?`)) return
try {
await ApplicationApi.remove(a)
setRows((rs) => rs.filter((r) => r.name !== a.name))
} catch (e) {
setError(e instanceof ApiError ? e.message : 'Failed to delete application')
}
}
const columns: Column<Application>[] = [
{
key: 'name',
header: 'Name',
render: (a) => (
<Text fontSize="$3" color="$blue11" onPress={() => onOpen(a)} cursor="pointer">
{a.name}
</Text>
),
},
{ key: 'displayName', header: 'Display name' },
{ key: 'template', header: 'Template', width: 150 },
{ key: 'namespace', header: 'Namespace', width: 200 },
{ key: 'status', header: 'Status', width: 130, render: (a) => <StatusTag status={a.status} /> },
{
key: 'action',
header: '',
width: 150,
render: (a) => (
<XStack gap="$2">
<Button size="$2" onPress={() => onOpen(a)}>
Edit
</Button>
<Button size="$2" theme="red" icon={<Trash size={14} />} onPress={() => void onDelete(a)} />
</XStack>
),
},
]
return (
<>
<PageHeader
title="Applications"
subtitle="Deployed applications."
actions={
<Button icon={<Plus size={16} />} onPress={() => void onAdd()}>
Add
</Button>
}
/>
{error ? <Text color="$red10">{error}</Text> : null}
<DataTable
columns={columns}
rows={rows}
loading={loading}
rowKey={(a) => `${a.owner}/${a.name}`}
empty="No applications yet. Click Add to create one."
/>
</>
)
}
@@ -0,0 +1,29 @@
/**
* Application domain logic — pure, no UI.
*
* Ported from ApplicationListPage.newApplication(): the new-application template
* (template/namespace/parameters/status) and the deploy lifecycle predicate.
*/
import type { Application } from '~/lib/api'
const randomSuffix = () => Math.random().toString(36).slice(2, 8)
/** A fresh application, mirroring ApplicationListPage.newApplication(). */
export function newApplication(owner: string): Application {
const suffix = randomSuffix()
return {
owner,
name: `application_${suffix}`,
displayName: `New Application - ${suffix}`,
description: '',
template: '',
namespace: `hanzo-cloud-app-${suffix}`,
parameters: '',
status: 'Not Deployed',
}
}
export const STATUS_OPTIONS = ['Not Deployed', 'Pending', 'Running', 'Failed'] as const
/** True when the app has not been deployed yet (deploy is the action). */
export const isUndeployed = (a: Application) => (a.status ?? 'Not Deployed') === 'Not Deployed'
@@ -0,0 +1,89 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Button, Text, XStack } from '@hanzo/gui'
import { Trash } from '@hanzogui/lucide-icons-2'
import { ApiError, ChatApi, type Chat } from '~/lib/api'
import { useSession } from '~/lib/auth/session'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
export function ChatListView({ onOpen }: { onOpen: (c: Chat) => void }) {
const { account } = useSession()
const user = account?.name ?? 'admin'
const [rows, setRows] = useState<Chat[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const { rows } = await ChatApi.list({ user })
setRows(rows ?? [])
setError(null)
} catch (e) {
setError(e instanceof ApiError ? e.message : 'Failed to load chats')
} finally {
setLoading(false)
}
}, [user])
useEffect(() => {
void load()
}, [load])
const onDelete = async (c: Chat) => {
if (typeof window !== 'undefined' && !window.confirm(`Delete chat "${c.name}"?`)) return
try {
await ChatApi.remove(c)
setRows((rs) => rs.filter((r) => r.name !== c.name))
} catch (e) {
setError(e instanceof ApiError ? e.message : 'Failed to delete chat')
}
}
const columns: Column<Chat>[] = [
{
key: 'name',
header: 'Name',
render: (c) => (
<Text fontSize="$3" color="$blue11" onPress={() => onOpen(c)} cursor="pointer">
{c.displayName || c.name}
</Text>
),
},
{ key: 'user', header: 'User', width: 160 },
{ key: 'store', header: 'Store', width: 160 },
{ key: 'messageCount', header: 'Messages', width: 110 },
{ key: 'updatedTime', header: 'Updated', width: 200 },
{
key: 'action',
header: '',
width: 150,
render: (c) => (
<XStack gap="$2">
<Button size="$2" onPress={() => onOpen(c)}>
View
</Button>
<Button size="$2" theme="red" icon={<Trash size={14} />} onPress={() => void onDelete(c)} />
</XStack>
),
},
]
return (
<>
<PageHeader title="Chat" subtitle="Chat sessions and history." />
{error ? <Text color="$red10">{error}</Text> : null}
<DataTable
columns={columns}
rows={rows}
loading={loading}
rowKey={(c) => `${c.owner}/${c.name}`}
empty="No chats yet."
/>
</>
)
}
+108
View File
@@ -0,0 +1,108 @@
'use client'
import { useEffect, useState } from 'react'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { ApiError, ChatApi, MessageApi, type Chat, type Message } from '~/lib/api'
import { PageHeader } from '~/components/ui/PageHeader'
const Bubble = ({ m }: { m: Message }) => {
const isAI = m.author === 'AI'
return (
<XStack justify={isAI ? 'flex-start' : 'flex-end'}>
<Card
p="$3"
maxW="80%"
bg={isAI ? '$color2' : '$blue5'}
borderWidth={1}
borderColor="$borderColor"
rounded="$4"
>
<YStack gap="$1">
<Text fontSize="$1" color="$color11">
{isAI ? 'AI' : m.author || 'User'}
</Text>
<Text fontSize="$3">{m.text ?? ''}</Text>
</YStack>
</Card>
</XStack>
)
}
/**
* Read-only chat view: the session header + its message thread.
*
* Ported from ChatPage.js / ChatBox.js: loads the chat (`get-chat`) and its
* messages (`get-messages?owner&chat`), renders each turn by author (AI vs user).
*/
export function ChatView({ name, onDone }: { name: string; onDone: () => void }) {
const [chat, setChat] = useState<Chat | null>(null)
const [messages, setMessages] = useState<Message[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let live = true
setLoading(true)
ChatApi.get('admin', name)
.then(async (c) => {
if (!live) return
setChat(c)
const msgs = await MessageApi.listForChat(c.owner, c.name)
if (live) {
setMessages(msgs ?? [])
setError(null)
}
})
.catch((e: unknown) => {
if (live) setError(e instanceof ApiError ? e.message : 'Failed to load chat')
})
.finally(() => {
if (live) setLoading(false)
})
return () => {
live = false
}
}, [name])
if (loading) {
return (
<XStack p="$6" justify="center">
<Spinner size="large" color="$color11" />
</XStack>
)
}
if (error && !chat) {
return (
<YStack gap="$3">
<Text color="$red10">{error}</Text>
<Button self="flex-start" onPress={onDone}>
Back
</Button>
</YStack>
)
}
if (!chat) return null
return (
<>
<PageHeader
title={chat.displayName || chat.name}
subtitle={`${chat.user ?? ''}${chat.store ? ` · ${chat.store}` : ''}`}
actions={<Button onPress={onDone}>Back</Button>}
/>
{error ? <Text color="$red10">{error}</Text> : null}
{messages.length === 0 ? (
<YStack p="$6" items="center" borderWidth={1} borderColor="$borderColor" rounded="$4">
<Text color="$color11">No messages in this chat.</Text>
</YStack>
) : (
<YStack gap="$3">
{messages.map((m) => (
<Bubble key={`${m.owner}/${m.name}`} m={m} />
))}
</YStack>
)}
</>
)
}
@@ -0,0 +1,186 @@
'use client'
import { useEffect, useState } from 'react'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { ApiError, ModelRouteApi, type ModelRoute } from '~/lib/api'
import { useSession } from '~/lib/auth/session'
import { FieldRow, FieldText, FieldSwitch } from '~/components/ui/Field'
import { PageHeader } from '~/components/ui/PageHeader'
import { newModelRoute } from './logic'
/**
* Model-route editor. `modelName === null` => create mode (empty form, modelName
* editable, POST add-model-route). Otherwise edit mode (loaded by owner/modelName,
* modelName read-only, POST update-model-route). Ported from ModelRouteEditPage.js.
*/
export function ModelRouteEditView({
modelName,
onDone,
}: {
modelName: string | null
onDone: () => void
}) {
const { account } = useSession()
const owner = account?.name ?? 'admin'
const isNew = modelName === null
const [route, setRoute] = useState<ModelRoute | null>(isNew ? newModelRoute(owner) : null)
const [loading, setLoading] = useState(!isNew)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (isNew) return
let live = true
setLoading(true)
ModelRouteApi.get(owner, modelName)
.then((r) => {
if (live) {
setRoute(r)
setError(null)
}
})
.catch((e: unknown) => {
if (live) setError(e instanceof ApiError ? e.message : 'Failed to load model route')
})
.finally(() => {
if (live) setLoading(false)
})
return () => {
live = false
}
}, [isNew, owner, modelName])
if (loading) {
return (
<XStack p="$6" justify="center">
<Spinner size="large" color="$color11" />
</XStack>
)
}
if (error && !route) {
return (
<YStack gap="$3">
<Text color="$red10">{error}</Text>
<Button self="flex-start" onPress={onDone}>
Back
</Button>
</YStack>
)
}
if (!route) return null
const r = route
const set = (patch: Partial<ModelRoute>) => setRoute({ ...r, ...patch })
const num = (v: string) => (Number(v) >= 0 ? Number(v) : 0)
const save = async (exit: boolean) => {
if (isNew && !r.modelName) {
setError('Model name is required')
return
}
setSaving(true)
setError(null)
try {
if (isNew) {
await ModelRouteApi.add(r)
} else {
await ModelRouteApi.update(owner, modelName, r)
}
if (exit || isNew) onDone()
} catch (e) {
setError(e instanceof ApiError ? e.message : 'Failed to save model route')
} finally {
setSaving(false)
}
}
return (
<>
<PageHeader
title={isNew ? 'New model route' : 'Edit model route'}
subtitle={r.modelName || undefined}
actions={
<XStack gap="$2">
<Button onPress={onDone}>Back</Button>
<Button disabled={saving} onPress={() => void save(false)}>
Save
</Button>
<Button theme="blue" disabled={saving} onPress={() => void save(true)}>
Save & Exit
</Button>
</XStack>
}
/>
{error ? <Text color="$red10">{error}</Text> : null}
<Card p="$4" gap="$3.5" borderWidth={1} borderColor="$borderColor">
<FieldRow label="Model name">
<FieldText
value={r.modelName ?? ''}
onChange={(v) => set({ modelName: v })}
disabled={!isNew}
placeholder="claude-sonnet-4-6"
/>
</FieldRow>
<FieldRow label="Provider">
<FieldText
value={r.provider ?? ''}
onChange={(v) => set({ provider: v })}
placeholder="Primary provider name (must match a Provider)"
/>
</FieldRow>
<FieldRow label="Upstream">
<FieldText
value={r.upstream ?? ''}
onChange={(v) => set({ upstream: v })}
placeholder="Model id sent to the upstream API"
/>
</FieldRow>
<FieldRow label="Owned by">
<FieldText
value={r.ownedBy ?? ''}
onChange={(v) => set({ ownedBy: v })}
placeholder="Override for owned_by in /v1/models"
/>
</FieldRow>
<FieldRow label="Fallback 1 provider">
<FieldText value={r.fallback1Provider ?? ''} onChange={(v) => set({ fallback1Provider: v })} />
</FieldRow>
<FieldRow label="Fallback 1 upstream">
<FieldText value={r.fallback1Upstream ?? ''} onChange={(v) => set({ fallback1Upstream: v })} />
</FieldRow>
<FieldRow label="Fallback 2 provider">
<FieldText value={r.fallback2Provider ?? ''} onChange={(v) => set({ fallback2Provider: v })} />
</FieldRow>
<FieldRow label="Fallback 2 upstream">
<FieldText value={r.fallback2Upstream ?? ''} onChange={(v) => set({ fallback2Upstream: v })} />
</FieldRow>
<FieldRow label="Input $/M">
<FieldText
value={String(r.inputPricePerMillion ?? 0)}
onChange={(v) => set({ inputPricePerMillion: num(v) })}
placeholder="0 = use default"
/>
</FieldRow>
<FieldRow label="Output $/M">
<FieldText
value={String(r.outputPricePerMillion ?? 0)}
onChange={(v) => set({ outputPricePerMillion: num(v) })}
placeholder="0 = use default"
/>
</FieldRow>
<FieldRow label="Premium">
<FieldSwitch checked={!!r.premium} onChange={(v) => set({ premium: v })} />
</FieldRow>
<FieldRow label="Hidden">
<FieldSwitch checked={!!r.hidden} onChange={(v) => set({ hidden: v })} />
</FieldRow>
<FieldRow label="Enabled">
<FieldSwitch checked={!!r.enabled} onChange={(v) => set({ enabled: v })} />
</FieldRow>
</Card>
</>
)
}
@@ -0,0 +1,130 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Button, Text, XStack } from '@hanzo/gui'
import { Plus, Trash } from '@hanzogui/lucide-icons-2'
import { ApiError, ModelRouteApi, type ModelRoute } from '~/lib/api'
import { useSession } from '~/lib/auth/session'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
const Tag = ({ on, on_label, off_label }: { on?: boolean; on_label: string; off_label: string }) => (
<Text
fontSize="$1"
px="$2"
py="$1"
rounded="$2"
bg={on ? '$green5' : '$color3'}
color={on ? '$green11' : '$color11'}
>
{on ? on_label : off_label}
</Text>
)
const price = (n?: number) => (n && n > 0 ? `$${n.toFixed(2)}` : '-')
export function ModelRouteListView({
onOpen,
onNew,
}: {
onOpen: (r: ModelRoute) => void
onNew: () => void
}) {
const { account } = useSession()
const owner = account?.name ?? 'admin'
const [rows, setRows] = useState<ModelRoute[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const { rows } = await ModelRouteApi.list({ owner })
setRows(rows ?? [])
setError(null)
} catch (e) {
setError(e instanceof ApiError ? e.message : 'Failed to load model routes')
} finally {
setLoading(false)
}
}, [owner])
useEffect(() => {
void load()
}, [load])
const onDelete = async (r: ModelRoute) => {
if (typeof window !== 'undefined' && !window.confirm(`Delete route "${r.modelName}"?`)) return
try {
await ModelRouteApi.remove(r)
setRows((rs) => rs.filter((x) => x.modelName !== r.modelName))
} catch (e) {
setError(e instanceof ApiError ? e.message : 'Failed to delete model route')
}
}
const columns: Column<ModelRoute>[] = [
{
key: 'modelName',
header: 'Model name',
render: (r) => (
<Text fontSize="$3" color="$blue11" onPress={() => onOpen(r)} cursor="pointer">
{r.modelName}
</Text>
),
},
{ key: 'provider', header: 'Provider', width: 150 },
{ key: 'upstream', header: 'Upstream', width: 220 },
{
key: 'premium',
header: 'Premium',
width: 100,
render: (r) => <Tag on={r.premium} on_label="Premium" off_label="Free" />,
},
{ key: 'inputPricePerMillion', header: 'In $/M', width: 90, render: (r) => <Text fontSize="$3">{price(r.inputPricePerMillion)}</Text> },
{ key: 'outputPricePerMillion', header: 'Out $/M', width: 90, render: (r) => <Text fontSize="$3">{price(r.outputPricePerMillion)}</Text> },
{
key: 'enabled',
header: 'Enabled',
width: 90,
render: (r) => <Tag on={r.enabled} on_label="ON" off_label="OFF" />,
},
{
key: 'action',
header: '',
width: 150,
render: (r) => (
<XStack gap="$2">
<Button size="$2" onPress={() => onOpen(r)}>
Edit
</Button>
<Button size="$2" theme="red" icon={<Trash size={14} />} onPress={() => void onDelete(r)} />
</XStack>
),
},
]
return (
<>
<PageHeader
title="Models"
subtitle="Model routes and routing policy."
actions={
<Button icon={<Plus size={16} />} onPress={onNew}>
Add
</Button>
}
/>
{error ? <Text color="$red10">{error}</Text> : null}
<DataTable
columns={columns}
rows={rows}
loading={loading}
rowKey={(r) => `${r.owner}/${r.modelName}`}
empty="No model routes yet. Click Add to create one."
/>
</>
)
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Model-route domain logic — pure, no UI.
*
* Ported from ModelRouteListPage.newModelRoute() / ModelRouteEditPage: the
* new-route template. A model route is keyed by owner/modelName, so a fresh one
* starts with an empty modelName the user fills in (the create form is the only
* place modelName is editable).
*/
import type { ModelRoute } from '~/lib/api'
const randomSuffix = () => Math.random().toString(36).slice(2, 8)
/** A fresh model route, mirroring ModelRouteListPage.newModelRoute(). */
export function newModelRoute(owner: string): ModelRoute {
return {
owner,
name: `route_${randomSuffix()}`,
modelName: '',
provider: '',
upstream: '',
ownedBy: '',
fallback1Provider: '',
fallback1Upstream: '',
fallback2Provider: '',
fallback2Upstream: '',
premium: false,
hidden: false,
inputPricePerMillion: 0,
outputPricePerMillion: 0,
enabled: true,
}
}
@@ -0,0 +1,182 @@
'use client'
import { useEffect, useState } from 'react'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { ApiError, StoreApi, type Store } from '~/lib/api'
import {
FieldRow,
FieldText,
FieldTextArea,
FieldSelect,
FieldSwitch,
} from '~/components/ui/Field'
import { PageHeader } from '~/components/ui/PageHeader'
import { SPLIT_OPTIONS, SEARCH_OPTIONS, STATE_OPTIONS } from './logic'
/** Store editor. Loaded by owner/name. Mirrors StoreEditPage.js core fields. */
export function StoreEditView({ name, onDone }: { name: string; onDone: () => void }) {
const [store, setStore] = useState<Store | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let live = true
setLoading(true)
StoreApi.get('admin', name)
.then((s) => {
if (live) {
setStore(s)
setError(null)
}
})
.catch((e: unknown) => {
if (live) setError(e instanceof ApiError ? e.message : 'Failed to load store')
})
.finally(() => {
if (live) setLoading(false)
})
return () => {
live = false
}
}, [name])
if (loading) {
return (
<XStack p="$6" justify="center">
<Spinner size="large" color="$color11" />
</XStack>
)
}
if (error && !store) {
return (
<YStack gap="$3">
<Text color="$red10">{error}</Text>
<Button self="flex-start" onPress={onDone}>
Back
</Button>
</YStack>
)
}
if (!store) return null
const s = store
const set = (patch: Partial<Store>) => setStore({ ...s, ...patch })
const int = (v: string) => (Number.isFinite(Number(v)) && Number(v) >= 0 ? Math.trunc(Number(v)) : 0)
const save = async (exit: boolean) => {
setSaving(true)
setError(null)
try {
await StoreApi.update(s.owner, name, s)
if (exit) onDone()
} catch (e) {
setError(e instanceof ApiError ? e.message : 'Failed to save store')
} finally {
setSaving(false)
}
}
return (
<>
<PageHeader
title="Edit store"
subtitle={s.name}
actions={
<XStack gap="$2">
<Button onPress={onDone}>Back</Button>
<Button disabled={saving} onPress={() => void save(false)}>
Save
</Button>
<Button theme="blue" disabled={saving} onPress={() => void save(true)}>
Save & Exit
</Button>
</XStack>
}
/>
{error ? <Text color="$red10">{error}</Text> : null}
<Card p="$4" gap="$3.5" borderWidth={1} borderColor="$borderColor">
<FieldRow label="Name">
<FieldText value={s.name} onChange={(v) => set({ name: v })} disabled />
</FieldRow>
<FieldRow label="Display name">
<FieldText value={s.displayName ?? ''} onChange={(v) => set({ displayName: v })} />
</FieldRow>
<FieldRow label="Title">
<FieldText value={s.title ?? ''} onChange={(v) => set({ title: v })} />
</FieldRow>
<FieldRow label="Default">
<FieldSwitch checked={!!s.isDefault} onChange={(v) => set({ isDefault: v })} />
</FieldRow>
<FieldRow label="State">
<FieldSelect
value={s.state ?? 'Active'}
options={[...STATE_OPTIONS]}
onChange={(v) => set({ state: v })}
/>
</FieldRow>
<FieldRow label="Storage provider">
<FieldText value={s.storageProvider ?? ''} onChange={(v) => set({ storageProvider: v })} />
</FieldRow>
<FieldRow label="Storage subpath">
<FieldText value={s.storageSubpath ?? ''} onChange={(v) => set({ storageSubpath: v })} />
</FieldRow>
<FieldRow label="Image provider">
<FieldText value={s.imageProvider ?? ''} onChange={(v) => set({ imageProvider: v })} />
</FieldRow>
<FieldRow label="Split provider">
<FieldSelect
value={s.splitProvider ?? 'Default'}
options={[...SPLIT_OPTIONS]}
onChange={(v) => set({ splitProvider: v })}
/>
</FieldRow>
<FieldRow label="Search provider">
<FieldSelect
value={s.searchProvider ?? 'Default'}
options={[...SEARCH_OPTIONS]}
onChange={(v) => set({ searchProvider: v })}
/>
</FieldRow>
<FieldRow label="Model provider">
<FieldText value={s.modelProvider ?? ''} onChange={(v) => set({ modelProvider: v })} />
</FieldRow>
<FieldRow label="Embedding provider">
<FieldText value={s.embeddingProvider ?? ''} onChange={(v) => set({ embeddingProvider: v })} />
</FieldRow>
<FieldRow label="Agent provider">
<FieldText value={s.agentProvider ?? ''} onChange={(v) => set({ agentProvider: v })} />
</FieldRow>
<FieldRow label="Memory limit">
<FieldText value={String(s.memoryLimit ?? 0)} onChange={(v) => set({ memoryLimit: int(v) })} />
</FieldRow>
<FieldRow label="Frequency">
<FieldText value={String(s.frequency ?? 0)} onChange={(v) => set({ frequency: int(v) })} />
</FieldRow>
<FieldRow label="Limit minutes">
<FieldText value={String(s.limitMinutes ?? 0)} onChange={(v) => set({ limitMinutes: int(v) })} />
</FieldRow>
<FieldRow label="Knowledge count">
<FieldText value={String(s.knowledgeCount ?? 0)} onChange={(v) => set({ knowledgeCount: int(v) })} />
</FieldRow>
<FieldRow label="Suggestion count">
<FieldText value={String(s.suggestionCount ?? 0)} onChange={(v) => set({ suggestionCount: int(v) })} />
</FieldRow>
<FieldRow label="Welcome">
<FieldText value={s.welcome ?? ''} onChange={(v) => set({ welcome: v })} />
</FieldRow>
<FieldRow label="Welcome title">
<FieldText value={s.welcomeTitle ?? ''} onChange={(v) => set({ welcomeTitle: v })} />
</FieldRow>
<FieldRow label="Welcome text">
<FieldText value={s.welcomeText ?? ''} onChange={(v) => set({ welcomeText: v })} />
</FieldRow>
<FieldRow label="Prompt">
<FieldTextArea value={s.prompt ?? ''} onChange={(v) => set({ prompt: v })} rows={5} />
</FieldRow>
</Card>
</>
)
}
@@ -0,0 +1,131 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Button, Text, XStack } from '@hanzo/gui'
import { Plus, RefreshCw, Trash } from '@hanzogui/lucide-icons-2'
import { ApiError, StoreApi, type Store } from '~/lib/api'
import { useSession } from '~/lib/auth/session'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { newStore } from './logic'
const Badge = ({ on }: { on?: boolean }) => (
<Text
fontSize="$1"
px="$2"
py="$1"
rounded="$2"
bg={on ? '$green5' : '$color3'}
color={on ? '$green11' : '$color11'}
>
{on ? 'ON' : 'OFF'}
</Text>
)
export function StoreListView({ onOpen }: { onOpen: (s: Store) => void }) {
const { account } = useSession()
const owner = account?.name ?? 'admin'
const [rows, setRows] = useState<Store[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const data = await StoreApi.list(owner)
setRows(data ?? [])
setError(null)
} catch (e) {
setError(e instanceof ApiError ? e.message : 'Failed to load stores')
} finally {
setLoading(false)
}
}, [owner])
useEffect(() => {
void load()
}, [load])
const onAdd = async () => {
try {
const s = newStore(owner)
await StoreApi.add(s)
onOpen(s)
} catch (e) {
setError(e instanceof ApiError ? e.message : 'Failed to add store')
}
}
const onRefresh = async (s: Store) => {
try {
await StoreApi.refreshVectors(s)
} catch (e) {
setError(e instanceof ApiError ? e.message : 'Failed to refresh vectors')
}
}
const onDelete = async (s: Store) => {
if (typeof window !== 'undefined' && !window.confirm(`Delete store "${s.name}"?`)) return
try {
await StoreApi.remove(s)
setRows((rs) => rs.filter((r) => r.name !== s.name))
} catch (e) {
setError(e instanceof ApiError ? e.message : 'Failed to delete store')
}
}
const columns: Column<Store>[] = [
{
key: 'name',
header: 'Name',
render: (s) => (
<Text fontSize="$3" color="$blue11" onPress={() => onOpen(s)} cursor="pointer">
{s.name}
</Text>
),
},
{ key: 'displayName', header: 'Display name' },
{ key: 'modelProvider', header: 'Model provider', width: 170 },
{ key: 'embeddingProvider', header: 'Embedding provider', width: 180 },
{ key: 'isDefault', header: 'Default', width: 80, render: (s) => <Badge on={s.isDefault} /> },
{ key: 'state', header: 'State', width: 100 },
{
key: 'action',
header: '',
width: 200,
render: (s) => (
<XStack gap="$2">
<Button size="$2" onPress={() => onOpen(s)}>
Edit
</Button>
<Button size="$2" icon={<RefreshCw size={14} />} onPress={() => void onRefresh(s)} />
<Button size="$2" theme="red" icon={<Trash size={14} />} onPress={() => void onDelete(s)} />
</XStack>
),
},
]
return (
<>
<PageHeader
title="Stores"
subtitle="Knowledge stores and vectors."
actions={
<Button icon={<Plus size={16} />} onPress={() => void onAdd()}>
Add
</Button>
}
/>
{error ? <Text color="$red10">{error}</Text> : null}
<DataTable
columns={columns}
rows={rows}
loading={loading}
rowKey={(s) => `${s.owner}/${s.name}`}
empty="No stores yet. Click Add to create one."
/>
</>
)
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Store domain logic — pure, no UI.
*
* Ported from StoreListPage.newStore(): the new-store template (provider wiring,
* chunk/search strategy, chat copy, limits). Kept to the meaningful fields the
* editor surfaces; the backend round-trips the rest via the Store passthrough.
*/
import type { Store } from '~/lib/api'
const randomSuffix = () => Math.random().toString(36).slice(2, 8)
/** A fresh store, mirroring StoreListPage.newStore(). */
export function newStore(owner: string): Store {
const suffix = randomSuffix()
return {
owner,
name: `store_${suffix}`,
displayName: `New Store - ${suffix}`,
title: `Title - ${suffix}`,
storageProvider: 'provider-storage-built-in',
storageSubpath: `store_${suffix}`,
imageProvider: '',
splitProvider: 'Default',
searchProvider: 'Default',
modelProvider: '',
embeddingProvider: '',
agentProvider: '',
textToSpeechProvider: 'Browser Built-In',
speechToTextProvider: 'Browser Built-In',
memoryLimit: 5,
frequency: 10000,
limitMinutes: 10,
knowledgeCount: 5,
suggestionCount: 3,
welcome: 'Hello',
welcomeTitle: "Hello, I'm Hanzo AI Assistant",
welcomeText: "I'm here to help answer your questions",
prompt: '',
themeColor: '#5734d3',
state: 'Active',
isDefault: false,
}
}
export const SPLIT_OPTIONS = ['Default', 'Basic', 'QA', 'Markdown'] as const
export const SEARCH_OPTIONS = ['Default', 'Hierarchy'] as const
export const STATE_OPTIONS = ['Active', 'Inactive'] as const
-64
View File
@@ -1,64 +0,0 @@
'use client'
/**
* Resource list — fetch + table + error, the shared shape of every list module.
*
* Four product modules (Models, Applications, Stores, Chat) are a header over a
* fetched table; this captures that once. Providers is richer (edit surface) and
* builds on DataTable directly rather than through here.
*/
import { useEffect, useState } from 'react'
import { Text, YStack } from '@hanzo/gui'
import { ApiError } from '~/lib/api'
import { PageHeader } from './PageHeader'
import { DataTable, type Column } from './DataTable'
export function ResourceList<T>({
title,
subtitle,
columns,
rowKey,
load,
}: {
title: string
subtitle: string
columns: Column<T>[]
rowKey: (row: T) => string
load: () => Promise<T[]>
}) {
const [rows, setRows] = useState<T[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let live = true
setLoading(true)
load()
.then((data) => {
if (live) {
setRows(data)
setError(null)
}
})
.catch((e: unknown) => {
if (live) setError(e instanceof ApiError ? e.message : 'Failed to load')
})
.finally(() => {
if (live) setLoading(false)
})
return () => {
live = false
}
// load is stable per module instance.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<YStack gap="$4">
<PageHeader title={title} subtitle={subtitle} />
{error ? <Text color="$red10">{error}</Text> : null}
<DataTable columns={columns} rows={rows} loading={loading} rowKey={rowKey} />
</YStack>
)
}
+1
View File
@@ -15,3 +15,4 @@ export { ModelRouteApi } from './model-routes'
export { ApplicationApi } from './applications'
export { StoreApi } from './stores'
export { ChatApi } from './chats'
export { MessageApi } from './messages'
+9
View File
@@ -0,0 +1,9 @@
/** Message read API — `/v1/get-messages`. Ported from MessageBackend.js. */
import { get } from './client'
import { type Message } from './types'
export const MessageApi = {
/** Messages in one chat (`get-messages?owner&chat`). Ordered oldest-first. */
listForChat: (owner: string, chat: string) =>
get<Message[]>('get-messages', { owner, chat }),
}
+74 -4
View File
@@ -42,37 +42,107 @@ export type Provider = Owned & {
[key: string]: unknown
}
/** A model route (`/v1/*-model-route`). */
/**
* A model route (`/v1/*-model-route`).
*
* Keyed by `owner`/`modelName` (NOT `owner/name` — the backend uses modelName as
* the identity). Maps a user-facing model name to a primary provider + upstream
* model id, with two fallbacks, pricing overrides, and visibility flags.
*/
export type ModelRoute = Owned & {
modelName?: string
provider?: string
upstream?: string
ownedBy?: string
fallback1Provider?: string
fallback1Upstream?: string
fallback2Provider?: string
fallback2Upstream?: string
premium?: boolean
hidden?: boolean
inputPricePerMillion?: number
outputPricePerMillion?: number
enabled?: boolean
state?: string
updatedTime?: string
[key: string]: unknown
}
/** An application (`/v1/*-application`). */
/**
* An application (`/v1/*-application`).
*
* A deployable app from a template, into a k8s namespace, with a deploy lifecycle
* (`status`: "Not Deployed" -> deployed). Keyed by `owner/name`.
*/
export type Application = Owned & {
category?: string
description?: string
template?: string
namespace?: string
parameters?: string
status?: string
state?: string
isDefault?: boolean
[key: string]: unknown
}
/** A knowledge store (`/v1/*-store`). */
/**
* A knowledge store (`/v1/*-store`).
*
* The RAG/chat surface config: storage + model + embedding providers, chunking
* and search strategy, chat welcome copy, and limits. Keyed by `owner/name`.
*/
export type Store = Owned & {
title?: string
storageProvider?: string
storageSubpath?: string
imageProvider?: string
splitProvider?: string
searchProvider?: string
modelProvider?: string
embeddingProvider?: string
agentProvider?: string
textToSpeechProvider?: string
speechToTextProvider?: string
memoryLimit?: number
frequency?: number
limitMinutes?: number
knowledgeCount?: number
suggestionCount?: number
welcome?: string
welcomeTitle?: string
welcomeText?: string
prompt?: string
themeColor?: string
state?: string
isDefault?: boolean
[key: string]: unknown
}
/** A chat session (`/v1/*-chat`). */
/** A chat session (`/v1/*-chat`). Keyed by `owner/name`. */
export type Chat = Owned & {
type?: string
user?: string
store?: string
category?: string
messageCount?: number
tokenCount?: number
updatedTime?: string
[key: string]: unknown
}
/**
* A chat message (`/v1/get-messages`).
*
* `author` is "AI" for assistant turns, otherwise the user. `text` is the
* content; `reasonText` carries any reasoning trace.
*/
export type Message = Owned & {
chat?: string
author?: string
text?: string
reasonText?: string
replyTo?: string
[key: string]: unknown
}
+16 -4
View File
@@ -69,28 +69,40 @@ export const productModules: ProductModule[] = [
label: 'Models',
icon: RouteIcon,
description: 'Model routes and routing policy.',
routes: [{ path: '', component: ModelsModule }],
routes: [
{ path: '', component: ModelsModule },
{ path: ':name', component: ModelsModule },
],
},
{
id: 'applications',
label: 'Applications',
icon: Boxes,
description: 'Deployed applications.',
routes: [{ path: '', component: ApplicationsModule }],
routes: [
{ path: '', component: ApplicationsModule },
{ path: ':name', component: ApplicationsModule },
],
},
{
id: 'stores',
label: 'Stores',
icon: Database,
description: 'Knowledge stores and vectors.',
routes: [{ path: '', component: StoresModule }],
routes: [
{ path: '', component: StoresModule },
{ path: ':name', component: StoresModule },
],
},
{
id: 'chat',
label: 'Chat',
icon: MessageSquare,
description: 'Chat sessions and history.',
routes: [{ path: '', component: ChatModule }],
routes: [
{ path: '', component: ChatModule },
{ path: ':name', component: ChatModule },
],
},
]