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:
@@ -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
|
||||
|
||||
@@ -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)}`)} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)}`)} />
|
||||
}
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user