refactor: shared zod, tanstack-query auth, env module, primitives, lucide icons
@kyush/shared workspace package
- Add shared workspace with package.json + tsconfig + index/schemas/types
- Schemas cover the major input shapes (CreateUser, UpdateBackend,
CreateScriptInput as a discriminated union, AdminLoginInput, ScriptTestInput,
v1 chat completion request/response, etc.)
- Loose chat completion schemas for the conversation timeline
- Both server and client now depend on the workspace package; client/src/types
is now a thin re-export of @kyush/shared
Server route validation
- All admin/auth + admin + scripts mutating routes now use @hono/zod-validator
with the shared input schemas, eliminating dozens of manual `if (!field)` checks
- server/src/schemas/{common,v1}.ts re-export the shared schemas with .openapi()
metadata so the OpenAPI doc still describes everything
Single-source process.env
- New server/src/config/env.ts is the only file that touches process.env
- admin-auth, db-paths, time, ModelCatalogService, index, main all read from env
- Each value is parsed/normalised exactly once with sensible fallbacks
Production-ready auth (TanStack Query)
- @tanstack/solid-query is the new data layer for the session
- AuthProvider wraps QueryClientProvider, exposes session/loading/login/logout
via a useAuth() context that mirrors the previous shape
- 401 responses collapse the cached session via setQueryData; CSRF is mirrored
into the api client through a tracked createEffect
API client (ky + es-toolkit + shared types)
- client/src/api/client.ts now imports CreateUserInput, CreateScriptInput, etc.
from @kyush/shared
- compactSearchParams() uses es-toolkit's omitBy to drop undefined query params
- buildUrl() retained for OIDC redirects through window.location.href
Lazy imports + default exports
- Every lazy-loaded route file (Dashboard, Users, Backends, Analytics,
DetailLogs, Models, Scripts) now has a default export
- App.tsx + Scripts.tsx use the cleaner `lazy(() => import('./...'))` pattern
with no `.then((m) => ({ default: m.X }))` indirection
- ScriptEditor moved to client/src/components/script-editor.tsx (kebab-case)
with default export; LoginGate similarly moved to login-gate.tsx
UI primitive cleanup
- KCheckbox/KSelect/KDialog/KDropdownMenu/KPopover/KSwitch/KTabs/KTextField/
KToast/KTooltip → CheckboxPrimitive/SelectPrimitive/DialogPrimitive/...
- Select.tsx and Checkbox.tsx now use lucide-solid icons (Check, ChevronDown)
instead of '✓' / '▾' literals
- Select drops its createMemo over a tiny option list — inline derivation is
cheaper than the memo bookkeeping
ConversationTimeline refactor (the prior hacky parser)
- Drives entirely off the new LooseChatCompletionRequestSchema /
LooseChatCompletionResponseSchema
- All the inlined `(message as Record<string, unknown>).reasoning_content`
type-narrowing chains are gone; helpers extract messages cleanly
- createMemo wrappers replaced by inline reactive derivations
ts-reset + Tailwind v4 + es-toolkit + Node 24 cleanups
- Add @total-typescript/ts-reset to client and server (reset.d.ts in both)
- @tailwindcss/vite plugin wired into client/vite.config.ts; styles.css now
imports tailwindcss and bridges existing tokens via @theme inline
- es-toolkit added to client + server
- Replace `path.dirname(fileURLToPath(import.meta.url))` with the native
`import.meta.dirname` (Node 20.11+) across server config files + tests
Type-guard / no-`as` cleanup pass
- isPragmaColumnRow type guard replaces `as Array<{ name: string }>` casts in
database.ts and request-logs-db.ts
- Lazy DB singletons rewrite: `let db: Database | undefined` + `db ??= openDb()`
removes the `undefined as unknown as Database` reset hack
- Scripts.tsx splits the save payload through buildCreatePayload /
buildUpdatePayload helpers that use a switch on script_type so the
discriminated union picks the correct variant — no payload casts
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
17c4286b6a
commit
435ffdce42
53 changed files with 2343 additions and 1686 deletions
|
|
@ -16,24 +16,31 @@
|
|||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "^0.13.11",
|
||||
"@kyush/shared": "workspace:*",
|
||||
"@solidjs/router": "^0.15.4",
|
||||
"@tanstack/solid-query": "^5.62.7",
|
||||
"d3": "^7.9.0",
|
||||
"es-toolkit": "^1.32.0",
|
||||
"ky": "^1.7.5",
|
||||
"lucide-solid": "^1.1.0",
|
||||
"solid-js": "^1.9.11",
|
||||
"solid-monaco": "^0.3.0"
|
||||
"solid-monaco": "^0.3.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-a11y": "^10.3.3",
|
||||
"@storybook/addon-docs": "^10.3.3",
|
||||
"@storybook/addon-links": "^10.3.3",
|
||||
"@storybook/addon-vitest": "^10.3.3",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@total-typescript/ts-reset": "^0.6.1",
|
||||
"@types/node": "^25.3.3",
|
||||
"@vitest/browser": "^4.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.1",
|
||||
"playwright": "^1.58.2",
|
||||
"storybook": "^10.3.3",
|
||||
"storybook-solidjs-vite": "^10.0.11",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-solid": "^2.11.10",
|
||||
|
|
|
|||
|
|
@ -1,45 +1,25 @@
|
|||
import { Router, Route } from '@solidjs/router';
|
||||
import { Route, Router } from '@solidjs/router';
|
||||
import { lazy, Show, Suspense } from 'solid-js';
|
||||
|
||||
import { AuthProvider, useAuth } from './auth';
|
||||
import { LoginGate } from './components/LoginGate';
|
||||
import LoginGate from './components/login-gate';
|
||||
import { Panel } from './ui';
|
||||
|
||||
const Dashboard = lazy(() =>
|
||||
import('./routes/Dashboard').then((module) => ({
|
||||
default: module.Dashboard,
|
||||
})),
|
||||
);
|
||||
const Users = lazy(() =>
|
||||
import('./routes/Users').then((module) => ({ default: module.Users })),
|
||||
);
|
||||
const Backends = lazy(() =>
|
||||
import('./routes/Backends').then((module) => ({ default: module.Backends })),
|
||||
);
|
||||
const Analytics = lazy(() =>
|
||||
import('./routes/Analytics').then((module) => ({
|
||||
default: module.Analytics,
|
||||
})),
|
||||
);
|
||||
const DetailLogs = lazy(() =>
|
||||
import('./routes/DetailLogs').then((module) => ({
|
||||
default: module.DetailLogs,
|
||||
})),
|
||||
);
|
||||
const Models = lazy(() =>
|
||||
import('./routes/Models').then((module) => ({ default: module.Models })),
|
||||
);
|
||||
const Scripts = lazy(() =>
|
||||
import('./routes/Scripts').then((module) => ({ default: module.Scripts })),
|
||||
);
|
||||
const Dashboard = lazy(() => import('./routes/Dashboard'));
|
||||
const Users = lazy(() => import('./routes/Users'));
|
||||
const Backends = lazy(() => import('./routes/Backends'));
|
||||
const Analytics = lazy(() => import('./routes/Analytics'));
|
||||
const DetailLogs = lazy(() => import('./routes/DetailLogs'));
|
||||
const Models = lazy(() => import('./routes/Models'));
|
||||
const Scripts = lazy(() => import('./routes/Scripts'));
|
||||
|
||||
function RouteLoadingFallback() {
|
||||
function FullScreenPanel(props: { title: string; description: string }) {
|
||||
return (
|
||||
<div class="auth-screen">
|
||||
<Panel
|
||||
class="auth-screen__panel"
|
||||
description="Preparing the selected dashboard view."
|
||||
title="Loading Admin Page"
|
||||
description={props.description}
|
||||
title={props.title}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -51,18 +31,22 @@ function AuthenticatedApp() {
|
|||
return (
|
||||
<Show
|
||||
fallback={
|
||||
<div class="auth-screen">
|
||||
<Panel
|
||||
class="auth-screen__panel"
|
||||
description="Restoring the current administrator session."
|
||||
title="Loading Admin Session"
|
||||
/>
|
||||
</div>
|
||||
<FullScreenPanel
|
||||
description="Restoring the current administrator session."
|
||||
title="Loading Admin Session"
|
||||
/>
|
||||
}
|
||||
when={!auth.loading()}
|
||||
>
|
||||
<Show fallback={<LoginGate />} when={auth.session()?.authenticated}>
|
||||
<Suspense fallback={<RouteLoadingFallback />}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<FullScreenPanel
|
||||
description="Preparing the selected dashboard view."
|
||||
title="Loading Admin Page"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Router base="/dashboard">
|
||||
<Route component={Dashboard} path="/" />
|
||||
<Route component={Users} path="/users" />
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { omitBy } from 'es-toolkit';
|
||||
import ky, {
|
||||
HTTPError,
|
||||
type KyInstance,
|
||||
|
|
@ -6,27 +7,34 @@ import ky, {
|
|||
} from 'ky';
|
||||
|
||||
import type {
|
||||
User,
|
||||
AdminApiTokenSummary,
|
||||
AdminSessionResponse,
|
||||
AnalyticsBackendQualityPoint,
|
||||
AnalyticsBoxPlotPoint,
|
||||
AnalyticsDailyTotalsPoint,
|
||||
AnalyticsHistogramBin,
|
||||
AnalyticsModelTrendPoint,
|
||||
Backend,
|
||||
BackendMetrics,
|
||||
BackendModelsResponse,
|
||||
CreateBackendInput,
|
||||
CreateModelRewriteInput,
|
||||
CreatePermissionInput,
|
||||
CreateScriptInput,
|
||||
CreateUserInput,
|
||||
DashboardSummaryResponse,
|
||||
ModelCacheOverview,
|
||||
ModelRewriteRule,
|
||||
Permission,
|
||||
RequestLogPage,
|
||||
UpdateBackendInput,
|
||||
UpdateModelRewriteInput,
|
||||
UpdateScriptInput,
|
||||
UpdateUserInput,
|
||||
UsageStats,
|
||||
BackendMetrics,
|
||||
AnalyticsDailyTotalsPoint,
|
||||
AnalyticsBackendQualityPoint,
|
||||
AnalyticsModelTrendPoint,
|
||||
AnalyticsHistogramBin,
|
||||
AnalyticsBoxPlotPoint,
|
||||
DashboardSummaryResponse,
|
||||
User,
|
||||
UserScript,
|
||||
CreateScriptData,
|
||||
UpdateScriptData,
|
||||
AdminApiTokenSummary,
|
||||
AdminSessionResponse,
|
||||
} from '../types';
|
||||
} from '@kyush/shared';
|
||||
|
||||
/**
|
||||
* Base URL prepended by ky to every request.
|
||||
|
|
@ -68,12 +76,8 @@ const httpClient: KyInstance = ky.extend({
|
|||
hooks: {
|
||||
beforeRequest: [
|
||||
(request) => {
|
||||
if (!UNSAFE_METHODS.has(request.method.toUpperCase())) {
|
||||
return;
|
||||
}
|
||||
if (!csrfToken) {
|
||||
return;
|
||||
}
|
||||
if (!UNSAFE_METHODS.has(request.method.toUpperCase())) return;
|
||||
if (!csrfToken) return;
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname.startsWith('/admin')) {
|
||||
request.headers.set('X-CSRF-Token', csrfToken);
|
||||
|
|
@ -84,15 +88,31 @@ const httpClient: KyInstance = ky.extend({
|
|||
});
|
||||
|
||||
type SearchParamsInit = Exclude<KyOptions['searchParams'], undefined>;
|
||||
type Primitive = string | number | boolean | undefined | null;
|
||||
|
||||
/**
|
||||
* Drop `undefined`/`null` keys so callers can pass `{ userId: maybeUndefined }`
|
||||
* without polluting the query string with empty values. Returns `undefined`
|
||||
* when nothing is left so ky skips the search parameter step entirely.
|
||||
*/
|
||||
function compactSearchParams(
|
||||
params: Record<string, Primitive>,
|
||||
): SearchParamsInit | undefined {
|
||||
const cleaned = omitBy(
|
||||
params,
|
||||
(value) => value === undefined || value === null,
|
||||
);
|
||||
const entries = Object.entries(cleaned);
|
||||
if (entries.length === 0) return undefined;
|
||||
return Object.fromEntries(entries.map(([k, v]) => [k, String(v)]));
|
||||
}
|
||||
|
||||
async function toApiError(error: HTTPError): Promise<ApiError> {
|
||||
const { response, request } = error;
|
||||
let message = `HTTP ${response.status}`;
|
||||
try {
|
||||
const payload = (await response.clone().json()) as { error?: string };
|
||||
if (payload.error) {
|
||||
message = payload.error;
|
||||
}
|
||||
if (payload.error) message = payload.error;
|
||||
} catch {
|
||||
// body wasn't JSON; keep the default message
|
||||
}
|
||||
|
|
@ -113,14 +133,10 @@ async function toApiError(error: HTTPError): Promise<ApiError> {
|
|||
async function unwrap<T>(promise: ResponsePromise): Promise<T> {
|
||||
try {
|
||||
const response = await promise;
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
}
|
||||
if (response.status === 204) return {} as T;
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
if (error instanceof HTTPError) {
|
||||
throw await toApiError(error);
|
||||
}
|
||||
if (error instanceof HTTPError) throw await toApiError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -170,81 +186,85 @@ function buildUrl(path: string, searchParams?: Record<string, string>): string {
|
|||
return url.toString();
|
||||
}
|
||||
|
||||
interface AnalyticsRequestParams {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
month?: string;
|
||||
date?: string;
|
||||
q?: string;
|
||||
userId?: number;
|
||||
backendId?: number;
|
||||
endpoint?: string;
|
||||
detailLogged?: boolean;
|
||||
}
|
||||
|
||||
interface ModelTrendsParams {
|
||||
backendId?: number;
|
||||
days?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface HistogramParams {
|
||||
backendId?: number;
|
||||
days?: number;
|
||||
bins?: number;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
auth: {
|
||||
getSession: (): Promise<AdminSessionResponse> =>
|
||||
getJson<AdminSessionResponse>('admin/auth/session'),
|
||||
login: (
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<AdminSessionResponse> =>
|
||||
getSession: () => getJson<AdminSessionResponse>('admin/auth/session'),
|
||||
login: (username: string, password: string) =>
|
||||
postJson<AdminSessionResponse>('admin/auth/login', {
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
logout: (): Promise<void> => postJson<void>('admin/auth/logout'),
|
||||
logout: () => postJson<void>('admin/auth/logout'),
|
||||
beginOidc: (next: string = window.location.pathname) => {
|
||||
window.location.href = buildUrl('admin/auth/oidc/start', { next });
|
||||
},
|
||||
getTokens: (): Promise<AdminApiTokenSummary[]> =>
|
||||
getJson<AdminApiTokenSummary[]>('admin/auth/tokens'),
|
||||
createToken: (
|
||||
name: string,
|
||||
expiresInDays?: number,
|
||||
): Promise<{ token: string; record: AdminApiTokenSummary }> =>
|
||||
postJson('admin/auth/tokens', { name, expiresInDays }),
|
||||
deleteToken: (id: number): Promise<void> =>
|
||||
deleteJson<void>(`admin/auth/tokens/${id}`),
|
||||
getTokens: () => getJson<AdminApiTokenSummary[]>('admin/auth/tokens'),
|
||||
createToken: (name: string, expiresInDays?: number) =>
|
||||
postJson<{ token: string; record: AdminApiTokenSummary }>(
|
||||
'admin/auth/tokens',
|
||||
{ name, expiresInDays },
|
||||
),
|
||||
deleteToken: (id: number) => deleteJson<void>(`admin/auth/tokens/${id}`),
|
||||
},
|
||||
|
||||
users: {
|
||||
getAll: (): Promise<User[]> => getJson<User[]>('admin/users'),
|
||||
getById: (id: number): Promise<User> => getJson<User>(`admin/users/${id}`),
|
||||
create: (data: {
|
||||
name: string;
|
||||
email?: string;
|
||||
api_key?: string;
|
||||
detail_logging?: boolean;
|
||||
}): Promise<User> => postJson<User>('admin/users', data),
|
||||
update: (id: number, data: Partial<User>): Promise<User> =>
|
||||
getAll: () => getJson<User[]>('admin/users'),
|
||||
getById: (id: number) => getJson<User>(`admin/users/${id}`),
|
||||
create: (data: CreateUserInput) => postJson<User>('admin/users', data),
|
||||
update: (id: number, data: UpdateUserInput) =>
|
||||
putJson<User>(`admin/users/${id}`, data),
|
||||
delete: (id: number): Promise<void> =>
|
||||
deleteJson<void>(`admin/users/${id}`),
|
||||
regenerateApiKey: (id: number): Promise<User> =>
|
||||
delete: (id: number) => deleteJson<void>(`admin/users/${id}`),
|
||||
regenerateApiKey: (id: number) =>
|
||||
postJson<User>(`admin/users/${id}/regenerate-api-key`),
|
||||
},
|
||||
|
||||
backends: {
|
||||
getAll: (): Promise<Backend[]> => getJson<Backend[]>('admin/backends'),
|
||||
getById: (id: number): Promise<Backend> =>
|
||||
getJson<Backend>(`admin/backends/${id}`),
|
||||
getModels: (id: number): Promise<BackendModelsResponse> =>
|
||||
getAll: () => getJson<Backend[]>('admin/backends'),
|
||||
getById: (id: number) => getJson<Backend>(`admin/backends/${id}`),
|
||||
getModels: (id: number) =>
|
||||
getJson<BackendModelsResponse>(`admin/backends/${id}/models`),
|
||||
refreshModels: (id: number): Promise<BackendModelsResponse> =>
|
||||
refreshModels: (id: number) =>
|
||||
postJson<BackendModelsResponse>(`admin/backends/${id}/models/refresh`),
|
||||
create: (data: {
|
||||
name: string;
|
||||
base_url: string;
|
||||
api_key?: string;
|
||||
detail_logging?: boolean;
|
||||
}): Promise<Backend> => postJson<Backend>('admin/backends', data),
|
||||
update: (id: number, data: Partial<Backend>): Promise<Backend> =>
|
||||
create: (data: CreateBackendInput) =>
|
||||
postJson<Backend>('admin/backends', data),
|
||||
update: (id: number, data: UpdateBackendInput) =>
|
||||
putJson<Backend>(`admin/backends/${id}`, data),
|
||||
delete: (id: number): Promise<void> =>
|
||||
deleteJson<void>(`admin/backends/${id}`),
|
||||
delete: (id: number) => deleteJson<void>(`admin/backends/${id}`),
|
||||
},
|
||||
|
||||
permissions: {
|
||||
getAll: (): Promise<Permission[]> =>
|
||||
getJson<Permission[]>('admin/permissions'),
|
||||
getByUser: (userId: number): Promise<Permission[]> =>
|
||||
getAll: () => getJson<Permission[]>('admin/permissions'),
|
||||
getByUser: (userId: number) =>
|
||||
getJson<Permission[]>(`admin/permissions/user/${userId}`),
|
||||
getByBackend: (backendId: number): Promise<Permission[]> =>
|
||||
getByBackend: (backendId: number) =>
|
||||
getJson<Permission[]>(`admin/permissions/backend/${backendId}`),
|
||||
create: (data: {
|
||||
user_id: number;
|
||||
backend_id: number;
|
||||
}): Promise<Permission> => postJson<Permission>('admin/permissions', data),
|
||||
delete: (userId: number, backendId: number): Promise<void> =>
|
||||
create: (data: CreatePermissionInput) =>
|
||||
postJson<Permission>('admin/permissions', data),
|
||||
delete: (userId: number, backendId: number) =>
|
||||
deleteJson<void>('admin/permissions', {
|
||||
user_id: String(userId),
|
||||
backend_id: String(backendId),
|
||||
|
|
@ -252,49 +272,35 @@ export const api = {
|
|||
},
|
||||
|
||||
modelRewrites: {
|
||||
getAll: (): Promise<ModelRewriteRule[]> =>
|
||||
getJson<ModelRewriteRule[]>('admin/model-rewrites'),
|
||||
create: (data: {
|
||||
source_model: string;
|
||||
target_model: string;
|
||||
is_active?: boolean;
|
||||
force?: boolean;
|
||||
note?: string;
|
||||
}): Promise<ModelRewriteRule> =>
|
||||
getAll: () => getJson<ModelRewriteRule[]>('admin/model-rewrites'),
|
||||
create: (data: CreateModelRewriteInput) =>
|
||||
postJson<ModelRewriteRule>('admin/model-rewrites', data),
|
||||
update: (
|
||||
id: number,
|
||||
data: Partial<ModelRewriteRule>,
|
||||
): Promise<ModelRewriteRule> =>
|
||||
update: (id: number, data: UpdateModelRewriteInput) =>
|
||||
putJson<ModelRewriteRule>(`admin/model-rewrites/${id}`, data),
|
||||
delete: (id: number): Promise<void> =>
|
||||
deleteJson<void>(`admin/model-rewrites/${id}`),
|
||||
delete: (id: number) => deleteJson<void>(`admin/model-rewrites/${id}`),
|
||||
},
|
||||
|
||||
modelCache: {
|
||||
getOverview: (): Promise<ModelCacheOverview> =>
|
||||
getJson<ModelCacheOverview>('admin/models/cache'),
|
||||
getOverview: () => getJson<ModelCacheOverview>('admin/models/cache'),
|
||||
},
|
||||
|
||||
scripts: {
|
||||
getAll: (): Promise<UserScript[]> => getJson<UserScript[]>('admin/scripts'),
|
||||
getById: (id: number): Promise<UserScript> =>
|
||||
getJson<UserScript>(`admin/scripts/${id}`),
|
||||
create: (data: CreateScriptData): Promise<UserScript> =>
|
||||
getAll: () => getJson<UserScript[]>('admin/scripts'),
|
||||
getById: (id: number) => getJson<UserScript>(`admin/scripts/${id}`),
|
||||
create: (data: CreateScriptInput) =>
|
||||
postJson<UserScript>('admin/scripts', data),
|
||||
update: (id: number, data: UpdateScriptData): Promise<UserScript> =>
|
||||
update: (id: number, data: UpdateScriptInput) =>
|
||||
putJson<UserScript>(`admin/scripts/${id}`, data),
|
||||
delete: (id: number): Promise<void> =>
|
||||
deleteJson<void>(`admin/scripts/${id}`),
|
||||
activate: (id: number): Promise<UserScript> =>
|
||||
delete: (id: number) => deleteJson<void>(`admin/scripts/${id}`),
|
||||
activate: (id: number) =>
|
||||
postJson<UserScript>(`admin/scripts/${id}/activate`),
|
||||
deactivate: (id: number): Promise<UserScript> =>
|
||||
deactivate: (id: number) =>
|
||||
postJson<UserScript>(`admin/scripts/${id}/deactivate`),
|
||||
test: (
|
||||
id: number,
|
||||
context: {
|
||||
user?: User;
|
||||
backend?: Backend;
|
||||
user?: { id: number; name: string; email?: string };
|
||||
backend?: { id: number; name: string; base_url: string };
|
||||
request: {
|
||||
method: string;
|
||||
path: string;
|
||||
|
|
@ -303,120 +309,81 @@ export const api = {
|
|||
isStream: boolean;
|
||||
};
|
||||
},
|
||||
): Promise<{ success: boolean; error?: string; executionTime?: number }> =>
|
||||
postJson(`admin/scripts/${id}/test`, context),
|
||||
) =>
|
||||
postJson<{ success: boolean; error?: string; executionTime?: number }>(
|
||||
`admin/scripts/${id}/test`,
|
||||
context,
|
||||
),
|
||||
},
|
||||
|
||||
dashboard: {
|
||||
getSummary: (days: number = 30): Promise<DashboardSummaryResponse> =>
|
||||
getSummary: (days: number = 30) =>
|
||||
getJson<DashboardSummaryResponse>('admin/dashboard/summary', { days }),
|
||||
},
|
||||
|
||||
analytics: {
|
||||
getUsage: (
|
||||
userId?: number,
|
||||
backendId?: number,
|
||||
days: number = 30,
|
||||
): Promise<UsageStats[]> => {
|
||||
const searchParams: Record<string, number> = { days };
|
||||
if (userId) searchParams.userId = userId;
|
||||
if (backendId) searchParams.backendId = backendId;
|
||||
return getJson<UsageStats[]>('admin/analytics/usage', searchParams);
|
||||
},
|
||||
getRequests: (
|
||||
params: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
month?: string;
|
||||
date?: string;
|
||||
q?: string;
|
||||
userId?: number;
|
||||
backendId?: number;
|
||||
endpoint?: string;
|
||||
detailLogged?: boolean;
|
||||
} = {},
|
||||
): Promise<RequestLogPage> => {
|
||||
const searchParams: Record<string, string | number> = {
|
||||
limit: params.limit ?? 100,
|
||||
offset: params.offset ?? 0,
|
||||
};
|
||||
if (params.month) searchParams.month = params.month;
|
||||
if (params.date) searchParams.date = params.date;
|
||||
if (params.q) searchParams.q = params.q;
|
||||
if (params.userId) searchParams.userId = params.userId;
|
||||
if (params.backendId) searchParams.backendId = params.backendId;
|
||||
if (params.endpoint) searchParams.endpoint = params.endpoint;
|
||||
if (params.detailLogged !== undefined) {
|
||||
searchParams.detailLogged = params.detailLogged ? '1' : '0';
|
||||
}
|
||||
return getJson<RequestLogPage>('admin/analytics/requests', searchParams);
|
||||
},
|
||||
getMetrics: (
|
||||
backendId?: number,
|
||||
days: number = 30,
|
||||
): Promise<BackendMetrics[]> => {
|
||||
const searchParams: Record<string, number> = { days };
|
||||
if (backendId) searchParams.backendId = backendId;
|
||||
return getJson<BackendMetrics[]>('admin/analytics/metrics', searchParams);
|
||||
},
|
||||
getDailyTotals: (
|
||||
backendId?: number,
|
||||
days: number = 30,
|
||||
): Promise<AnalyticsDailyTotalsPoint[]> => {
|
||||
const searchParams: Record<string, number> = { days };
|
||||
if (backendId) searchParams.backendId = backendId;
|
||||
return getJson<AnalyticsDailyTotalsPoint[]>(
|
||||
getUsage: (userId?: number, backendId?: number, days: number = 30) =>
|
||||
getJson<UsageStats[]>(
|
||||
'admin/analytics/usage',
|
||||
compactSearchParams({ userId, backendId, days }),
|
||||
),
|
||||
getRequests: (params: AnalyticsRequestParams = {}) =>
|
||||
getJson<RequestLogPage>(
|
||||
'admin/analytics/requests',
|
||||
compactSearchParams({
|
||||
limit: params.limit ?? 100,
|
||||
offset: params.offset ?? 0,
|
||||
month: params.month,
|
||||
date: params.date,
|
||||
q: params.q,
|
||||
userId: params.userId,
|
||||
backendId: params.backendId,
|
||||
endpoint: params.endpoint,
|
||||
detailLogged:
|
||||
params.detailLogged === undefined
|
||||
? undefined
|
||||
: params.detailLogged
|
||||
? '1'
|
||||
: '0',
|
||||
}),
|
||||
),
|
||||
getMetrics: (backendId?: number, days: number = 30) =>
|
||||
getJson<BackendMetrics[]>(
|
||||
'admin/analytics/metrics',
|
||||
compactSearchParams({ backendId, days }),
|
||||
),
|
||||
getDailyTotals: (backendId?: number, days: number = 30) =>
|
||||
getJson<AnalyticsDailyTotalsPoint[]>(
|
||||
'admin/analytics/daily-totals',
|
||||
searchParams,
|
||||
);
|
||||
},
|
||||
getBackendQuality: (
|
||||
backendId?: number,
|
||||
days: number = 30,
|
||||
): Promise<AnalyticsBackendQualityPoint[]> => {
|
||||
const searchParams: Record<string, number> = { days };
|
||||
if (backendId) searchParams.backendId = backendId;
|
||||
return getJson<AnalyticsBackendQualityPoint[]>(
|
||||
compactSearchParams({ backendId, days }),
|
||||
),
|
||||
getBackendQuality: (backendId?: number, days: number = 30) =>
|
||||
getJson<AnalyticsBackendQualityPoint[]>(
|
||||
'admin/analytics/backend-quality',
|
||||
searchParams,
|
||||
);
|
||||
},
|
||||
getModelTrends: (
|
||||
params: { backendId?: number; days?: number; limit?: number } = {},
|
||||
): Promise<AnalyticsModelTrendPoint[]> => {
|
||||
const searchParams: Record<string, number> = {
|
||||
days: params.days ?? 30,
|
||||
limit: params.limit ?? 8,
|
||||
};
|
||||
if (params.backendId) searchParams.backendId = params.backendId;
|
||||
return getJson<AnalyticsModelTrendPoint[]>(
|
||||
compactSearchParams({ backendId, days }),
|
||||
),
|
||||
getModelTrends: (params: ModelTrendsParams = {}) =>
|
||||
getJson<AnalyticsModelTrendPoint[]>(
|
||||
'admin/analytics/model-trends',
|
||||
searchParams,
|
||||
);
|
||||
},
|
||||
getResponseLengthHistogram: (
|
||||
params: { backendId?: number; days?: number; bins?: number } = {},
|
||||
): Promise<AnalyticsHistogramBin[]> => {
|
||||
const searchParams: Record<string, number> = {
|
||||
days: params.days ?? 30,
|
||||
bins: params.bins ?? 20,
|
||||
};
|
||||
if (params.backendId) searchParams.backendId = params.backendId;
|
||||
return getJson<AnalyticsHistogramBin[]>(
|
||||
compactSearchParams({
|
||||
backendId: params.backendId,
|
||||
days: params.days ?? 30,
|
||||
limit: params.limit ?? 8,
|
||||
}),
|
||||
),
|
||||
getResponseLengthHistogram: (params: HistogramParams = {}) =>
|
||||
getJson<AnalyticsHistogramBin[]>(
|
||||
'admin/analytics/response-length-histogram',
|
||||
searchParams,
|
||||
);
|
||||
},
|
||||
getResponseLengthBoxPlot: (
|
||||
backendId?: number,
|
||||
days: number = 30,
|
||||
): Promise<AnalyticsBoxPlotPoint[]> => {
|
||||
const searchParams: Record<string, number> = { days };
|
||||
if (backendId) searchParams.backendId = backendId;
|
||||
return getJson<AnalyticsBoxPlotPoint[]>(
|
||||
compactSearchParams({
|
||||
backendId: params.backendId,
|
||||
days: params.days ?? 30,
|
||||
bins: params.bins ?? 20,
|
||||
}),
|
||||
),
|
||||
getResponseLengthBoxPlot: (backendId?: number, days: number = 30) =>
|
||||
getJson<AnalyticsBoxPlotPoint[]>(
|
||||
'admin/analytics/response-length-box-plot',
|
||||
searchParams,
|
||||
);
|
||||
},
|
||||
compactSearchParams({ backendId, days }),
|
||||
),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,20 +1,82 @@
|
|||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/solid-query';
|
||||
import {
|
||||
createContext,
|
||||
createSignal,
|
||||
onMount,
|
||||
createEffect,
|
||||
onCleanup,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type JSX,
|
||||
type ParentComponent,
|
||||
} from 'solid-js';
|
||||
|
||||
import { api, setAdminCsrfToken, setUnauthorizedHandler } from './api/client';
|
||||
import {
|
||||
ApiError,
|
||||
api,
|
||||
setAdminCsrfToken,
|
||||
setUnauthorizedHandler,
|
||||
} from './api/client';
|
||||
|
||||
import type { AdminSessionResponse } from './types';
|
||||
import type { AdminSessionResponse } from '@kyush/shared';
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* QueryClient
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function makeQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Treat 401 specially via a global onError pipeline; never retry auth
|
||||
// failures, since the unauthorizedHandler will reset the session.
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof ApiError && error.status === 401) return false;
|
||||
if (
|
||||
error instanceof ApiError &&
|
||||
error.status >= 400 &&
|
||||
error.status < 500
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return failureCount < 2;
|
||||
},
|
||||
staleTime: 30_000,
|
||||
gcTime: 5 * 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
throwOnError: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* Query keys
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const authKeys = {
|
||||
session: ['auth', 'session'] as const,
|
||||
} as const;
|
||||
|
||||
const UNAUTHENTICATED_FALLBACK: AdminSessionResponse = {
|
||||
authenticated: false,
|
||||
authMode: 'both',
|
||||
csrfToken: null,
|
||||
principal: null,
|
||||
};
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* Auth context (thin wrapper around the session query + mutations)
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface AuthContextValue {
|
||||
session: Accessor<AdminSessionResponse | null>;
|
||||
loading: Accessor<boolean>;
|
||||
session: () => AdminSessionResponse | null;
|
||||
loading: () => boolean;
|
||||
refreshSession: () => Promise<AdminSessionResponse>;
|
||||
login: (username: string, password: string) => Promise<AdminSessionResponse>;
|
||||
logout: () => Promise<void>;
|
||||
|
|
@ -22,74 +84,112 @@ interface AuthContextValue {
|
|||
|
||||
const AuthContext = createContext<AuthContextValue>();
|
||||
|
||||
function unauthenticatedState(
|
||||
previous: AdminSessionResponse | null,
|
||||
): AdminSessionResponse {
|
||||
return {
|
||||
authenticated: false,
|
||||
authMode: previous?.authMode ?? 'both',
|
||||
csrfToken: null,
|
||||
principal: null,
|
||||
};
|
||||
function useSessionQuery() {
|
||||
return useQuery(() => ({
|
||||
queryKey: authKeys.session,
|
||||
queryFn: () => api.auth.getSession(),
|
||||
// The session is already authoritative for the dashboard's lifecycle,
|
||||
// so cache it forever and let mutations invalidate it explicitly.
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
gcTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
}));
|
||||
}
|
||||
|
||||
export const AuthProvider: ParentComponent<{ children: JSX.Element }> = (
|
||||
props,
|
||||
) => {
|
||||
const [session, setSession] = createSignal<AdminSessionResponse | null>(null);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
function AuthContextProvider(props: {
|
||||
children: import('solid-js').JSX.Element;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const sessionQuery = useSessionQuery();
|
||||
|
||||
const refreshSession = async () => {
|
||||
const nextSession = await api.auth.getSession();
|
||||
setSession(nextSession);
|
||||
setAdminCsrfToken(nextSession.csrfToken);
|
||||
setLoading(false);
|
||||
return nextSession;
|
||||
};
|
||||
// Mirror the CSRF token into the api client whenever the session updates.
|
||||
createEffect(() => {
|
||||
const data = sessionQuery.data;
|
||||
setAdminCsrfToken(data?.csrfToken ?? null);
|
||||
});
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const nextSession = await api.auth.login(username, password);
|
||||
setSession(nextSession);
|
||||
setAdminCsrfToken(nextSession.csrfToken);
|
||||
return nextSession;
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
await api.auth.logout();
|
||||
setSession((previous) => unauthenticatedState(previous));
|
||||
setAdminCsrfToken(null);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
// Wire the api client's 401 handler — when an unauthorized response surfaces,
|
||||
// immediately collapse the cached session to the unauthenticated fallback.
|
||||
createEffect(() => {
|
||||
setUnauthorizedHandler(() => {
|
||||
setSession((previous) => unauthenticatedState(previous));
|
||||
queryClient.setQueryData<AdminSessionResponse>(
|
||||
authKeys.session,
|
||||
(previous) => ({
|
||||
...UNAUTHENTICATED_FALLBACK,
|
||||
authMode: previous?.authMode ?? UNAUTHENTICATED_FALLBACK.authMode,
|
||||
}),
|
||||
);
|
||||
setAdminCsrfToken(null);
|
||||
});
|
||||
|
||||
void refreshSession().catch(() => {
|
||||
setSession({
|
||||
authenticated: false,
|
||||
authMode: 'both',
|
||||
csrfToken: null,
|
||||
principal: null,
|
||||
});
|
||||
setLoading(false);
|
||||
});
|
||||
onCleanup(() => setUnauthorizedHandler(null));
|
||||
});
|
||||
|
||||
const loginMutation = useMutation(() => ({
|
||||
mutationFn: ({
|
||||
username,
|
||||
password,
|
||||
}: {
|
||||
username: string;
|
||||
password: string;
|
||||
}) => api.auth.login(username, password),
|
||||
onSuccess: (next) => {
|
||||
queryClient.setQueryData(authKeys.session, next);
|
||||
},
|
||||
}));
|
||||
|
||||
const logoutMutation = useMutation(() => ({
|
||||
mutationFn: () => api.auth.logout(),
|
||||
onSuccess: () => {
|
||||
queryClient.setQueryData<AdminSessionResponse>(
|
||||
authKeys.session,
|
||||
(previous) => ({
|
||||
...UNAUTHENTICATED_FALLBACK,
|
||||
authMode: previous?.authMode ?? UNAUTHENTICATED_FALLBACK.authMode,
|
||||
}),
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const value: AuthContextValue = {
|
||||
session: () => sessionQuery.data ?? null,
|
||||
// Only treat the very first fetch as "loading" — once we have any data
|
||||
// (or an error), the gate should resolve to login or to the dashboard.
|
||||
loading: () =>
|
||||
sessionQuery.isPending && sessionQuery.fetchStatus !== 'idle',
|
||||
refreshSession: async () => {
|
||||
const next = await queryClient.fetchQuery({
|
||||
queryKey: authKeys.session,
|
||||
queryFn: () => api.auth.getSession(),
|
||||
staleTime: 0,
|
||||
});
|
||||
return next;
|
||||
},
|
||||
login: (username, password) =>
|
||||
loginMutation.mutateAsync({ username, password }),
|
||||
logout: () => logoutMutation.mutateAsync(),
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{ session, loading, refreshSession, login, logout }}
|
||||
>
|
||||
{props.children}
|
||||
</AuthContext.Provider>
|
||||
<AuthContext.Provider value={value}>{props.children}</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const AuthProvider: ParentComponent = (props) => {
|
||||
const queryClient = makeQueryClient();
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthContextProvider>{props.children}</AuthContextProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useAuth() {
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('Auth context is not available');
|
||||
throw new Error('useAuth must be used inside <AuthProvider>');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,21 @@
|
|||
import { createSignal, Show, type Component } from 'solid-js';
|
||||
|
||||
import { Alert, Button, Panel, TextField } from '../ui';
|
||||
import { ApiError, api } from '../api/client';
|
||||
import { useAuth } from '../auth';
|
||||
import { api, ApiError } from '../api/client';
|
||||
import { Alert, Button, Panel, TextField } from '../ui';
|
||||
|
||||
export const LoginGate: Component = () => {
|
||||
const LoginGate: Component = () => {
|
||||
const auth = useAuth();
|
||||
const [username, setUsername] = createSignal('');
|
||||
const [password, setPassword] = createSignal('');
|
||||
const [submitting, setSubmitting] = createSignal(false);
|
||||
const [errorMessage, setErrorMessage] = createSignal<string | null>(null);
|
||||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
const authMode = () => auth.session()?.authMode ?? 'both';
|
||||
const envEnabled = () => authMode() === 'env' || authMode() === 'both';
|
||||
const oidcEnabled = () => authMode() === 'oidc' || authMode() === 'both';
|
||||
|
||||
const handleSubmit = async (event: SubmitEvent) => {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
|
|
@ -27,10 +31,6 @@ export const LoginGate: Component = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const authMode = () => auth.session()?.authMode ?? 'both';
|
||||
const envEnabled = () => authMode() === 'env' || authMode() === 'both';
|
||||
const oidcEnabled = () => authMode() === 'oidc' || authMode() === 'both';
|
||||
|
||||
return (
|
||||
<div class="auth-screen">
|
||||
<Panel
|
||||
|
|
@ -84,3 +84,5 @@ export const LoginGate: Component = () => {
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginGate;
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import { Dynamic } from 'solid-js/web';
|
||||
import { createSignal, lazy, onCleanup, onMount, Suspense } from 'solid-js';
|
||||
import { Dynamic } from 'solid-js/web';
|
||||
|
||||
const THEME_STORAGE_KEY = 'kyush-theme';
|
||||
const MonacoEditor = lazy(() =>
|
||||
import('solid-monaco').then((module) => ({ default: module.MonacoEditor })),
|
||||
);
|
||||
|
||||
const MonacoEditor = lazy(async () => {
|
||||
const module = await import('solid-monaco');
|
||||
return { default: module.MonacoEditor };
|
||||
});
|
||||
|
||||
interface ScriptEditorProps {
|
||||
value: string;
|
||||
|
|
@ -13,8 +15,7 @@ interface ScriptEditorProps {
|
|||
path?: string;
|
||||
}
|
||||
|
||||
export function ScriptEditor(props: ScriptEditorProps) {
|
||||
const defaultCode = `// User-defined middleware script
|
||||
const DEFAULT_CODE = `// User-defined middleware script
|
||||
// Available functions: onRequest, onResponse
|
||||
|
||||
/**
|
||||
|
|
@ -28,13 +29,12 @@ export async function onRequest(ctx) {
|
|||
|
||||
// Example: Edit body
|
||||
// if (typeof ctx.request.body === 'object') {
|
||||
// if (typeof ctx.request.body['chat_template_kwargs'] !== 'object') {
|
||||
// ctx.request.body['chat_template_kwargs'] = {};
|
||||
// }
|
||||
|
||||
// ctx.request.body['chat_template_kwargs'] ??= {};
|
||||
// }
|
||||
|
||||
// Example: Log request
|
||||
// console.log('Request:', ctx.request.method, ctx.request.path);
|
||||
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
|
|
@ -46,20 +46,23 @@ export async function onRequest(ctx) {
|
|||
export async function onResponse(ctx) {
|
||||
// Example: Log response
|
||||
// console.log('Response status:', ctx.response?.status);
|
||||
|
||||
// Example: Handle streaming responses
|
||||
// if (ctx.response?.isStream && ctx.onChunk) {
|
||||
// const originalOnChunk = ctx.onChunk;
|
||||
// ctx.onChunk = (chunk) => {
|
||||
// console.log('Stream chunk:', chunk);
|
||||
// originalOnChunk(chunk);
|
||||
// };
|
||||
// }
|
||||
|
||||
|
||||
return ctx;
|
||||
}
|
||||
`;
|
||||
|
||||
function readThemePreference(): 'vs' | 'vs-dark' {
|
||||
const root = document.documentElement;
|
||||
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
const explicit = root.dataset.theme;
|
||||
const preferred = stored === 'light' || stored === 'dark' ? stored : explicit;
|
||||
const isDark = preferred
|
||||
? preferred === 'dark'
|
||||
: window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
return isDark ? 'vs-dark' : 'vs';
|
||||
}
|
||||
|
||||
function ScriptEditor(props: ScriptEditorProps) {
|
||||
const [editorTheme, setEditorTheme] = createSignal<'vs' | 'vs-dark'>(
|
||||
'vs-dark',
|
||||
);
|
||||
|
|
@ -68,25 +71,13 @@ export async function onResponse(ctx) {
|
|||
const root = document.documentElement;
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const syncTheme = () => {
|
||||
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
const explicitTheme = root.dataset.theme;
|
||||
const preferredTheme =
|
||||
storedTheme === 'light' || storedTheme === 'dark'
|
||||
? storedTheme
|
||||
: explicitTheme;
|
||||
const isDark = preferredTheme
|
||||
? preferredTheme === 'dark'
|
||||
: mediaQuery.matches;
|
||||
setEditorTheme(isDark ? 'vs-dark' : 'vs');
|
||||
};
|
||||
const syncTheme = () => setEditorTheme(readThemePreference());
|
||||
|
||||
const observer = new MutationObserver(syncTheme);
|
||||
observer.observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme'],
|
||||
});
|
||||
|
||||
mediaQuery.addEventListener('change', syncTheme);
|
||||
syncTheme();
|
||||
|
||||
|
|
@ -113,7 +104,7 @@ export async function onResponse(ctx) {
|
|||
<Dynamic
|
||||
component={MonacoEditor}
|
||||
language="typescript"
|
||||
onChange={(value: string) => props.onChange(value)}
|
||||
onChange={props.onChange}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
|
|
@ -125,9 +116,11 @@ export async function onResponse(ctx) {
|
|||
}}
|
||||
path={props.path}
|
||||
theme={editorTheme()}
|
||||
value={props.value || defaultCode}
|
||||
value={props.value || DEFAULT_CODE}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScriptEditor;
|
||||
5
client/src/reset.d.ts
vendored
Normal file
5
client/src/reset.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Activate ts-reset's improved built-in types globally for the client.
|
||||
// See https://www.totaltypescript.com/ts-reset for the rules this enables —
|
||||
// it sharpens types like `JSON.parse`, `Array.prototype.filter`, `fetch`,
|
||||
// `Object.entries`, etc., so we don't need to widen them with manual casts.
|
||||
import '@total-typescript/ts-reset';
|
||||
|
|
@ -45,7 +45,7 @@ type AnalyticsChartRow = { date: string } & Record<
|
|||
>;
|
||||
const formatInteger = new Intl.NumberFormat('en-US');
|
||||
|
||||
export const Analytics: Component = () => {
|
||||
const Analytics: Component = () => {
|
||||
const [days, setDays] = createSignal('30');
|
||||
const [backendFilter, setBackendFilter] = createSignal('all');
|
||||
const [hiddenDailySeries, setHiddenDailySeries] = createSignal<Set<string>>(
|
||||
|
|
@ -419,3 +419,5 @@ export const Analytics: Component = () => {
|
|||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Analytics;
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ const emptyForm = (): BackendFormState => ({
|
|||
detail_logging: false,
|
||||
});
|
||||
|
||||
export const Backends: Component = () => {
|
||||
const Backends: Component = () => {
|
||||
const [backends, { refetch }] = createResource(() => api.backends.getAll());
|
||||
const [dialogOpen, setDialogOpen] = createSignal(false);
|
||||
const [confirmOpen, setConfirmOpen] = createSignal(false);
|
||||
|
|
@ -527,3 +527,5 @@ export const Backends: Component = () => {
|
|||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Backends;
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ type DashboardChartRow = { date: string } & Record<
|
|||
string | number | null
|
||||
>;
|
||||
|
||||
export const Dashboard: Component = () => {
|
||||
const Dashboard: Component = () => {
|
||||
const [days, setDays] = createSignal('30');
|
||||
const [hiddenTrafficSeries, setHiddenTrafficSeries] = createSignal<
|
||||
Set<string>
|
||||
|
|
@ -494,3 +494,5 @@ export const Dashboard: Component = () => {
|
|||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ function prettyPrint(value?: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
export const DetailLogs: Component = () => {
|
||||
const DetailLogs: Component = () => {
|
||||
const [filters, setFilters] = createSignal<FilterState>(emptyFilters());
|
||||
const [page, setPage] = createSignal(1);
|
||||
const [pageSize, setPageSize] = createSignal(25);
|
||||
|
|
@ -527,3 +527,5 @@ export const DetailLogs: Component = () => {
|
|||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailLogs;
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ const emptyForm = (): RewriteFormState => ({
|
|||
note: '',
|
||||
});
|
||||
|
||||
export const Models: Component = () => {
|
||||
const Models: Component = () => {
|
||||
const [overview, { refetch: refetchOverview }] = createResource(() =>
|
||||
api.modelCache.getOverview(),
|
||||
);
|
||||
|
|
@ -528,3 +528,5 @@ export const Models: Component = () => {
|
|||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Models;
|
||||
|
|
|
|||
|
|
@ -39,14 +39,15 @@ import {
|
|||
TextField,
|
||||
} from '../ui';
|
||||
|
||||
import type { ScriptType, UserScript } from '../types';
|
||||
import type {
|
||||
CreateScriptInput,
|
||||
ScriptType,
|
||||
UpdateScriptInput,
|
||||
UserScript,
|
||||
} from '../types';
|
||||
|
||||
type NoticeTone = 'success' | 'warning' | 'danger' | 'info';
|
||||
const ScriptEditor = lazy(() =>
|
||||
import('../components/ScriptEditor').then((module) => ({
|
||||
default: module.ScriptEditor,
|
||||
})),
|
||||
);
|
||||
const ScriptEditor = lazy(() => import('../components/script-editor'));
|
||||
|
||||
interface ScriptFormState {
|
||||
id?: number;
|
||||
|
|
@ -119,7 +120,7 @@ const scriptTypeLabels: Record<ScriptType, string> = {
|
|||
'per-user': 'Per User',
|
||||
};
|
||||
|
||||
export const Scripts: Component = () => {
|
||||
const Scripts: Component = () => {
|
||||
const [scripts, { refetch: refetchScripts }] = createResource(() =>
|
||||
api.scripts.getAll(),
|
||||
);
|
||||
|
|
@ -247,6 +248,51 @@ export const Scripts: Component = () => {
|
|||
return null;
|
||||
};
|
||||
|
||||
const buildCreatePayload = (current: ScriptFormState): CreateScriptInput => {
|
||||
const base = {
|
||||
name: current.name.trim(),
|
||||
script_code: current.script_code,
|
||||
is_active: current.is_active,
|
||||
};
|
||||
|
||||
// Use type narrowing on script_type so the discriminated union picks the
|
||||
// right variant — no `as` casting required.
|
||||
switch (current.script_type) {
|
||||
case 'per-user-backend':
|
||||
return {
|
||||
...base,
|
||||
script_type: 'per-user-backend',
|
||||
target_user_id: Number(current.target_user_id),
|
||||
target_backend_id: Number(current.target_backend_id),
|
||||
};
|
||||
case 'per-backend':
|
||||
return {
|
||||
...base,
|
||||
script_type: 'per-backend',
|
||||
target_backend_id: Number(current.target_backend_id),
|
||||
};
|
||||
case 'per-user':
|
||||
return {
|
||||
...base,
|
||||
script_type: 'per-user',
|
||||
target_user_id: Number(current.target_user_id),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const buildUpdatePayload = (current: ScriptFormState): UpdateScriptInput => ({
|
||||
name: current.name.trim(),
|
||||
script_type: current.script_type,
|
||||
target_user_id: current.target_user_id
|
||||
? Number(current.target_user_id)
|
||||
: null,
|
||||
target_backend_id: current.target_backend_id
|
||||
? Number(current.target_backend_id)
|
||||
: null,
|
||||
script_code: current.script_code,
|
||||
is_active: current.is_active,
|
||||
});
|
||||
|
||||
const saveScript = async () => {
|
||||
const error = validateForm();
|
||||
if (error) {
|
||||
|
|
@ -255,27 +301,18 @@ export const Scripts: Component = () => {
|
|||
}
|
||||
|
||||
const current = form();
|
||||
const payload = {
|
||||
name: current.name.trim(),
|
||||
script_type: current.script_type,
|
||||
target_user_id: current.target_user_id
|
||||
? Number(current.target_user_id)
|
||||
: null,
|
||||
target_backend_id: current.target_backend_id
|
||||
? Number(current.target_backend_id)
|
||||
: null,
|
||||
script_code: current.script_code,
|
||||
is_active: current.is_active,
|
||||
};
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (current.id) {
|
||||
const updated = await api.scripts.update(current.id, payload);
|
||||
const updated = await api.scripts.update(
|
||||
current.id,
|
||||
buildUpdatePayload(current),
|
||||
);
|
||||
setNotice({ tone: 'success', message: 'Script updated.' });
|
||||
syncForm(updated);
|
||||
} else {
|
||||
const created = await api.scripts.create(payload);
|
||||
const created = await api.scripts.create(buildCreatePayload(current));
|
||||
setNotice({ tone: 'success', message: 'Script created.' });
|
||||
syncForm(created);
|
||||
}
|
||||
|
|
@ -668,7 +705,7 @@ export const Scripts: Component = () => {
|
|||
}
|
||||
>
|
||||
<ScriptEditor
|
||||
onChange={(value) =>
|
||||
onChange={(value: string) =>
|
||||
setForm((current) => ({ ...current, script_code: value }))
|
||||
}
|
||||
path={
|
||||
|
|
@ -750,3 +787,5 @@ export const Scripts: Component = () => {
|
|||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Scripts;
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ const emptyForm = (): UserFormState => ({
|
|||
|
||||
const maskApiKey = (apiKey: string) => `${apiKey.slice(0, 5)}...`;
|
||||
|
||||
export const Users: Component = () => {
|
||||
const Users: Component = () => {
|
||||
const [users, { refetch: refetchUsers }] = createResource(() =>
|
||||
api.users.getAll(),
|
||||
);
|
||||
|
|
@ -897,3 +897,5 @@ export const Users: Component = () => {
|
|||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Users;
|
||||
|
|
|
|||
|
|
@ -1,293 +1,4 @@
|
|||
export type User = {
|
||||
id: number;
|
||||
api_key: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
is_active: boolean;
|
||||
detail_logging: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type Backend = {
|
||||
id: number;
|
||||
name: string;
|
||||
base_url: string;
|
||||
api_key?: string;
|
||||
is_active: boolean;
|
||||
detail_logging: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
cached_model_count?: number;
|
||||
last_model_sync_at?: string;
|
||||
model_cache_initialized?: boolean;
|
||||
model_cache_state?: 'ready' | 'uninitialized' | 'error' | 'inactive';
|
||||
};
|
||||
|
||||
export type BackendModelSnapshot = {
|
||||
id: number;
|
||||
backend_id: number;
|
||||
model_id: string;
|
||||
raw_json?: string;
|
||||
fetched_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type BackendModelCacheStatus = {
|
||||
backend_id: number;
|
||||
initialized: boolean;
|
||||
state: 'ready' | 'uninitialized' | 'error' | 'inactive';
|
||||
model_count: number;
|
||||
last_synced_at?: string;
|
||||
last_attempted_at?: string;
|
||||
last_error?: string;
|
||||
};
|
||||
|
||||
export type BackendModelsResponse = {
|
||||
backend: Backend;
|
||||
cache: BackendModelCacheStatus;
|
||||
snapshots: BackendModelSnapshot[];
|
||||
models: string[];
|
||||
};
|
||||
|
||||
export type BackendModelCatalogEntry = {
|
||||
model_id: string;
|
||||
backend_ids: number[];
|
||||
};
|
||||
|
||||
export type ModelCacheOverview = {
|
||||
backends: BackendModelCacheStatus[];
|
||||
models: BackendModelCatalogEntry[];
|
||||
};
|
||||
|
||||
export type ModelRewriteRule = {
|
||||
id: number;
|
||||
source_model: string;
|
||||
target_model: string;
|
||||
is_active: boolean;
|
||||
force: boolean;
|
||||
note?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type Permission = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
backend_id: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type RequestLog = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
backend_id: number;
|
||||
endpoint: string;
|
||||
request_model?: string;
|
||||
routed_model?: string;
|
||||
response_model?: string;
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
status_code: number;
|
||||
response_time_ms?: number;
|
||||
error_message?: string;
|
||||
detail_logged: boolean;
|
||||
local_date: string;
|
||||
request_headers?: string;
|
||||
request_body?: string;
|
||||
response_headers?: string;
|
||||
response_body?: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type RequestLogPage = {
|
||||
rows: RequestLog[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
export type UsageStats = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
backend_id: number;
|
||||
date: string;
|
||||
total_requests: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
|
||||
export type BackendMetrics = {
|
||||
id: number;
|
||||
backend_id: number;
|
||||
date: string;
|
||||
total_requests: number;
|
||||
total_tokens: number;
|
||||
avg_response_time_ms: number;
|
||||
error_count: number;
|
||||
success_rate: number;
|
||||
};
|
||||
|
||||
export type AnalyticsDailyTotalsPoint = {
|
||||
date: string;
|
||||
total_requests: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
|
||||
export type AnalyticsBackendQualityPoint = {
|
||||
date: string;
|
||||
backend_id: number;
|
||||
total_requests: number;
|
||||
total_tokens: number;
|
||||
avg_response_time_ms: number;
|
||||
error_count: number;
|
||||
success_rate: number;
|
||||
};
|
||||
|
||||
export type AnalyticsModelTrendPoint = {
|
||||
date: string;
|
||||
model: string;
|
||||
request_count: number;
|
||||
};
|
||||
|
||||
export type AnalyticsHistogramBin = {
|
||||
bin_start: number;
|
||||
bin_end: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type AnalyticsBoxPlotPoint = {
|
||||
date: string;
|
||||
min: number;
|
||||
q1: number;
|
||||
median: number;
|
||||
q3: number;
|
||||
max: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type DashboardHealthStatus = {
|
||||
status: 'ok';
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type DashboardOverviewSummary = {
|
||||
total_users: number;
|
||||
active_users: number;
|
||||
total_backends: number;
|
||||
active_backends: number;
|
||||
total_permissions: number;
|
||||
total_scripts: number;
|
||||
active_scripts: number;
|
||||
};
|
||||
|
||||
export type DashboardHealthSummary = {
|
||||
cache_state_counts: Record<
|
||||
Backend['model_cache_state'] extends infer T ? Extract<T, string> : never,
|
||||
number
|
||||
>;
|
||||
stale_backends: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
state: NonNullable<Backend['model_cache_state']>;
|
||||
last_synced_at?: string;
|
||||
}>;
|
||||
public_health: DashboardHealthStatus;
|
||||
admin_health: DashboardHealthStatus;
|
||||
};
|
||||
|
||||
export type DashboardLoggingSummary = {
|
||||
users_with_detail_logging: number;
|
||||
backends_with_detail_logging: number;
|
||||
};
|
||||
|
||||
export type DashboardScriptSummary = {
|
||||
active_by_type: Record<ScriptType, number>;
|
||||
total_by_type: Record<ScriptType, number>;
|
||||
};
|
||||
|
||||
export type DashboardAccessSummary = {
|
||||
permission_assignments: number;
|
||||
users_without_permissions: number;
|
||||
};
|
||||
|
||||
export type DashboardSummaryResponse = {
|
||||
window_days: number;
|
||||
generated_at: string;
|
||||
overview: DashboardOverviewSummary;
|
||||
health: DashboardHealthSummary;
|
||||
logging: DashboardLoggingSummary;
|
||||
scripts: DashboardScriptSummary;
|
||||
access: DashboardAccessSummary;
|
||||
series: {
|
||||
daily_totals: AnalyticsDailyTotalsPoint[];
|
||||
backend_quality: AnalyticsBackendQualityPoint[];
|
||||
model_trends: AnalyticsModelTrendPoint[];
|
||||
};
|
||||
};
|
||||
|
||||
export type ScriptType = 'per-user-backend' | 'per-backend' | 'per-user';
|
||||
|
||||
export type UserScript = {
|
||||
id: number;
|
||||
name: string;
|
||||
script_type: ScriptType;
|
||||
target_user_id: number | null;
|
||||
target_backend_id: number | null;
|
||||
script_code: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type CreateScriptData = {
|
||||
name: string;
|
||||
script_type: ScriptType;
|
||||
target_user_id?: number | null;
|
||||
target_backend_id?: number | null;
|
||||
script_code: string;
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
export type UpdateScriptData = {
|
||||
name?: string;
|
||||
script_type?: ScriptType;
|
||||
target_user_id?: number | null;
|
||||
target_backend_id?: number | null;
|
||||
script_code?: string;
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
export type AdminAuthMode = 'env' | 'oidc' | 'both';
|
||||
|
||||
export type AdminPrincipal = {
|
||||
provider: 'env' | 'oidc';
|
||||
subject: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export type AdminSessionResponse = {
|
||||
authenticated: boolean;
|
||||
authMode: AdminAuthMode;
|
||||
csrfToken: string | null;
|
||||
principal: AdminPrincipal | null;
|
||||
};
|
||||
|
||||
export type AdminApiTokenSummary = {
|
||||
id: number;
|
||||
name: string;
|
||||
provider: 'env' | 'oidc';
|
||||
subject: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
display_name: string;
|
||||
token_prefix: string;
|
||||
expires_at: string;
|
||||
last_used_at?: string;
|
||||
revoked_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
// Re-export every shared domain type so existing client code can keep
|
||||
// importing from `../types`. The shared package is the single source of
|
||||
// truth for the schemas and types that travel across the wire.
|
||||
export * from '@kyush/shared';
|
||||
|
|
|
|||
|
|
@ -1,14 +1,27 @@
|
|||
import { For, Show, createMemo } from 'solid-js';
|
||||
import {
|
||||
LooseChatCompletionRequestSchema,
|
||||
LooseChatCompletionResponseSchema,
|
||||
} from '@kyush/shared';
|
||||
import { For, Show, type Component } from 'solid-js';
|
||||
|
||||
import { MetaCluster } from './MetaCluster';
|
||||
import { StatusBadge, type StatusTone } from './StatusBadge';
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* Types
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
type KnownChatRole = 'system' | 'user' | 'assistant';
|
||||
|
||||
interface MetaItem {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ParsedMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
metadata?: Array<{ key: string; value: string }>;
|
||||
metadata?: MetaItem[];
|
||||
}
|
||||
|
||||
interface ConversationTimelineProps {
|
||||
|
|
@ -17,184 +30,191 @@ interface ConversationTimelineProps {
|
|||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
function normalizePayload(value: unknown): Record<string, unknown> | null {
|
||||
if (!value) return null;
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* Parsing helpers
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function parseJsonLike(value: unknown): unknown {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object'
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return typeof value === 'object' ? (value as Record<string, unknown>) : null;
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeMessages(
|
||||
payload: Record<string, unknown> | null,
|
||||
function stringifyContent(value: unknown): string {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function parseRequest(body: unknown) {
|
||||
const raw = parseJsonLike(body);
|
||||
if (raw == null) return null;
|
||||
const result = LooseChatCompletionRequestSchema.safeParse(raw);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
|
||||
function parseResponse(body: unknown) {
|
||||
const raw = parseJsonLike(body);
|
||||
if (raw == null) return null;
|
||||
const result = LooseChatCompletionResponseSchema.safeParse(raw);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift the user/system/assistant messages out of an OpenAI-shaped request body.
|
||||
* Returns an empty array when nothing parsable is present.
|
||||
*/
|
||||
function extractRequestMessages(
|
||||
request: ReturnType<typeof parseRequest>,
|
||||
): ParsedMessage[] {
|
||||
const messages = payload?.messages;
|
||||
if (!Array.isArray(messages)) return [];
|
||||
|
||||
return messages
|
||||
.filter(
|
||||
(item): item is Record<string, unknown> =>
|
||||
Boolean(item) && typeof item === 'object',
|
||||
)
|
||||
.map((item) => ({
|
||||
role: typeof item.role === 'string' ? item.role : 'unknown',
|
||||
content:
|
||||
typeof item.content === 'string'
|
||||
? item.content
|
||||
: JSON.stringify(item.content ?? ''),
|
||||
}));
|
||||
if (!request?.messages) return [];
|
||||
return request.messages.map((message) => ({
|
||||
role: typeof message.role === 'string' ? message.role : 'unknown',
|
||||
content: stringifyContent(message.content),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeAssistantMessages(
|
||||
payload: Record<string, unknown> | null,
|
||||
/**
|
||||
* Lift the assistant turns out of an OpenAI-shaped response body, including the
|
||||
* extra metadata fields (reasoning, tool calls, finish_reason, etc.) that show
|
||||
* up in chat completion choices.
|
||||
*/
|
||||
function extractResponseMessages(
|
||||
response: ReturnType<typeof parseResponse>,
|
||||
): ParsedMessage[] {
|
||||
const choices = payload?.choices;
|
||||
if (!Array.isArray(choices)) return [];
|
||||
if (!response?.choices) return [];
|
||||
|
||||
const messages: Array<ParsedMessage | null> = choices.map((choice) => {
|
||||
if (!choice || typeof choice !== 'object') return null;
|
||||
const message = (choice as Record<string, unknown>).message;
|
||||
if (!message || typeof message !== 'object') return null;
|
||||
return response.choices
|
||||
.map((choice): ParsedMessage | null => {
|
||||
const message = choice.message;
|
||||
if (!message) return null;
|
||||
|
||||
const content =
|
||||
typeof (message as Record<string, unknown>).content === 'string'
|
||||
? String((message as Record<string, unknown>).content)
|
||||
: JSON.stringify((message as Record<string, unknown>).content ?? '');
|
||||
const metadata = [
|
||||
(message as Record<string, unknown>).reasoning_content !== undefined
|
||||
? {
|
||||
key: 'Reasoning',
|
||||
value:
|
||||
typeof (message as Record<string, unknown>).reasoning_content ===
|
||||
'string'
|
||||
? String((message as Record<string, unknown>).reasoning_content)
|
||||
: JSON.stringify(
|
||||
(message as Record<string, unknown>).reasoning_content,
|
||||
),
|
||||
}
|
||||
: null,
|
||||
(message as Record<string, unknown>).tool_calls !== undefined
|
||||
? {
|
||||
key: 'Tool Calls',
|
||||
value: JSON.stringify(
|
||||
(message as Record<string, unknown>).tool_calls,
|
||||
),
|
||||
}
|
||||
: null,
|
||||
(choice as Record<string, unknown>).finish_reason !== undefined
|
||||
? {
|
||||
key: 'Finish',
|
||||
value: String((choice as Record<string, unknown>).finish_reason),
|
||||
}
|
||||
: null,
|
||||
(choice as Record<string, unknown>).matched_stop !== undefined
|
||||
? {
|
||||
key: 'Matched Stop',
|
||||
value: String((choice as Record<string, unknown>).matched_stop),
|
||||
}
|
||||
: null,
|
||||
(choice as Record<string, unknown>).logprobs !== undefined
|
||||
? {
|
||||
key: 'Logprobs',
|
||||
value: JSON.stringify((choice as Record<string, unknown>).logprobs),
|
||||
}
|
||||
: null,
|
||||
].filter((item): item is { key: string; value: string } => Boolean(item));
|
||||
const metadata: MetaItem[] = [];
|
||||
if (message.reasoning_content !== undefined) {
|
||||
metadata.push({
|
||||
key: 'Reasoning',
|
||||
value: stringifyContent(message.reasoning_content),
|
||||
});
|
||||
}
|
||||
if (message.tool_calls !== undefined) {
|
||||
metadata.push({
|
||||
key: 'Tool Calls',
|
||||
value: stringifyContent(message.tool_calls),
|
||||
});
|
||||
}
|
||||
if (choice.finish_reason !== undefined) {
|
||||
metadata.push({
|
||||
key: 'Finish',
|
||||
value: String(choice.finish_reason),
|
||||
});
|
||||
}
|
||||
if (choice.matched_stop !== undefined) {
|
||||
metadata.push({
|
||||
key: 'Matched Stop',
|
||||
value: String(choice.matched_stop),
|
||||
});
|
||||
}
|
||||
if (choice.logprobs !== undefined) {
|
||||
metadata.push({
|
||||
key: 'Logprobs',
|
||||
value: stringifyContent(choice.logprobs),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
role: 'assistant' as const,
|
||||
content,
|
||||
metadata,
|
||||
};
|
||||
});
|
||||
|
||||
return messages.filter(
|
||||
(message): message is ParsedMessage => message !== null,
|
||||
);
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: stringifyContent(message.content),
|
||||
metadata: metadata.length > 0 ? metadata : undefined,
|
||||
};
|
||||
})
|
||||
.filter((message): message is ParsedMessage => message !== null);
|
||||
}
|
||||
|
||||
export function hasRenderableConversation(
|
||||
requestBody?: unknown,
|
||||
responseBody?: unknown,
|
||||
): boolean {
|
||||
const requestMessages = normalizeMessages(normalizePayload(requestBody));
|
||||
const responseMessages = normalizeAssistantMessages(
|
||||
normalizePayload(responseBody),
|
||||
);
|
||||
return requestMessages.length > 0 || responseMessages.length > 0;
|
||||
function buildSummaryItems(
|
||||
request: ReturnType<typeof parseRequest>,
|
||||
response: ReturnType<typeof parseResponse>,
|
||||
): MetaItem[] {
|
||||
const items: MetaItem[] = [];
|
||||
|
||||
if (typeof request?.model === 'string') {
|
||||
items.push({ key: 'Model', value: request.model });
|
||||
}
|
||||
if (request?.temperature !== undefined) {
|
||||
items.push({ key: 'Temp', value: String(request.temperature) });
|
||||
}
|
||||
if (typeof response?.created === 'number') {
|
||||
items.push({ key: 'Created', value: String(response.created) });
|
||||
}
|
||||
|
||||
const usage = response?.usage;
|
||||
if (usage) {
|
||||
if (usage.prompt_tokens !== undefined) {
|
||||
items.push({ key: 'Prompt', value: String(usage.prompt_tokens) });
|
||||
}
|
||||
if (usage.completion_tokens !== undefined) {
|
||||
items.push({ key: 'Completion', value: String(usage.completion_tokens) });
|
||||
}
|
||||
if (usage.total_tokens !== undefined) {
|
||||
items.push({ key: 'Total', value: String(usage.total_tokens) });
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
const roleTone: Record<KnownChatRole, StatusTone> = {
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* Public helpers + component
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const ROLE_TONES: Record<KnownChatRole, StatusTone> = {
|
||||
system: 'info',
|
||||
user: 'warning',
|
||||
assistant: 'success',
|
||||
};
|
||||
|
||||
function getRoleTone(role: string): StatusTone {
|
||||
if (role === 'system' || role === 'user' || role === 'assistant') {
|
||||
return roleTone[role];
|
||||
}
|
||||
return 'neutral';
|
||||
function isKnownRole(role: string): role is KnownChatRole {
|
||||
return role === 'system' || role === 'user' || role === 'assistant';
|
||||
}
|
||||
|
||||
function getRoleClass(role: string): string {
|
||||
if (role === 'system' || role === 'user' || role === 'assistant') {
|
||||
return `ui-conversation__turn--${role}`;
|
||||
}
|
||||
return 'ui-conversation__turn--unknown';
|
||||
const getRoleTone = (role: string): StatusTone =>
|
||||
isKnownRole(role) ? ROLE_TONES[role] : 'neutral';
|
||||
|
||||
const getRoleClass = (role: string): string =>
|
||||
isKnownRole(role)
|
||||
? `ui-conversation__turn--${role}`
|
||||
: 'ui-conversation__turn--unknown';
|
||||
|
||||
export function hasRenderableConversation(
|
||||
requestBody?: unknown,
|
||||
responseBody?: unknown,
|
||||
): boolean {
|
||||
const requestMessages = extractRequestMessages(parseRequest(requestBody));
|
||||
const responseMessages = extractResponseMessages(parseResponse(responseBody));
|
||||
return requestMessages.length > 0 || responseMessages.length > 0;
|
||||
}
|
||||
|
||||
export function ConversationTimeline(props: ConversationTimelineProps) {
|
||||
const parsedRequest = createMemo(() => normalizePayload(props.requestBody));
|
||||
const parsedResponse = createMemo(() => normalizePayload(props.responseBody));
|
||||
|
||||
const requestMessages = createMemo(() => normalizeMessages(parsedRequest()));
|
||||
const responseMessages = createMemo(() =>
|
||||
normalizeAssistantMessages(parsedResponse()),
|
||||
);
|
||||
const messages = createMemo(() => [
|
||||
...requestMessages(),
|
||||
...responseMessages(),
|
||||
]);
|
||||
|
||||
const summaryItems = createMemo(() => {
|
||||
const request = parsedRequest();
|
||||
const response = parsedResponse();
|
||||
const usage =
|
||||
response?.usage && typeof response.usage === 'object'
|
||||
? (response.usage as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
return [
|
||||
typeof request?.model === 'string'
|
||||
? { key: 'Model', value: request.model }
|
||||
: null,
|
||||
request?.temperature !== undefined
|
||||
? { key: 'Temp', value: String(request.temperature) }
|
||||
: null,
|
||||
typeof response?.created === 'number'
|
||||
? { key: 'Created', value: String(response.created) }
|
||||
: null,
|
||||
usage?.prompt_tokens !== undefined
|
||||
? { key: 'Prompt', value: String(usage.prompt_tokens) }
|
||||
: null,
|
||||
usage?.completion_tokens !== undefined
|
||||
? { key: 'Completion', value: String(usage.completion_tokens) }
|
||||
: null,
|
||||
usage?.total_tokens !== undefined
|
||||
? { key: 'Total', value: String(usage.total_tokens) }
|
||||
: null,
|
||||
].filter((item): item is { key: string; value: string } => Boolean(item));
|
||||
});
|
||||
export const ConversationTimeline: Component<ConversationTimelineProps> = (
|
||||
props,
|
||||
) => {
|
||||
// Inline derivations — Solid's reactive prop reads make extra createMemo
|
||||
// wrappers unnecessary for cheap shape transforms like these.
|
||||
const request = () => parseRequest(props.requestBody);
|
||||
const response = () => parseResponse(props.responseBody);
|
||||
const messages = () => [
|
||||
...extractRequestMessages(request()),
|
||||
...extractResponseMessages(response()),
|
||||
];
|
||||
const summaryItems = () => buildSummaryItems(request(), response());
|
||||
|
||||
return (
|
||||
<div class="ui-conversation">
|
||||
|
|
@ -240,4 +260,4 @@ export function ConversationTimeline(props: ConversationTimelineProps) {
|
|||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import * as KCheckbox from '@kobalte/core/checkbox';
|
||||
import * as CheckboxPrimitive from '@kobalte/core/checkbox';
|
||||
import Check from 'lucide-solid/icons/check';
|
||||
import { Show, type Component } from 'solid-js';
|
||||
|
||||
import { cn } from '../lib/cn';
|
||||
|
||||
|
|
@ -12,29 +14,29 @@ interface CheckboxProps {
|
|||
onChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export function Checkbox(props: CheckboxProps) {
|
||||
export const Checkbox: Component<CheckboxProps> = (props) => {
|
||||
return (
|
||||
<KCheckbox.Root
|
||||
<CheckboxPrimitive.Root
|
||||
checked={props.checked}
|
||||
class={cn('ui-checkbox', props.class)}
|
||||
defaultChecked={props.defaultChecked}
|
||||
disabled={props.disabled}
|
||||
onChange={props.onChange}
|
||||
>
|
||||
<KCheckbox.Input />
|
||||
<KCheckbox.Control class="ui-checkbox__control">
|
||||
<KCheckbox.Indicator class="ui-checkbox__indicator">
|
||||
✓
|
||||
</KCheckbox.Indicator>
|
||||
</KCheckbox.Control>
|
||||
<CheckboxPrimitive.Input />
|
||||
<CheckboxPrimitive.Control class="ui-checkbox__control">
|
||||
<CheckboxPrimitive.Indicator class="ui-checkbox__indicator">
|
||||
<Check aria-hidden="true" size={14} />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Control>
|
||||
<span>
|
||||
<KCheckbox.Label>{props.label}</KCheckbox.Label>
|
||||
{props.description && (
|
||||
<KCheckbox.Description class="ui-field__description">
|
||||
<CheckboxPrimitive.Label>{props.label}</CheckboxPrimitive.Label>
|
||||
<Show when={props.description}>
|
||||
<CheckboxPrimitive.Description class="ui-field__description">
|
||||
{props.description}
|
||||
</KCheckbox.Description>
|
||||
)}
|
||||
</CheckboxPrimitive.Description>
|
||||
</Show>
|
||||
</span>
|
||||
</KCheckbox.Root>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import * as KDialog from '@kobalte/core/dialog';
|
||||
import * as DialogPrimitive from '@kobalte/core/dialog';
|
||||
|
||||
import { cn } from '../lib/cn';
|
||||
|
||||
|
|
@ -8,54 +8,54 @@ type WrapperProps = ParentProps<{ class?: string; [key: string]: unknown }>;
|
|||
|
||||
export const Dialog = {
|
||||
Root: (props: WrapperProps) => (
|
||||
<KDialog.Root {...(props as KDialog.DialogRootProps)}>
|
||||
<DialogPrimitive.Root {...(props as DialogPrimitive.DialogRootProps)}>
|
||||
{props.children}
|
||||
</KDialog.Root>
|
||||
</DialogPrimitive.Root>
|
||||
),
|
||||
Trigger: (props: WrapperProps) => (
|
||||
<KDialog.Trigger
|
||||
{...(props as KDialog.DialogTriggerProps)}
|
||||
<DialogPrimitive.Trigger
|
||||
{...(props as DialogPrimitive.DialogTriggerProps)}
|
||||
class={cn('ui-button', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KDialog.Trigger>
|
||||
</DialogPrimitive.Trigger>
|
||||
),
|
||||
Portal: (props: WrapperProps) => (
|
||||
<KDialog.Portal>{props.children}</KDialog.Portal>
|
||||
<DialogPrimitive.Portal>{props.children}</DialogPrimitive.Portal>
|
||||
),
|
||||
Overlay: (props: WrapperProps) => (
|
||||
<KDialog.Overlay
|
||||
{...(props as KDialog.DialogOverlayProps)}
|
||||
<DialogPrimitive.Overlay
|
||||
{...(props as DialogPrimitive.DialogOverlayProps)}
|
||||
class={cn('ui-dialog__overlay', props.class)}
|
||||
/>
|
||||
),
|
||||
Content: (props: WrapperProps) => (
|
||||
<KDialog.Content
|
||||
{...(props as KDialog.DialogContentProps)}
|
||||
<DialogPrimitive.Content
|
||||
{...(props as DialogPrimitive.DialogContentProps)}
|
||||
class={cn('ui-dialog__content', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KDialog.Content>
|
||||
</DialogPrimitive.Content>
|
||||
),
|
||||
Title: (props: WrapperProps) => (
|
||||
<KDialog.Title {...(props as KDialog.DialogTitleProps)}>
|
||||
<DialogPrimitive.Title {...(props as DialogPrimitive.DialogTitleProps)}>
|
||||
{props.children}
|
||||
</KDialog.Title>
|
||||
</DialogPrimitive.Title>
|
||||
),
|
||||
Description: (props: WrapperProps) => (
|
||||
<KDialog.Description
|
||||
{...(props as KDialog.DialogDescriptionProps)}
|
||||
<DialogPrimitive.Description
|
||||
{...(props as DialogPrimitive.DialogDescriptionProps)}
|
||||
class={cn('ui-subtitle', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KDialog.Description>
|
||||
</DialogPrimitive.Description>
|
||||
),
|
||||
CloseButton: (props: WrapperProps) => (
|
||||
<KDialog.CloseButton
|
||||
{...(props as KDialog.DialogCloseButtonProps)}
|
||||
<DialogPrimitive.CloseButton
|
||||
{...(props as DialogPrimitive.DialogCloseButtonProps)}
|
||||
class={cn('ui-button', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KDialog.CloseButton>
|
||||
</DialogPrimitive.CloseButton>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import * as KDropdownMenu from '@kobalte/core/dropdown-menu';
|
||||
import * as DropdownMenuPrimitive from '@kobalte/core/dropdown-menu';
|
||||
|
||||
import { cn } from '../lib/cn';
|
||||
|
||||
|
|
@ -8,40 +8,44 @@ type WrapperProps = ParentProps<{ class?: string; [key: string]: unknown }>;
|
|||
|
||||
export const DropdownMenu = {
|
||||
Root: (props: WrapperProps) => (
|
||||
<KDropdownMenu.Root {...(props as KDropdownMenu.DropdownMenuRootProps)}>
|
||||
<DropdownMenuPrimitive.Root
|
||||
{...(props as DropdownMenuPrimitive.DropdownMenuRootProps)}
|
||||
>
|
||||
{props.children}
|
||||
</KDropdownMenu.Root>
|
||||
</DropdownMenuPrimitive.Root>
|
||||
),
|
||||
Trigger: (props: WrapperProps) => (
|
||||
<KDropdownMenu.Trigger
|
||||
{...(props as KDropdownMenu.DropdownMenuTriggerProps)}
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
{...(props as DropdownMenuPrimitive.DropdownMenuTriggerProps)}
|
||||
class={cn('ui-button', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KDropdownMenu.Trigger>
|
||||
</DropdownMenuPrimitive.Trigger>
|
||||
),
|
||||
Portal: (props: WrapperProps) => (
|
||||
<KDropdownMenu.Portal>{props.children}</KDropdownMenu.Portal>
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
{props.children}
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
),
|
||||
Content: (props: WrapperProps) => (
|
||||
<KDropdownMenu.Content
|
||||
{...(props as KDropdownMenu.DropdownMenuContentProps)}
|
||||
<DropdownMenuPrimitive.Content
|
||||
{...(props as DropdownMenuPrimitive.DropdownMenuContentProps)}
|
||||
class={cn('ui-dropdown__content', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KDropdownMenu.Content>
|
||||
</DropdownMenuPrimitive.Content>
|
||||
),
|
||||
Item: (props: WrapperProps) => (
|
||||
<KDropdownMenu.Item
|
||||
{...(props as KDropdownMenu.DropdownMenuItemProps)}
|
||||
<DropdownMenuPrimitive.Item
|
||||
{...(props as DropdownMenuPrimitive.DropdownMenuItemProps)}
|
||||
class={cn('ui-dropdown__item', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KDropdownMenu.Item>
|
||||
</DropdownMenuPrimitive.Item>
|
||||
),
|
||||
Separator: (props: WrapperProps) => (
|
||||
<KDropdownMenu.Separator
|
||||
{...(props as KDropdownMenu.DropdownMenuSeparatorProps)}
|
||||
<DropdownMenuPrimitive.Separator
|
||||
{...(props as DropdownMenuPrimitive.DropdownMenuSeparatorProps)}
|
||||
class={cn('ui-dropdown__separator', props.class)}
|
||||
/>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import * as KPopover from '@kobalte/core/popover';
|
||||
import * as PopoverPrimitive from '@kobalte/core/popover';
|
||||
|
||||
import { cn } from '../lib/cn';
|
||||
|
||||
|
|
@ -8,48 +8,48 @@ type WrapperProps = ParentProps<{ class?: string; [key: string]: unknown }>;
|
|||
|
||||
export const Popover = {
|
||||
Root: (props: WrapperProps) => (
|
||||
<KPopover.Root {...(props as KPopover.PopoverRootProps)}>
|
||||
<PopoverPrimitive.Root {...(props as PopoverPrimitive.PopoverRootProps)}>
|
||||
{props.children}
|
||||
</KPopover.Root>
|
||||
</PopoverPrimitive.Root>
|
||||
),
|
||||
Trigger: (props: WrapperProps) => (
|
||||
<KPopover.Trigger
|
||||
{...(props as KPopover.PopoverTriggerProps)}
|
||||
<PopoverPrimitive.Trigger
|
||||
{...(props as PopoverPrimitive.PopoverTriggerProps)}
|
||||
class={cn('ui-button', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KPopover.Trigger>
|
||||
</PopoverPrimitive.Trigger>
|
||||
),
|
||||
Portal: (props: WrapperProps) => (
|
||||
<KPopover.Portal>{props.children}</KPopover.Portal>
|
||||
<PopoverPrimitive.Portal>{props.children}</PopoverPrimitive.Portal>
|
||||
),
|
||||
Content: (props: WrapperProps) => (
|
||||
<KPopover.Content
|
||||
{...(props as KPopover.PopoverContentProps)}
|
||||
<PopoverPrimitive.Content
|
||||
{...(props as PopoverPrimitive.PopoverContentProps)}
|
||||
class={cn('ui-popover__content', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KPopover.Content>
|
||||
</PopoverPrimitive.Content>
|
||||
),
|
||||
Title: (props: WrapperProps) => (
|
||||
<KPopover.Title {...(props as KPopover.PopoverTitleProps)}>
|
||||
<PopoverPrimitive.Title {...(props as PopoverPrimitive.PopoverTitleProps)}>
|
||||
{props.children}
|
||||
</KPopover.Title>
|
||||
</PopoverPrimitive.Title>
|
||||
),
|
||||
Description: (props: WrapperProps) => (
|
||||
<KPopover.Description
|
||||
{...(props as KPopover.PopoverDescriptionProps)}
|
||||
<PopoverPrimitive.Description
|
||||
{...(props as PopoverPrimitive.PopoverDescriptionProps)}
|
||||
class={cn('ui-subtitle', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KPopover.Description>
|
||||
</PopoverPrimitive.Description>
|
||||
),
|
||||
CloseButton: (props: WrapperProps) => (
|
||||
<KPopover.CloseButton
|
||||
{...(props as KPopover.PopoverCloseButtonProps)}
|
||||
<PopoverPrimitive.CloseButton
|
||||
{...(props as PopoverPrimitive.PopoverCloseButtonProps)}
|
||||
class={cn('ui-button', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KPopover.CloseButton>
|
||||
</PopoverPrimitive.CloseButton>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import * as KSelect from '@kobalte/core/select';
|
||||
import { Show, createMemo } from 'solid-js';
|
||||
import * as SelectPrimitive from '@kobalte/core/select';
|
||||
import Check from 'lucide-solid/icons/check';
|
||||
import ChevronDown from 'lucide-solid/icons/chevron-down';
|
||||
import { Show, type Component } from 'solid-js';
|
||||
|
||||
import { cn } from '../lib/cn';
|
||||
|
||||
|
|
@ -18,19 +20,25 @@ interface SelectProps {
|
|||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function Select(props: SelectProps) {
|
||||
const selected = createMemo(() =>
|
||||
props.options.find((option) => option.value === props.value),
|
||||
);
|
||||
export const Select: Component<SelectProps> = (props) => {
|
||||
// Note: derive `selected` inline (no createMemo) — Solid's reactive primitives
|
||||
// already cache prop reads, and `find` over a typically tiny option list is
|
||||
// cheaper than the memo bookkeeping.
|
||||
const selected = () =>
|
||||
props.options.find((option) => option.value === props.value);
|
||||
|
||||
return (
|
||||
<KSelect.Root<SelectOption>
|
||||
<SelectPrimitive.Root<SelectOption>
|
||||
class={cn('ui-select', props.class)}
|
||||
itemComponent={(itemProps) => (
|
||||
<KSelect.Item class="ui-select__item" item={itemProps.item}>
|
||||
<KSelect.ItemLabel>{itemProps.item.rawValue.label}</KSelect.ItemLabel>
|
||||
<KSelect.ItemIndicator>✓</KSelect.ItemIndicator>
|
||||
</KSelect.Item>
|
||||
<SelectPrimitive.Item class="ui-select__item" item={itemProps.item}>
|
||||
<SelectPrimitive.ItemLabel>
|
||||
{itemProps.item.rawValue.label}
|
||||
</SelectPrimitive.ItemLabel>
|
||||
<SelectPrimitive.ItemIndicator class="ui-select__item-indicator">
|
||||
<Check aria-hidden="true" size={14} />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)}
|
||||
onChange={(option) => props.onChange?.(option?.value ?? '')}
|
||||
optionTextValue="label"
|
||||
|
|
@ -40,21 +48,25 @@ export function Select(props: SelectProps) {
|
|||
value={selected()}
|
||||
>
|
||||
<Show when={props.label}>
|
||||
<KSelect.Label class="ui-field__label">{props.label}</KSelect.Label>
|
||||
<SelectPrimitive.Label class="ui-field__label">
|
||||
{props.label}
|
||||
</SelectPrimitive.Label>
|
||||
</Show>
|
||||
<KSelect.Trigger class="ui-select__trigger">
|
||||
<KSelect.Value<SelectOption> class="ui-select__value">
|
||||
<SelectPrimitive.Trigger class="ui-select__trigger">
|
||||
<SelectPrimitive.Value<SelectOption> class="ui-select__value">
|
||||
{(state) =>
|
||||
state.selectedOption()?.label ?? props.placeholder ?? 'Select'
|
||||
}
|
||||
</KSelect.Value>
|
||||
<KSelect.Icon>▾</KSelect.Icon>
|
||||
</KSelect.Trigger>
|
||||
<KSelect.Portal>
|
||||
<KSelect.Content class="ui-select__content">
|
||||
<KSelect.Listbox />
|
||||
</KSelect.Content>
|
||||
</KSelect.Portal>
|
||||
</KSelect.Root>
|
||||
</SelectPrimitive.Value>
|
||||
<SelectPrimitive.Icon class="ui-select__icon">
|
||||
<ChevronDown aria-hidden="true" size={14} />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content class="ui-select__content">
|
||||
<SelectPrimitive.Listbox />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
</SelectPrimitive.Root>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import * as KSwitch from '@kobalte/core/switch';
|
||||
import * as SwitchPrimitive from '@kobalte/core/switch';
|
||||
|
||||
import { cn } from '../lib/cn';
|
||||
|
||||
|
|
@ -14,25 +14,25 @@ interface SwitchProps {
|
|||
|
||||
export function Switch(props: SwitchProps) {
|
||||
return (
|
||||
<KSwitch.Root
|
||||
<SwitchPrimitive.Root
|
||||
checked={props.checked}
|
||||
class={cn('ui-switch', props.class)}
|
||||
defaultChecked={props.defaultChecked}
|
||||
disabled={props.disabled}
|
||||
onChange={props.onChange}
|
||||
>
|
||||
<KSwitch.Input />
|
||||
<KSwitch.Control class="ui-switch__control">
|
||||
<KSwitch.Thumb class="ui-switch__thumb" />
|
||||
</KSwitch.Control>
|
||||
<SwitchPrimitive.Input />
|
||||
<SwitchPrimitive.Control class="ui-switch__control">
|
||||
<SwitchPrimitive.Thumb class="ui-switch__thumb" />
|
||||
</SwitchPrimitive.Control>
|
||||
<span>
|
||||
<KSwitch.Label>{props.label}</KSwitch.Label>
|
||||
<SwitchPrimitive.Label>{props.label}</SwitchPrimitive.Label>
|
||||
{props.description && (
|
||||
<KSwitch.Description class="ui-field__description">
|
||||
<SwitchPrimitive.Description class="ui-field__description">
|
||||
{props.description}
|
||||
</KSwitch.Description>
|
||||
</SwitchPrimitive.Description>
|
||||
)}
|
||||
</span>
|
||||
</KSwitch.Root>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import * as KTabs from '@kobalte/core/tabs';
|
||||
import * as TabsPrimitive from '@kobalte/core/tabs';
|
||||
|
||||
import { cn } from '../lib/cn';
|
||||
|
||||
|
|
@ -8,35 +8,35 @@ type WrapperProps = ParentProps<{ class?: string; [key: string]: unknown }>;
|
|||
|
||||
export const Tabs = {
|
||||
Root: (props: WrapperProps) => (
|
||||
<KTabs.Root
|
||||
{...(props as unknown as KTabs.TabsRootProps)}
|
||||
<TabsPrimitive.Root
|
||||
{...(props as unknown as TabsPrimitive.TabsRootProps)}
|
||||
class={cn('ui-tabs', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KTabs.Root>
|
||||
</TabsPrimitive.Root>
|
||||
),
|
||||
List: (props: WrapperProps) => (
|
||||
<KTabs.List
|
||||
{...(props as unknown as KTabs.TabsListProps)}
|
||||
<TabsPrimitive.List
|
||||
{...(props as unknown as TabsPrimitive.TabsListProps)}
|
||||
class={cn('ui-tabs__list', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KTabs.List>
|
||||
</TabsPrimitive.List>
|
||||
),
|
||||
Trigger: (props: WrapperProps) => (
|
||||
<KTabs.Trigger
|
||||
{...(props as unknown as KTabs.TabsTriggerProps)}
|
||||
<TabsPrimitive.Trigger
|
||||
{...(props as unknown as TabsPrimitive.TabsTriggerProps)}
|
||||
class={cn('ui-tabs__trigger', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KTabs.Trigger>
|
||||
</TabsPrimitive.Trigger>
|
||||
),
|
||||
Content: (props: WrapperProps) => (
|
||||
<KTabs.Content
|
||||
{...(props as unknown as KTabs.TabsContentProps)}
|
||||
<TabsPrimitive.Content
|
||||
{...(props as unknown as TabsPrimitive.TabsContentProps)}
|
||||
class={cn('ui-tabs__content', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KTabs.Content>
|
||||
</TabsPrimitive.Content>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import * as KTextField from '@kobalte/core/text-field';
|
||||
import * as TextFieldPrimitive from '@kobalte/core/text-field';
|
||||
|
||||
import { cn } from '../lib/cn';
|
||||
|
||||
|
|
@ -21,15 +21,17 @@ interface TextFieldProps extends ParentProps {
|
|||
|
||||
export function TextField(props: TextFieldProps) {
|
||||
return (
|
||||
<KTextField.Root
|
||||
<TextFieldPrimitive.Root
|
||||
class={cn('ui-field', props.class)}
|
||||
validationState={props.errorMessage ? 'invalid' : 'valid'}
|
||||
>
|
||||
<KTextField.Label class="ui-field__label">{props.label}</KTextField.Label>
|
||||
<TextFieldPrimitive.Label class="ui-field__label">
|
||||
{props.label}
|
||||
</TextFieldPrimitive.Label>
|
||||
<div class="ui-field__control-row">
|
||||
<div class="ui-field__control-fill">
|
||||
{props.multiline ? (
|
||||
<KTextField.TextArea
|
||||
<TextFieldPrimitive.TextArea
|
||||
class="ui-textarea"
|
||||
onInput={
|
||||
props.onInput as JSX.EventHandlerUnion<
|
||||
|
|
@ -41,7 +43,7 @@ export function TextField(props: TextFieldProps) {
|
|||
value={props.value}
|
||||
/>
|
||||
) : (
|
||||
<KTextField.Input
|
||||
<TextFieldPrimitive.Input
|
||||
class="ui-input"
|
||||
onInput={
|
||||
props.onInput as JSX.EventHandlerUnion<
|
||||
|
|
@ -58,15 +60,15 @@ export function TextField(props: TextFieldProps) {
|
|||
{props.children}
|
||||
</div>
|
||||
{props.description && (
|
||||
<KTextField.Description class="ui-field__description">
|
||||
<TextFieldPrimitive.Description class="ui-field__description">
|
||||
{props.description}
|
||||
</KTextField.Description>
|
||||
</TextFieldPrimitive.Description>
|
||||
)}
|
||||
{props.errorMessage && (
|
||||
<KTextField.ErrorMessage class="ui-field__error">
|
||||
<TextFieldPrimitive.ErrorMessage class="ui-field__error">
|
||||
{props.errorMessage}
|
||||
</KTextField.ErrorMessage>
|
||||
</TextFieldPrimitive.ErrorMessage>
|
||||
)}
|
||||
</KTextField.Root>
|
||||
</TextFieldPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import * as KToast from '@kobalte/core/toast';
|
||||
import * as ToastPrimitive from '@kobalte/core/toast';
|
||||
|
||||
import { cn } from '../lib/cn';
|
||||
|
||||
|
|
@ -8,49 +8,49 @@ type WrapperProps = ParentProps<{ class?: string; [key: string]: unknown }>;
|
|||
|
||||
export const Toast = {
|
||||
Region: (props: WrapperProps) => (
|
||||
<KToast.Region
|
||||
{...(props as KToast.ToastRegionProps)}
|
||||
<ToastPrimitive.Region
|
||||
{...(props as ToastPrimitive.ToastRegionProps)}
|
||||
class={cn('ui-toast-region', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KToast.Region>
|
||||
</ToastPrimitive.Region>
|
||||
),
|
||||
List: (props: WrapperProps) => (
|
||||
<KToast.List
|
||||
{...(props as KToast.ToastListProps)}
|
||||
<ToastPrimitive.List
|
||||
{...(props as ToastPrimitive.ToastListProps)}
|
||||
class={cn('ui-toast-list', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KToast.List>
|
||||
</ToastPrimitive.List>
|
||||
),
|
||||
Root: (props: WrapperProps) => (
|
||||
<KToast.Root
|
||||
{...(props as unknown as KToast.ToastRootProps)}
|
||||
<ToastPrimitive.Root
|
||||
{...(props as unknown as ToastPrimitive.ToastRootProps)}
|
||||
class={cn('ui-toast', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KToast.Root>
|
||||
</ToastPrimitive.Root>
|
||||
),
|
||||
Title: (props: WrapperProps) => (
|
||||
<KToast.Title {...(props as KToast.ToastTitleProps)}>
|
||||
<ToastPrimitive.Title {...(props as ToastPrimitive.ToastTitleProps)}>
|
||||
{props.children}
|
||||
</KToast.Title>
|
||||
</ToastPrimitive.Title>
|
||||
),
|
||||
Description: (props: WrapperProps) => (
|
||||
<KToast.Description
|
||||
{...(props as KToast.ToastDescriptionProps)}
|
||||
<ToastPrimitive.Description
|
||||
{...(props as ToastPrimitive.ToastDescriptionProps)}
|
||||
class={cn('ui-subtitle', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KToast.Description>
|
||||
</ToastPrimitive.Description>
|
||||
),
|
||||
CloseButton: (props: WrapperProps) => (
|
||||
<KToast.CloseButton
|
||||
{...(props as KToast.ToastCloseButtonProps)}
|
||||
<ToastPrimitive.CloseButton
|
||||
{...(props as ToastPrimitive.ToastCloseButtonProps)}
|
||||
class={cn('ui-button', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KToast.CloseButton>
|
||||
</ToastPrimitive.CloseButton>
|
||||
),
|
||||
toaster: KToast.toaster,
|
||||
toaster: ToastPrimitive.toaster,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import * as KTooltip from '@kobalte/core/tooltip';
|
||||
import * as TooltipPrimitive from '@kobalte/core/tooltip';
|
||||
|
||||
import { cn } from '../lib/cn';
|
||||
|
||||
|
|
@ -8,30 +8,35 @@ type WrapperProps = ParentProps<{ class?: string; [key: string]: unknown }>;
|
|||
|
||||
export const Tooltip = {
|
||||
Root: (props: WrapperProps) => (
|
||||
<KTooltip.Root openDelay={150} {...(props as KTooltip.TooltipRootProps)}>
|
||||
<TooltipPrimitive.Root
|
||||
openDelay={150}
|
||||
{...(props as TooltipPrimitive.TooltipRootProps)}
|
||||
>
|
||||
{props.children}
|
||||
</KTooltip.Root>
|
||||
</TooltipPrimitive.Root>
|
||||
),
|
||||
Trigger: (props: WrapperProps) => (
|
||||
<KTooltip.Trigger
|
||||
{...(props as KTooltip.TooltipTriggerProps)}
|
||||
<TooltipPrimitive.Trigger
|
||||
{...(props as TooltipPrimitive.TooltipTriggerProps)}
|
||||
class={props.class}
|
||||
>
|
||||
{props.children}
|
||||
</KTooltip.Trigger>
|
||||
</TooltipPrimitive.Trigger>
|
||||
),
|
||||
Portal: (props: WrapperProps) => (
|
||||
<KTooltip.Portal>{props.children}</KTooltip.Portal>
|
||||
<TooltipPrimitive.Portal>{props.children}</TooltipPrimitive.Portal>
|
||||
),
|
||||
Content: (props: WrapperProps) => (
|
||||
<KTooltip.Content
|
||||
{...(props as KTooltip.TooltipContentProps)}
|
||||
<TooltipPrimitive.Content
|
||||
{...(props as TooltipPrimitive.TooltipContentProps)}
|
||||
class={cn('ui-tooltip__content', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KTooltip.Content>
|
||||
</TooltipPrimitive.Content>
|
||||
),
|
||||
Arrow: (props: WrapperProps) => (
|
||||
<KTooltip.Arrow {...(props as KTooltip.TooltipArrowProps)} />
|
||||
<TooltipPrimitive.Arrow
|
||||
{...(props as TooltipPrimitive.TooltipArrowProps)}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,32 @@
|
|||
@import 'tailwindcss';
|
||||
|
||||
@import './styles/tokens.css';
|
||||
@import './styles/base.css';
|
||||
@import './styles/primitives.css';
|
||||
@import './styles/patterns.css';
|
||||
@import './styles/layout.css';
|
||||
@import './styles/pages.css';
|
||||
|
||||
/*
|
||||
* Tailwind v4 theme bridging — expose existing CSS custom properties as theme tokens
|
||||
* so utility classes (e.g. `text-(--color-text)`, `bg-(--color-surface)`) and the
|
||||
* `theme()` function resolve to the same design tokens that the legacy stylesheets use.
|
||||
*/
|
||||
@theme inline {
|
||||
--color-bg: var(--color-bg);
|
||||
--color-surface: var(--color-surface);
|
||||
--color-text: var(--color-text);
|
||||
--color-text-muted: var(--color-text-muted);
|
||||
--color-accent: var(--color-accent);
|
||||
--color-success: var(--color-success);
|
||||
--color-warning: var(--color-warning);
|
||||
--color-danger: var(--color-danger);
|
||||
--color-border: var(--color-border);
|
||||
|
||||
--radius-1: var(--radius-1);
|
||||
--radius-2: var(--radius-2);
|
||||
--radius-3: var(--radius-3);
|
||||
|
||||
--font-sans: var(--font-sans, ui-sans-serif, system-ui, sans-serif);
|
||||
--font-mono: var(--font-mono, ui-monospace, monospace);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import solidPlugin from 'vite-plugin-solid';
|
||||
|
||||
|
|
@ -5,6 +6,7 @@ export default defineConfig({
|
|||
base: '/dashboard/',
|
||||
plugins: [
|
||||
solidPlugin(),
|
||||
tailwindcss(),
|
||||
{
|
||||
name: 'dashboard-trailing-slash-redirect',
|
||||
configureServer(server) {
|
||||
|
|
|
|||
644
pnpm-lock.yaml
generated
644
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,5 @@
|
|||
packages:
|
||||
- shared
|
||||
- server
|
||||
- client
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@
|
|||
"@hono/node-server": "^1.13.7",
|
||||
"@hono/swagger-ui": "^0.5.0",
|
||||
"@hono/zod-openapi": "^0.18.0",
|
||||
"@hono/zod-validator": "^0.4.2",
|
||||
"@kyush/shared": "workspace:*",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"es-toolkit": "^1.32.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"hono": "^4.6.14",
|
||||
"isolated-vm": "^6.0.2",
|
||||
|
|
@ -29,6 +32,7 @@
|
|||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@total-typescript/ts-reset": "^0.6.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^25.3.3",
|
||||
"chalk": "^5.6.0",
|
||||
|
|
|
|||
|
|
@ -1,90 +1,68 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { env } from './env.js';
|
||||
|
||||
import type { AdminAuthMode } from '../../../shared/types.js';
|
||||
|
||||
function normalizeAuthMode(value?: string): AdminAuthMode {
|
||||
if (value === 'env' || value === 'oidc' || value === 'both') {
|
||||
return value;
|
||||
}
|
||||
return 'both';
|
||||
}
|
||||
|
||||
function parseList(value?: string): string[] {
|
||||
return (value ?? '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function getAdminAuthMode(): AdminAuthMode {
|
||||
return normalizeAuthMode(process.env.ADMIN_AUTH_MODE);
|
||||
return env.ADMIN_AUTH_MODE;
|
||||
}
|
||||
|
||||
export function isEnvAdminEnabled(): boolean {
|
||||
const mode = getAdminAuthMode();
|
||||
const mode = env.ADMIN_AUTH_MODE;
|
||||
return mode === 'env' || mode === 'both';
|
||||
}
|
||||
|
||||
export function isOidcEnabled(): boolean {
|
||||
const mode = getAdminAuthMode();
|
||||
const mode = env.ADMIN_AUTH_MODE;
|
||||
return mode === 'oidc' || mode === 'both';
|
||||
}
|
||||
|
||||
export function getAdminUsername(): string | null {
|
||||
return process.env.ADMIN_USERNAME?.trim() || null;
|
||||
return env.ADMIN_USERNAME;
|
||||
}
|
||||
|
||||
export function getAdminPasswordHash(): string | null {
|
||||
return process.env.ADMIN_PASSWORD_HASH?.trim() || null;
|
||||
return env.ADMIN_PASSWORD_HASH;
|
||||
}
|
||||
|
||||
export function getAdminSessionSecret(): string {
|
||||
return (
|
||||
process.env.ADMIN_SESSION_SECRET?.trim() ||
|
||||
'development-admin-session-secret'
|
||||
);
|
||||
return env.ADMIN_SESSION_SECRET;
|
||||
}
|
||||
|
||||
export function getAdminSessionTtlHours(): number {
|
||||
const parsed = Number(process.env.ADMIN_SESSION_TTL_HOURS ?? 12);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 12;
|
||||
return env.ADMIN_SESSION_TTL_HOURS;
|
||||
}
|
||||
|
||||
export function getAdminApiTokenTtlDays(): number {
|
||||
const parsed = Number(process.env.ADMIN_API_TOKEN_TTL_DAYS ?? 30);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 30;
|
||||
return env.ADMIN_API_TOKEN_TTL_DAYS;
|
||||
}
|
||||
|
||||
export function getCookieSecure(): boolean {
|
||||
return (
|
||||
process.env.NODE_ENV === 'production' &&
|
||||
process.env.ADMIN_COOKIE_SECURE !== 'false'
|
||||
);
|
||||
return env.ADMIN_COOKIE_SECURE;
|
||||
}
|
||||
|
||||
export function getAllowedOidcEmails(): string[] {
|
||||
return parseList(process.env.OIDC_ALLOWED_EMAILS).map((entry) =>
|
||||
entry.toLowerCase(),
|
||||
);
|
||||
return env.OIDC_ALLOWED_EMAILS;
|
||||
}
|
||||
|
||||
export function getTrustedProxyIps(): string[] {
|
||||
return parseList(process.env.ADMIN_TRUSTED_PROXY_IPS);
|
||||
return env.ADMIN_TRUSTED_PROXY_IPS;
|
||||
}
|
||||
|
||||
export function getOidcConfig() {
|
||||
return {
|
||||
issuerUrl: process.env.OIDC_ISSUER_URL?.trim() || '',
|
||||
clientId: process.env.OIDC_CLIENT_ID?.trim() || '',
|
||||
clientSecret: process.env.OIDC_CLIENT_SECRET?.trim() || '',
|
||||
redirectUri: process.env.OIDC_REDIRECT_URI?.trim() || '',
|
||||
scopes: process.env.OIDC_SCOPES?.trim() || 'openid profile email',
|
||||
issuerUrl: env.OIDC_ISSUER_URL,
|
||||
clientId: env.OIDC_CLIENT_ID,
|
||||
clientSecret: env.OIDC_CLIENT_SECRET,
|
||||
redirectUri: env.OIDC_REDIRECT_URI,
|
||||
scopes: env.OIDC_SCOPES,
|
||||
};
|
||||
}
|
||||
|
||||
export function hashOpaqueToken(token: string): string {
|
||||
return createHash('sha256')
|
||||
.update(getAdminSessionSecret())
|
||||
.update(env.ADMIN_SESSION_SECRET)
|
||||
.update(':')
|
||||
.update(token)
|
||||
.digest('hex');
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { ensureDir, getAnalyticsDbPath } from './db-paths.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const moduleDir = import.meta.dirname;
|
||||
|
||||
let db: Database.Database;
|
||||
let db: Database.Database | undefined;
|
||||
|
||||
function loadSchema(database: Database.Database): void {
|
||||
const schemaPath = path.join(
|
||||
__dirname,
|
||||
moduleDir,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
|
|
@ -23,36 +22,27 @@ function loadSchema(database: Database.Database): void {
|
|||
database.exec(schema);
|
||||
}
|
||||
|
||||
export function getAnalyticsDb(): Database.Database {
|
||||
if (!db) {
|
||||
const analyticsDbPath = getAnalyticsDbPath();
|
||||
ensureDir(path.dirname(analyticsDbPath));
|
||||
function openDb(): Database.Database {
|
||||
const analyticsDbPath = getAnalyticsDbPath();
|
||||
ensureDir(path.dirname(analyticsDbPath));
|
||||
const handle = new Database(analyticsDbPath);
|
||||
handle.pragma('foreign_keys = ON');
|
||||
loadSchema(handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
db = new Database(analyticsDbPath);
|
||||
db.pragma('foreign_keys = ON');
|
||||
loadSchema(db);
|
||||
}
|
||||
export function getAnalyticsDb(): Database.Database {
|
||||
db ??= openDb();
|
||||
return db;
|
||||
}
|
||||
|
||||
export function initAnalyticsDb(): Database.Database {
|
||||
if (db) {
|
||||
db.close();
|
||||
}
|
||||
|
||||
const analyticsDbPath = getAnalyticsDbPath();
|
||||
ensureDir(path.dirname(analyticsDbPath));
|
||||
|
||||
db = new Database(analyticsDbPath);
|
||||
db.pragma('foreign_keys = ON');
|
||||
loadSchema(db);
|
||||
|
||||
db?.close();
|
||||
db = openDb();
|
||||
return db;
|
||||
}
|
||||
|
||||
export function closeAnalyticsDb(): void {
|
||||
if (db) {
|
||||
db.close();
|
||||
db = undefined as unknown as Database.Database;
|
||||
}
|
||||
db?.close();
|
||||
db = undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,35 @@
|
|||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { ensureDir, getCoreDbPath } from './db-paths.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// Node 20.11+ exposes import.meta.dirname directly — no fileURLToPath needed.
|
||||
const moduleDir = import.meta.dirname;
|
||||
|
||||
let db: Database.Database;
|
||||
// Lazy singleton — `getDb()` instantiates on first access, `closeDb()` resets
|
||||
// it back to `undefined` so the next `getDb()` reopens a fresh handle.
|
||||
let db: Database.Database | undefined;
|
||||
|
||||
function isPragmaColumnRow(value: unknown): value is { name: string } {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'name' in value &&
|
||||
typeof (value as { name: unknown }).name === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function hasColumn(
|
||||
database: Database.Database,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
): boolean {
|
||||
const columns = database
|
||||
.prepare(`PRAGMA table_info(${tableName})`)
|
||||
.all() as Array<{ name: string }>;
|
||||
return columns.some((column) => column.name === columnName);
|
||||
const columns = database.prepare(`PRAGMA table_info(${tableName})`).all();
|
||||
return columns.some(
|
||||
(column) => isPragmaColumnRow(column) && column.name === columnName,
|
||||
);
|
||||
}
|
||||
|
||||
function runCoreMigrations(database: Database.Database): void {
|
||||
|
|
@ -31,7 +42,7 @@ function runCoreMigrations(database: Database.Database): void {
|
|||
|
||||
function loadSchema(database: Database.Database): void {
|
||||
const schemaPath = path.join(
|
||||
__dirname,
|
||||
moduleDir,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
|
|
@ -42,40 +53,29 @@ function loadSchema(database: Database.Database): void {
|
|||
database.exec(schema);
|
||||
}
|
||||
|
||||
function openDb(): Database.Database {
|
||||
const coreDbPath = getCoreDbPath();
|
||||
ensureDir(path.dirname(coreDbPath));
|
||||
|
||||
const handle = new Database(coreDbPath);
|
||||
handle.pragma('foreign_keys = ON');
|
||||
loadSchema(handle);
|
||||
runCoreMigrations(handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (!db) {
|
||||
const coreDbPath = getCoreDbPath();
|
||||
ensureDir(path.dirname(coreDbPath));
|
||||
|
||||
db = new Database(coreDbPath);
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
loadSchema(db);
|
||||
runCoreMigrations(db);
|
||||
}
|
||||
db ??= openDb();
|
||||
return db;
|
||||
}
|
||||
|
||||
export function initDb(): Database.Database {
|
||||
if (db) {
|
||||
db.close();
|
||||
}
|
||||
|
||||
const coreDbPath = getCoreDbPath();
|
||||
ensureDir(path.dirname(coreDbPath));
|
||||
|
||||
db = new Database(coreDbPath);
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
loadSchema(db);
|
||||
runCoreMigrations(db);
|
||||
|
||||
db?.close();
|
||||
db = openDb();
|
||||
return db;
|
||||
}
|
||||
|
||||
export function closeDb(): void {
|
||||
if (db) {
|
||||
db.close();
|
||||
db = undefined as unknown as Database.Database;
|
||||
}
|
||||
db?.close();
|
||||
db = undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const DEFAULT_DB_DIR = path.join(process.cwd(), 'data');
|
||||
import { env } from './env.js';
|
||||
|
||||
export function getDbRootDir(): string {
|
||||
return process.env.DB_DIR || process.env.DB_PATH || DEFAULT_DB_DIR;
|
||||
return env.DB_DIR;
|
||||
}
|
||||
|
||||
export function ensureDir(dirPath: string): void {
|
||||
|
|
|
|||
165
server/src/config/env.ts
Normal file
165
server/src/config/env.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
/**
|
||||
* Single source of truth for every environment variable the server reads.
|
||||
*
|
||||
* - All `process.env` access is centralised here.
|
||||
* - Each value is parsed/validated/normalised once at module load and exposed
|
||||
* via `env`, an immutable object.
|
||||
* - Importers should grab a typed value (`env.SERVER_PORT`) instead of touching
|
||||
* `process.env` directly. This makes mistakes loud (typos surface as TS
|
||||
* errors) and concentrates default values in one place.
|
||||
*
|
||||
* Tests can mutate the underlying `process.env` before importing this module
|
||||
* (see tests/setup.ts) — values that may legitimately change at runtime are
|
||||
* exposed as functions instead of frozen primitives.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
|
||||
import type { AdminAuthMode } from '../../../shared/types.js';
|
||||
|
||||
const DEFAULT_DB_DIR = path.join(process.cwd(), 'data');
|
||||
const DEFAULT_TIME_ZONE = 'UTC';
|
||||
const DEFAULT_REFRESH_MIN_MS = 5 * 60 * 1000;
|
||||
const DEFAULT_CORS_ORIGINS = [
|
||||
'http://localhost:3000',
|
||||
'http://127.0.0.1:3000',
|
||||
'http://localhost:3002',
|
||||
'http://127.0.0.1:3002',
|
||||
];
|
||||
|
||||
function trimmed(value: string | undefined): string | undefined {
|
||||
const result = value?.trim();
|
||||
return result && result.length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function parseList(value: string | undefined): string[] {
|
||||
return (value ?? '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
|
||||
function parsePositiveNumber(
|
||||
value: string | undefined,
|
||||
fallback: number,
|
||||
): number {
|
||||
if (!value) return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function parseNonNegativeNumber(
|
||||
value: string | undefined,
|
||||
fallback: number,
|
||||
): number {
|
||||
if (!value) return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function normalizeAuthMode(value: string | undefined): AdminAuthMode {
|
||||
return value === 'env' || value === 'oidc' || value === 'both'
|
||||
? value
|
||||
: 'both';
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* Eagerly-resolved values (read once at boot)
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const env = {
|
||||
// Server runtime
|
||||
get SERVER_PORT(): number {
|
||||
return parsePositiveNumber(process.env.SERVER_PORT, 3000);
|
||||
},
|
||||
get NODE_ENV(): string | undefined {
|
||||
return process.env.NODE_ENV;
|
||||
},
|
||||
get IS_PRODUCTION(): boolean {
|
||||
return process.env.NODE_ENV === 'production';
|
||||
},
|
||||
|
||||
// Storage paths
|
||||
get DB_DIR(): string {
|
||||
return (
|
||||
trimmed(process.env.DB_DIR) ??
|
||||
trimmed(process.env.DB_PATH) ??
|
||||
DEFAULT_DB_DIR
|
||||
);
|
||||
},
|
||||
|
||||
// Time zone (used for daily/monthly bucket math)
|
||||
get TIME_ZONE(): string {
|
||||
return trimmed(process.env.TZ) ?? DEFAULT_TIME_ZONE;
|
||||
},
|
||||
|
||||
// CORS
|
||||
get CORS_ORIGINS(): string[] {
|
||||
const raw = trimmed(process.env.CORS_ORIGINS);
|
||||
return raw
|
||||
? raw.split(',').map((origin) => origin.trim())
|
||||
: DEFAULT_CORS_ORIGINS;
|
||||
},
|
||||
|
||||
// Admin auth mode + ENV credentials
|
||||
get ADMIN_AUTH_MODE(): AdminAuthMode {
|
||||
return normalizeAuthMode(process.env.ADMIN_AUTH_MODE);
|
||||
},
|
||||
get ADMIN_USERNAME(): string | null {
|
||||
return trimmed(process.env.ADMIN_USERNAME) ?? null;
|
||||
},
|
||||
get ADMIN_PASSWORD_HASH(): string | null {
|
||||
return trimmed(process.env.ADMIN_PASSWORD_HASH) ?? null;
|
||||
},
|
||||
get ADMIN_SESSION_SECRET(): string {
|
||||
return (
|
||||
trimmed(process.env.ADMIN_SESSION_SECRET) ??
|
||||
'development-admin-session-secret'
|
||||
);
|
||||
},
|
||||
get ADMIN_SESSION_TTL_HOURS(): number {
|
||||
return parsePositiveNumber(process.env.ADMIN_SESSION_TTL_HOURS, 12);
|
||||
},
|
||||
get ADMIN_API_TOKEN_TTL_DAYS(): number {
|
||||
return parsePositiveNumber(process.env.ADMIN_API_TOKEN_TTL_DAYS, 30);
|
||||
},
|
||||
get ADMIN_COOKIE_SECURE(): boolean {
|
||||
return (
|
||||
process.env.NODE_ENV === 'production' &&
|
||||
process.env.ADMIN_COOKIE_SECURE !== 'false'
|
||||
);
|
||||
},
|
||||
get ADMIN_TRUSTED_PROXY_IPS(): string[] {
|
||||
return parseList(process.env.ADMIN_TRUSTED_PROXY_IPS);
|
||||
},
|
||||
|
||||
// OIDC
|
||||
get OIDC_ISSUER_URL(): string {
|
||||
return trimmed(process.env.OIDC_ISSUER_URL) ?? '';
|
||||
},
|
||||
get OIDC_CLIENT_ID(): string {
|
||||
return trimmed(process.env.OIDC_CLIENT_ID) ?? '';
|
||||
},
|
||||
get OIDC_CLIENT_SECRET(): string {
|
||||
return trimmed(process.env.OIDC_CLIENT_SECRET) ?? '';
|
||||
},
|
||||
get OIDC_REDIRECT_URI(): string {
|
||||
return trimmed(process.env.OIDC_REDIRECT_URI) ?? '';
|
||||
},
|
||||
get OIDC_SCOPES(): string {
|
||||
return trimmed(process.env.OIDC_SCOPES) ?? 'openid profile email';
|
||||
},
|
||||
get OIDC_ALLOWED_EMAILS(): string[] {
|
||||
return parseList(process.env.OIDC_ALLOWED_EMAILS).map((entry) =>
|
||||
entry.toLowerCase(),
|
||||
);
|
||||
},
|
||||
|
||||
// Catalog refresh
|
||||
get MODEL_CATALOG_REFRESH_MIN_MS(): number {
|
||||
return parseNonNegativeNumber(
|
||||
process.env.MODEL_CATALOG_REFRESH_MIN_MS,
|
||||
DEFAULT_REFRESH_MIN_MS,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
|
|
@ -12,24 +11,33 @@ import {
|
|||
|
||||
import { getLocalMonthKey } from '../utils/time.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const moduleDir = import.meta.dirname;
|
||||
|
||||
const connections = new Map<string, Database.Database>();
|
||||
|
||||
function isPragmaColumnRow(value: unknown): value is { name: string } {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'name' in value &&
|
||||
typeof (value as { name: unknown }).name === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function hasColumn(
|
||||
database: Database.Database,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
): boolean {
|
||||
const columns = database
|
||||
.prepare(`PRAGMA table_info(${tableName})`)
|
||||
.all() as Array<{ name: string }>;
|
||||
return columns.some((column) => column.name === columnName);
|
||||
const columns = database.prepare(`PRAGMA table_info(${tableName})`).all();
|
||||
return columns.some(
|
||||
(column) => isPragmaColumnRow(column) && column.name === columnName,
|
||||
);
|
||||
}
|
||||
|
||||
function initRequestLogsSchema(db: Database.Database): void {
|
||||
const schemaPath = path.join(
|
||||
__dirname,
|
||||
moduleDir,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
|
|
@ -38,7 +46,7 @@ function initRequestLogsSchema(db: Database.Database): void {
|
|||
);
|
||||
const schema = fs.readFileSync(schemaPath, 'utf-8');
|
||||
db.exec(schema);
|
||||
if (hasColumn(db, 'request_logs', 'routed_model') === false) {
|
||||
if (!hasColumn(db, 'request_logs', 'routed_model')) {
|
||||
db.exec('ALTER TABLE request_logs ADD COLUMN routed_model TEXT');
|
||||
}
|
||||
}
|
||||
|
|
@ -47,9 +55,7 @@ export function getRequestLogsDb(
|
|||
monthKey: string = getLocalMonthKey(),
|
||||
): Database.Database {
|
||||
const existing = connections.get(monthKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
if (existing) return existing;
|
||||
|
||||
const dbPath = getRequestLogsDbPath(monthKey);
|
||||
ensureDir(path.dirname(dbPath));
|
||||
|
|
@ -63,23 +69,21 @@ export function getRequestLogsDb(
|
|||
export function initRequestLogsDb(
|
||||
monthKey: string = getLocalMonthKey(),
|
||||
): Database.Database {
|
||||
const existing = connections.get(monthKey);
|
||||
if (existing) {
|
||||
existing.close();
|
||||
connections.delete(monthKey);
|
||||
}
|
||||
|
||||
connections.get(monthKey)?.close();
|
||||
connections.delete(monthKey);
|
||||
return getRequestLogsDb(monthKey);
|
||||
}
|
||||
|
||||
const REQUEST_LOG_FILENAME_PATTERN = /^request_logs_(\d{4}-\d{2})\.db$/;
|
||||
|
||||
export function listRequestLogMonths(): string[] {
|
||||
const requestLogsDir = getRequestLogsDir();
|
||||
ensureDir(requestLogsDir);
|
||||
|
||||
return fs
|
||||
.readdirSync(requestLogsDir)
|
||||
.map((entry) => /^request_logs_(\d{4}-\d{2})\.db$/.exec(entry)?.[1])
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((entry) => REQUEST_LOG_FILENAME_PATTERN.exec(entry)?.[1])
|
||||
.filter((value): value is string => value !== undefined)
|
||||
.sort((a, b) => b.localeCompare(a));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
import { OpenAPIHono } from '@hono/zod-openapi';
|
||||
import { swaggerUI } from '@hono/swagger-ui';
|
||||
|
|
@ -9,6 +8,7 @@ import { bodyLimit } from 'hono/body-limit';
|
|||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
import { env } from './config/env.js';
|
||||
import adminRoutes from './routes/admin.js';
|
||||
import adminAuthRoutes from './routes/admin-auth.js';
|
||||
import apiRoutes from './routes/api.js';
|
||||
|
|
@ -19,11 +19,11 @@ import { ModelCatalogService } from './services/ModelCatalogService.js';
|
|||
|
||||
import type { AppEnv } from './types/hono.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const moduleDir = import.meta.dirname;
|
||||
|
||||
const envPathCandidates = [
|
||||
path.resolve(__dirname, '..', '..', '.env'),
|
||||
path.resolve(__dirname, '..', '..', '..', '..', '.env'),
|
||||
path.resolve(moduleDir, '..', '..', '.env'),
|
||||
path.resolve(moduleDir, '..', '..', '..', '..', '.env'),
|
||||
path.resolve(process.cwd(), '.env'),
|
||||
path.resolve(process.cwd(), '..', '.env'),
|
||||
];
|
||||
|
|
@ -43,26 +43,17 @@ export function createApp(): OpenAPIHono<AppEnv> {
|
|||
const app = new OpenAPIHono<AppEnv>();
|
||||
|
||||
const adminDistCandidates = [
|
||||
path.resolve(__dirname, '..', '..', '..', 'client', 'dist'),
|
||||
path.resolve(__dirname, '..', '..', '..', '..', 'client', 'dist'),
|
||||
path.resolve(moduleDir, '..', '..', '..', 'client', 'dist'),
|
||||
path.resolve(moduleDir, '..', '..', '..', '..', 'client', 'dist'),
|
||||
];
|
||||
const adminDistPath = adminDistCandidates.find((candidate) =>
|
||||
fs.existsSync(candidate),
|
||||
);
|
||||
|
||||
const corsOrigins = process.env.CORS_ORIGINS
|
||||
? process.env.CORS_ORIGINS.split(',').map((origin) => origin.trim())
|
||||
: [
|
||||
'http://localhost:3000',
|
||||
'http://127.0.0.1:3000',
|
||||
'http://localhost:3002',
|
||||
'http://127.0.0.1:3002',
|
||||
];
|
||||
|
||||
app.use(
|
||||
'*',
|
||||
cors({
|
||||
origin: corsOrigins,
|
||||
origin: env.CORS_ORIGINS,
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,20 +1,19 @@
|
|||
import { serve } from '@hono/node-server';
|
||||
|
||||
import { env } from './config/env.js';
|
||||
import app from './index.js';
|
||||
import { logger } from './utils/logger.js';
|
||||
|
||||
const PORT = Number(process.env.SERVER_PORT) || 3000;
|
||||
|
||||
serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
port: PORT,
|
||||
port: env.SERVER_PORT,
|
||||
},
|
||||
() => {
|
||||
logger.info(`Server running on port ${PORT}`);
|
||||
logger.info(`Admin API: http://localhost:${PORT}/admin`);
|
||||
logger.info(`Admin UI: http://localhost:${PORT}/dashboard`);
|
||||
logger.info(`OpenAI API: http://localhost:${PORT}/v1`);
|
||||
logger.info(`API Docs: http://localhost:${PORT}/admin/docs`);
|
||||
logger.info(`Server running on port ${env.SERVER_PORT}`);
|
||||
logger.info(`Admin API: http://localhost:${env.SERVER_PORT}/admin`);
|
||||
logger.info(`Admin UI: http://localhost:${env.SERVER_PORT}/dashboard`);
|
||||
logger.info(`OpenAI API: http://localhost:${env.SERVER_PORT}/v1`);
|
||||
logger.info(`API Docs: http://localhost:${env.SERVER_PORT}/admin/docs`);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
3
server/src/reset.d.ts
vendored
Normal file
3
server/src/reset.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Activate ts-reset's improved built-in types globally for the server.
|
||||
// See https://www.totaltypescript.com/ts-reset
|
||||
import '@total-typescript/ts-reset';
|
||||
|
|
@ -1,5 +1,11 @@
|
|||
import { zValidator } from '@hono/zod-validator';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import {
|
||||
AdminLoginInputSchema,
|
||||
CreateAdminTokenInputSchema,
|
||||
} from '@kyush/shared';
|
||||
|
||||
import { AdminApiTokenModel } from '../models/AdminApiToken.js';
|
||||
import { AdminSessionModel } from '../models/AdminSession.js';
|
||||
import {
|
||||
|
|
@ -97,13 +103,12 @@ router.get('/session', (c) => {
|
|||
return c.json(buildSessionResponse(adminAuth));
|
||||
});
|
||||
|
||||
router.post('/login', async (c) => {
|
||||
router.post('/login', zValidator('json', AdminLoginInputSchema), (c) => {
|
||||
if (!isEnvAdminEnabled()) {
|
||||
return c.json({ error: 'ENV admin login is disabled' }, 404);
|
||||
}
|
||||
|
||||
const body = await c.req.json();
|
||||
const { username, password } = body;
|
||||
const { username, password } = c.req.valid('json');
|
||||
const configuredUsername = getAdminUsername();
|
||||
|
||||
if (!configuredUsername || !username || !password) {
|
||||
|
|
@ -311,32 +316,29 @@ router.get('/tokens', requireAdminAccess, (c) => {
|
|||
return c.json(AdminApiTokenModel.listBySubject(adminAuth.principal.subject));
|
||||
});
|
||||
|
||||
router.post('/tokens', requireAdminAccess, requireSessionCsrf, async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { name, expiresInDays } = body;
|
||||
const trimmedName = name?.trim();
|
||||
if (!trimmedName) {
|
||||
return c.json({ error: 'Token name is required' }, 400);
|
||||
}
|
||||
router.post(
|
||||
'/tokens',
|
||||
requireAdminAccess,
|
||||
requireSessionCsrf,
|
||||
zValidator('json', CreateAdminTokenInputSchema),
|
||||
(c) => {
|
||||
const { name: trimmedName, expiresInDays } = c.req.valid('json');
|
||||
const ttlDays = expiresInDays ?? getAdminApiTokenTtlDays();
|
||||
const token = generateOpaqueToken('adm_tok');
|
||||
const adminAuth = c.get('adminAuth')!;
|
||||
const record = AdminApiTokenModel.create({
|
||||
tokenHash: hashAdminToken(token),
|
||||
tokenPrefix: tokenPrefix(token),
|
||||
name: trimmedName,
|
||||
principal: adminAuth.principal,
|
||||
expiresAt: new Date(
|
||||
Date.now() + ttlDays * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
});
|
||||
|
||||
const ttlDays =
|
||||
Number.isFinite(expiresInDays) && Number(expiresInDays) > 0
|
||||
? Number(expiresInDays)
|
||||
: getAdminApiTokenTtlDays();
|
||||
const token = generateOpaqueToken('adm_tok');
|
||||
const adminAuth = c.get('adminAuth')!;
|
||||
const record = AdminApiTokenModel.create({
|
||||
tokenHash: hashAdminToken(token),
|
||||
tokenPrefix: tokenPrefix(token),
|
||||
name: trimmedName,
|
||||
principal: adminAuth.principal,
|
||||
expiresAt: new Date(
|
||||
Date.now() + ttlDays * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
});
|
||||
|
||||
return c.json({ token, record }, 201);
|
||||
});
|
||||
return c.json({ token, record }, 201);
|
||||
},
|
||||
);
|
||||
|
||||
router.delete('/tokens/:id', requireAdminAccess, requireSessionCsrf, (c) => {
|
||||
const tokenId = Number(c.req.param('id'));
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
import { zValidator } from '@hono/zod-validator';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import {
|
||||
CreateBackendInputSchema,
|
||||
CreateModelRewriteInputSchema,
|
||||
CreatePermissionInputSchema,
|
||||
CreateUserInputSchema,
|
||||
UpdateBackendInputSchema,
|
||||
UpdateModelRewriteInputSchema,
|
||||
UpdateUserInputSchema,
|
||||
} from '@kyush/shared';
|
||||
|
||||
import scriptRoutes from './scripts.js';
|
||||
|
||||
import { UserModel } from '../models/User.js';
|
||||
|
|
@ -11,16 +22,6 @@ import { getUtcTimestamp } from '../utils/time.js';
|
|||
import { ModelCatalogService } from '../services/ModelCatalogService.js';
|
||||
import { AnalyticsService } from '../services/AnalyticsService.js';
|
||||
|
||||
import type {
|
||||
CreateBackendData,
|
||||
CreateModelRewriteData,
|
||||
CreatePermissionData,
|
||||
CreateUserData,
|
||||
UpdateBackendData,
|
||||
UpdateModelRewriteData,
|
||||
UpdateUserData,
|
||||
} from '../../../shared/types.js';
|
||||
|
||||
import type { AppEnv } from '../types/hono.js';
|
||||
|
||||
const router = new Hono<AppEnv>();
|
||||
|
|
@ -38,21 +39,11 @@ router.get('/users', (c) => {
|
|||
return c.json(UserModel.findAll());
|
||||
});
|
||||
|
||||
router.post('/users', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { name, email, api_key, detail_logging } = body;
|
||||
|
||||
if (!name?.trim()) {
|
||||
return c.json({ error: 'Name is required' }, 400);
|
||||
}
|
||||
router.post('/users', zValidator('json', CreateUserInputSchema), (c) => {
|
||||
const data = c.req.valid('json');
|
||||
|
||||
try {
|
||||
const user = UserModel.create({
|
||||
name: name.trim(),
|
||||
email: email?.trim() || undefined,
|
||||
api_key: api_key?.trim() || undefined,
|
||||
detail_logging,
|
||||
});
|
||||
const user = UserModel.create(data);
|
||||
return c.json(user, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('UNIQUE')) {
|
||||
|
|
@ -71,7 +62,7 @@ router.get('/users/:id', (c) => {
|
|||
return c.json(user);
|
||||
});
|
||||
|
||||
router.put('/users/:id', async (c) => {
|
||||
router.put('/users/:id', zValidator('json', UpdateUserInputSchema), (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
const user = UserModel.findById(id);
|
||||
|
||||
|
|
@ -79,22 +70,8 @@ router.put('/users/:id', async (c) => {
|
|||
return c.json({ error: 'User not found' }, 404);
|
||||
}
|
||||
|
||||
const body = await c.req.json();
|
||||
const { name, email, api_key, is_active, detail_logging } = body;
|
||||
|
||||
if (typeof name === 'string' && !name.trim()) {
|
||||
return c.json({ error: 'Name cannot be empty' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedUser = UserModel.update(id, {
|
||||
name: typeof name === 'string' ? name.trim() : undefined,
|
||||
email: typeof email === 'string' ? email.trim() || undefined : undefined,
|
||||
api_key:
|
||||
typeof api_key === 'string' ? api_key.trim() || undefined : undefined,
|
||||
is_active,
|
||||
detail_logging,
|
||||
});
|
||||
const updatedUser = UserModel.update(id, c.req.valid('json'));
|
||||
return c.json(updatedUser);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('UNIQUE')) {
|
||||
|
|
@ -134,20 +111,8 @@ router.get('/backends', (c) => {
|
|||
return c.json(ModelCatalogService.getBackendsWithSummary());
|
||||
});
|
||||
|
||||
router.post('/backends', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { name, base_url, api_key, detail_logging } = body;
|
||||
|
||||
if (!name || !base_url) {
|
||||
return c.json({ error: 'Name and base_url are required' }, 400);
|
||||
}
|
||||
|
||||
const backend = BackendModel.create({
|
||||
name,
|
||||
base_url,
|
||||
api_key,
|
||||
detail_logging,
|
||||
});
|
||||
router.post('/backends', zValidator('json', CreateBackendInputSchema), (c) => {
|
||||
const backend = BackendModel.create(c.req.valid('json'));
|
||||
return c.json(backend, 201);
|
||||
});
|
||||
|
||||
|
|
@ -162,29 +127,25 @@ router.get('/backends/:id', (c) => {
|
|||
return c.json(backend);
|
||||
});
|
||||
|
||||
router.put('/backends/:id', async (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
const backend = BackendModel.findById(id);
|
||||
if (!backend) {
|
||||
return c.json({ error: 'Backend not found' }, 404);
|
||||
}
|
||||
router.put(
|
||||
'/backends/:id',
|
||||
zValidator('json', UpdateBackendInputSchema),
|
||||
async (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
const backend = BackendModel.findById(id);
|
||||
if (!backend) {
|
||||
return c.json({ error: 'Backend not found' }, 404);
|
||||
}
|
||||
|
||||
const body = await c.req.json();
|
||||
const { name, base_url, api_key, is_active, detail_logging } = body;
|
||||
const updatedBackend = BackendModel.update(id, {
|
||||
name,
|
||||
base_url,
|
||||
api_key,
|
||||
is_active,
|
||||
detail_logging,
|
||||
});
|
||||
await ModelCatalogService.handleBackendUpdated(id);
|
||||
return c.json(
|
||||
ModelCatalogService.getBackendsWithSummary().find(
|
||||
(item) => item.id === id,
|
||||
) || updatedBackend,
|
||||
);
|
||||
});
|
||||
const updatedBackend = BackendModel.update(id, c.req.valid('json'));
|
||||
await ModelCatalogService.handleBackendUpdated(id);
|
||||
return c.json(
|
||||
ModelCatalogService.getBackendsWithSummary().find(
|
||||
(item) => item.id === id,
|
||||
) || updatedBackend,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
router.delete('/backends/:id', async (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
|
|
@ -255,24 +216,21 @@ router.get('/permissions/backend/:backendId', (c) => {
|
|||
return c.json(PermissionModel.findByBackendId(backendId));
|
||||
});
|
||||
|
||||
router.post('/permissions', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { user_id, backend_id } = body;
|
||||
|
||||
if (!user_id || !backend_id) {
|
||||
return c.json({ error: 'user_id and backend_id are required' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const permission = PermissionModel.create({ user_id, backend_id });
|
||||
return c.json(permission, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('already exists')) {
|
||||
return c.json({ error: error.message }, 409);
|
||||
router.post(
|
||||
'/permissions',
|
||||
zValidator('json', CreatePermissionInputSchema),
|
||||
(c) => {
|
||||
try {
|
||||
const permission = PermissionModel.create(c.req.valid('json'));
|
||||
return c.json(permission, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('already exists')) {
|
||||
return c.json({ error: error.message }, 409);
|
||||
}
|
||||
return c.json({ error: 'Failed to create permission' }, 500);
|
||||
}
|
||||
return c.json({ error: 'Failed to create permission' }, 500);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.delete('/permissions', (c) => {
|
||||
const user_id = c.req.query('user_id');
|
||||
|
|
@ -293,57 +251,51 @@ router.get('/model-rewrites', (c) => {
|
|||
return c.json(ModelRewriteModel.findAll());
|
||||
});
|
||||
|
||||
router.post('/model-rewrites', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { source_model, target_model, is_active, force, note } = body;
|
||||
|
||||
if (!source_model?.trim() || !target_model?.trim()) {
|
||||
return c.json({ error: 'source_model and target_model are required' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const rule = ModelRewriteModel.create({
|
||||
source_model: source_model.trim(),
|
||||
target_model: target_model.trim(),
|
||||
is_active,
|
||||
force,
|
||||
note,
|
||||
});
|
||||
ModelCatalogService.loadRewriteMap();
|
||||
return c.json(rule, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('UNIQUE')) {
|
||||
return c.json(
|
||||
{ error: 'Rewrite rule already exists for this source_model' },
|
||||
409,
|
||||
);
|
||||
router.post(
|
||||
'/model-rewrites',
|
||||
zValidator('json', CreateModelRewriteInputSchema),
|
||||
(c) => {
|
||||
try {
|
||||
const rule = ModelRewriteModel.create(c.req.valid('json'));
|
||||
ModelCatalogService.loadRewriteMap();
|
||||
return c.json(rule, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('UNIQUE')) {
|
||||
return c.json(
|
||||
{ error: 'Rewrite rule already exists for this source_model' },
|
||||
409,
|
||||
);
|
||||
}
|
||||
return c.json({ error: 'Failed to create model rewrite rule' }, 500);
|
||||
}
|
||||
return c.json({ error: 'Failed to create model rewrite rule' }, 500);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.put('/model-rewrites/:id', async (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
const existing = ModelRewriteModel.findById(id);
|
||||
if (!existing) {
|
||||
return c.json({ error: 'Model rewrite rule not found' }, 404);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const updated = ModelRewriteModel.update(id, body);
|
||||
ModelCatalogService.loadRewriteMap();
|
||||
return c.json(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('UNIQUE')) {
|
||||
return c.json(
|
||||
{ error: 'Rewrite rule already exists for this source_model' },
|
||||
409,
|
||||
);
|
||||
router.put(
|
||||
'/model-rewrites/:id',
|
||||
zValidator('json', UpdateModelRewriteInputSchema),
|
||||
(c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
const existing = ModelRewriteModel.findById(id);
|
||||
if (!existing) {
|
||||
return c.json({ error: 'Model rewrite rule not found' }, 404);
|
||||
}
|
||||
return c.json({ error: 'Failed to update model rewrite rule' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const updated = ModelRewriteModel.update(id, c.req.valid('json'));
|
||||
ModelCatalogService.loadRewriteMap();
|
||||
return c.json(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('UNIQUE')) {
|
||||
return c.json(
|
||||
{ error: 'Rewrite rule already exists for this source_model' },
|
||||
409,
|
||||
);
|
||||
}
|
||||
return c.json({ error: 'Failed to update model rewrite rule' }, 500);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.delete('/model-rewrites/:id', (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
|
|
|
|||
|
|
@ -1,93 +1,45 @@
|
|||
import { zValidator } from '@hono/zod-validator';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import {
|
||||
CreateScriptInputSchema,
|
||||
ScriptTestInputSchema,
|
||||
UpdateScriptInputSchema,
|
||||
} from '@kyush/shared';
|
||||
|
||||
import { ScriptModel } from '../models/Script.js';
|
||||
import { CompiledScript } from '../services/ScriptExecutor.js';
|
||||
|
||||
import type {
|
||||
CreateScriptData,
|
||||
UpdateScriptData,
|
||||
ScriptContextData,
|
||||
} from '../../../shared/types.js';
|
||||
import type { ScriptContextData } from '../../../shared/types.js';
|
||||
import type { AppEnv } from '../types/hono.js';
|
||||
|
||||
const router = new Hono<AppEnv>();
|
||||
|
||||
// ============ Script Management ============
|
||||
|
||||
router.get('/', (c) => {
|
||||
return c.json(ScriptModel.findAll());
|
||||
});
|
||||
|
||||
router.get('/active', (c) => {
|
||||
return c.json(ScriptModel.findActive());
|
||||
});
|
||||
router.get('/', (c) => c.json(ScriptModel.findAll()));
|
||||
router.get('/active', (c) => c.json(ScriptModel.findActive()));
|
||||
|
||||
router.get('/type/:type', (c) => {
|
||||
const scriptType = String(c.req.param('type'));
|
||||
const scriptType = c.req.param('type');
|
||||
return c.json(ScriptModel.findByScriptType(scriptType));
|
||||
});
|
||||
|
||||
router.get('/:id', (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
const script = ScriptModel.findById(id);
|
||||
if (!script) {
|
||||
return c.json({ error: 'Script not found' }, 404);
|
||||
}
|
||||
if (!script) return c.json({ error: 'Script not found' }, 404);
|
||||
return c.json(script);
|
||||
});
|
||||
|
||||
router.post('/', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const {
|
||||
name,
|
||||
script_type,
|
||||
target_user_id,
|
||||
target_backend_id,
|
||||
script_code,
|
||||
is_active,
|
||||
} = body;
|
||||
|
||||
if (!name || !script_type || !script_code) {
|
||||
return c.json(
|
||||
{ error: 'name, script_type, and script_code are required' },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (script_type === 'per-user-backend') {
|
||||
if (!target_user_id || !target_backend_id) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'target_user_id and target_backend_id are required for per-user-backend scripts',
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
} else if (script_type === 'per-backend') {
|
||||
if (!target_backend_id) {
|
||||
return c.json(
|
||||
{ error: 'target_backend_id is required for per-backend scripts' },
|
||||
400,
|
||||
);
|
||||
}
|
||||
} else if (script_type === 'per-user') {
|
||||
if (!target_user_id) {
|
||||
return c.json(
|
||||
{ error: 'target_user_id is required for per-user scripts' },
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/', zValidator('json', CreateScriptInputSchema), (c) => {
|
||||
const data = c.req.valid('json');
|
||||
try {
|
||||
const script = ScriptModel.create({
|
||||
name,
|
||||
script_type,
|
||||
target_user_id: target_user_id ?? null,
|
||||
target_backend_id: target_backend_id ?? null,
|
||||
script_code,
|
||||
is_active: is_active ?? true,
|
||||
name: data.name,
|
||||
script_type: data.script_type,
|
||||
target_user_id: data.target_user_id ?? null,
|
||||
target_backend_id: data.target_backend_id ?? null,
|
||||
script_code: data.script_code,
|
||||
is_active: data.is_active ?? true,
|
||||
});
|
||||
return c.json(script, 201);
|
||||
} catch (error) {
|
||||
|
|
@ -99,68 +51,46 @@ router.post('/', async (c) => {
|
|||
}
|
||||
});
|
||||
|
||||
router.put('/:id', async (c) => {
|
||||
router.put('/:id', zValidator('json', UpdateScriptInputSchema), (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
const script = ScriptModel.findById(id);
|
||||
if (!script) return c.json({ error: 'Script not found' }, 404);
|
||||
|
||||
if (!script) {
|
||||
return c.json({ error: 'Script not found' }, 404);
|
||||
}
|
||||
const data = c.req.valid('json');
|
||||
|
||||
const body = await c.req.json();
|
||||
const {
|
||||
name,
|
||||
script_type,
|
||||
target_user_id,
|
||||
target_backend_id,
|
||||
script_code,
|
||||
is_active,
|
||||
} = body;
|
||||
|
||||
if (script_type) {
|
||||
if (script_type === 'per-user-backend') {
|
||||
if (!target_user_id || !target_backend_id) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'target_user_id and target_backend_id are required for per-user-backend scripts',
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
} else if (script_type === 'per-backend') {
|
||||
if (!target_backend_id) {
|
||||
return c.json(
|
||||
{ error: 'target_backend_id is required for per-backend scripts' },
|
||||
400,
|
||||
);
|
||||
}
|
||||
} else if (script_type === 'per-user') {
|
||||
if (!target_user_id) {
|
||||
return c.json(
|
||||
{ error: 'target_user_id is required for per-user scripts' },
|
||||
400,
|
||||
);
|
||||
}
|
||||
// When script_type changes, the target fields must satisfy the discriminated
|
||||
// shape. The shared CreateScriptInputSchema enforces this on creation; the
|
||||
// update path mirrors the same checks because UpdateScriptInputSchema accepts
|
||||
// any combination by design.
|
||||
if (data.script_type === 'per-user-backend') {
|
||||
if (!data.target_user_id || !data.target_backend_id) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'target_user_id and target_backend_id are required for per-user-backend scripts',
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
} else if (data.script_type === 'per-backend' && !data.target_backend_id) {
|
||||
return c.json(
|
||||
{ error: 'target_backend_id is required for per-backend scripts' },
|
||||
400,
|
||||
);
|
||||
} else if (data.script_type === 'per-user' && !data.target_user_id) {
|
||||
return c.json(
|
||||
{ error: 'target_user_id is required for per-user scripts' },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const updatedScript = ScriptModel.update(id, {
|
||||
name,
|
||||
script_type,
|
||||
target_user_id,
|
||||
target_backend_id,
|
||||
script_code,
|
||||
is_active,
|
||||
});
|
||||
|
||||
const updatedScript = ScriptModel.update(id, data);
|
||||
return c.json(updatedScript);
|
||||
});
|
||||
|
||||
router.delete('/:id', (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
const success = ScriptModel.delete(id);
|
||||
if (!success) {
|
||||
if (!ScriptModel.delete(id)) {
|
||||
return c.json({ error: 'Script not found' }, 404);
|
||||
}
|
||||
return c.body(null, 204);
|
||||
|
|
@ -169,83 +99,64 @@ router.delete('/:id', (c) => {
|
|||
router.post('/:id/activate', (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
const script = ScriptModel.findById(id);
|
||||
if (!script) {
|
||||
return c.json({ error: 'Script not found' }, 404);
|
||||
}
|
||||
|
||||
const success = ScriptModel.activate(id);
|
||||
if (!success) {
|
||||
if (!script) return c.json({ error: 'Script not found' }, 404);
|
||||
if (!ScriptModel.activate(id)) {
|
||||
return c.json({ error: 'Failed to activate script' }, 500);
|
||||
}
|
||||
|
||||
return c.json({ ...script, is_active: true });
|
||||
});
|
||||
|
||||
router.post('/:id/deactivate', (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
const script = ScriptModel.findById(id);
|
||||
if (!script) {
|
||||
return c.json({ error: 'Script not found' }, 404);
|
||||
}
|
||||
|
||||
const success = ScriptModel.deactivate(id);
|
||||
if (!success) {
|
||||
if (!script) return c.json({ error: 'Script not found' }, 404);
|
||||
if (!ScriptModel.deactivate(id)) {
|
||||
return c.json({ error: 'Failed to deactivate script' }, 500);
|
||||
}
|
||||
|
||||
return c.json({ ...script, is_active: false });
|
||||
});
|
||||
|
||||
// ============ Script Testing ============
|
||||
router.post(
|
||||
'/:id/test',
|
||||
zValidator('json', ScriptTestInputSchema),
|
||||
async (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
const script = ScriptModel.findById(id);
|
||||
if (!script) return c.json({ error: 'Script not found' }, 404);
|
||||
|
||||
router.post('/:id/test', async (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
const script = ScriptModel.findById(id);
|
||||
if (!script) {
|
||||
return c.json({ error: 'Script not found' }, 404);
|
||||
}
|
||||
const body = c.req.valid('json');
|
||||
const testContext: ScriptContextData = {
|
||||
user: body.user ?? null,
|
||||
backend: body.backend ?? null,
|
||||
request: body.request,
|
||||
};
|
||||
|
||||
const body = await c.req.json();
|
||||
let compiled: CompiledScript | null = null;
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
compiled = await CompiledScript.compile(script.script_code);
|
||||
|
||||
if (!body.request) {
|
||||
return c.json({ error: 'request is required' }, 400);
|
||||
}
|
||||
if (compiled.hasOnRequest) await compiled.callOnRequest(testContext);
|
||||
if (compiled.hasOnResponse) await compiled.callOnResponse(testContext);
|
||||
|
||||
const testContext: ScriptContextData = {
|
||||
user: body.user ?? null,
|
||||
backend: body.backend ?? null,
|
||||
request: body.request,
|
||||
};
|
||||
|
||||
let compiled: CompiledScript | null = null;
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
compiled = await CompiledScript.compile(script.script_code);
|
||||
|
||||
if (compiled.hasOnRequest) {
|
||||
await compiled.callOnRequest(testContext);
|
||||
return c.json({
|
||||
success: true,
|
||||
executionTime: Date.now() - startTime,
|
||||
hasOnRequest: compiled.hasOnRequest,
|
||||
hasOnResponse: compiled.hasOnResponse,
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
400,
|
||||
);
|
||||
} finally {
|
||||
compiled?.dispose();
|
||||
}
|
||||
if (compiled.hasOnResponse) {
|
||||
await compiled.callOnResponse(testContext);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
executionTime: Date.now() - startTime,
|
||||
hasOnRequest: compiled.hasOnRequest,
|
||||
hasOnResponse: compiled.hasOnResponse,
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
400,
|
||||
);
|
||||
} finally {
|
||||
compiled?.dispose();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import { z } from '@hono/zod-openapi';
|
||||
import { ErrorResponseSchema } from '@kyush/shared';
|
||||
|
||||
export const ErrorResponse = z
|
||||
.object({
|
||||
error: z.string().openapi({ example: 'Something went wrong' }),
|
||||
cause: z.string().optional(),
|
||||
backend: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
})
|
||||
.openapi('ErrorResponse');
|
||||
export const ErrorResponse = ErrorResponseSchema.openapi('ErrorResponse', {
|
||||
example: { error: 'Something went wrong' },
|
||||
});
|
||||
|
||||
export type ErrorResponseType = z.infer<typeof ErrorResponse>;
|
||||
export type { ErrorResponse as ErrorResponseType } from '@kyush/shared';
|
||||
|
||||
// Keep `z` available for any future schema definitions in this file.
|
||||
export { z };
|
||||
|
|
|
|||
|
|
@ -1,68 +1,32 @@
|
|||
// Importing `z` from `@hono/zod-openapi` patches zod's prototype with `.openapi()`,
|
||||
// which lets us decorate the shared schemas with OpenAPI metadata without
|
||||
// duplicating their shape on the server.
|
||||
import { z } from '@hono/zod-openapi';
|
||||
import {
|
||||
ChatCompletionRequestSchema,
|
||||
ChatCompletionResponseSchema,
|
||||
ChatMessageSchema,
|
||||
ModelEntrySchema,
|
||||
ModelListResponseSchema,
|
||||
ModelNotAvailableResponseSchema,
|
||||
} from '@kyush/shared';
|
||||
|
||||
export const ChatMessage = z
|
||||
.object({
|
||||
role: z.enum(['system', 'user', 'assistant']),
|
||||
content: z.string(),
|
||||
})
|
||||
.openapi('ChatMessage');
|
||||
// Re-export the shared schemas with OpenAPI annotations attached. The
|
||||
// underlying zod instances are shared with the client, so any schema change
|
||||
// applies to both sides simultaneously.
|
||||
export const ChatMessage = ChatMessageSchema.openapi('ChatMessage');
|
||||
export const ChatCompletionRequest = ChatCompletionRequestSchema.openapi(
|
||||
'ChatCompletionRequest',
|
||||
{ example: { model: 'gpt-4o-mini', messages: [] } },
|
||||
);
|
||||
export const ChatCompletionResponse = ChatCompletionResponseSchema.openapi(
|
||||
'ChatCompletionResponse',
|
||||
);
|
||||
export const ModelEntry = ModelEntrySchema.openapi('ModelEntry');
|
||||
export const ModelListResponse =
|
||||
ModelListResponseSchema.openapi('ModelListResponse');
|
||||
export const ModelNotAvailableResponse =
|
||||
ModelNotAvailableResponseSchema.openapi('ModelNotAvailableResponse');
|
||||
|
||||
export const ChatCompletionRequest = z
|
||||
.object({
|
||||
model: z.string().openapi({ example: 'gpt-4o-mini' }),
|
||||
messages: z.array(ChatMessage),
|
||||
temperature: z.number().min(0).max(2).optional(),
|
||||
max_tokens: z.number().int().positive().optional(),
|
||||
top_p: z.number().min(0).max(1).optional(),
|
||||
frequency_penalty: z.number().optional(),
|
||||
presence_penalty: z.number().optional(),
|
||||
stream: z.boolean().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.openapi('ChatCompletionRequest');
|
||||
|
||||
export const ChatCompletionChoice = z.object({
|
||||
index: z.number().int(),
|
||||
message: ChatMessage,
|
||||
finish_reason: z.string(),
|
||||
});
|
||||
|
||||
export const ChatCompletionUsage = z.object({
|
||||
prompt_tokens: z.number().int(),
|
||||
completion_tokens: z.number().int(),
|
||||
total_tokens: z.number().int(),
|
||||
});
|
||||
|
||||
export const ChatCompletionResponse = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
object: z.string(),
|
||||
created: z.number().int(),
|
||||
model: z.string(),
|
||||
choices: z.array(ChatCompletionChoice),
|
||||
usage: ChatCompletionUsage,
|
||||
})
|
||||
.passthrough()
|
||||
.openapi('ChatCompletionResponse');
|
||||
|
||||
export const ModelEntry = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
object: z.literal('model'),
|
||||
})
|
||||
.openapi('ModelEntry');
|
||||
|
||||
export const ModelListResponse = z
|
||||
.object({
|
||||
object: z.literal('list'),
|
||||
data: z.array(ModelEntry),
|
||||
})
|
||||
.openapi('ModelListResponse');
|
||||
|
||||
export const ModelNotAvailableResponse = z
|
||||
.object({
|
||||
error: z.string(),
|
||||
request_model: z.string(),
|
||||
routed_model: z.string(),
|
||||
})
|
||||
.openapi('ModelNotAvailableResponse');
|
||||
// Re-export `z` so other server code can keep importing from a single place.
|
||||
export { z };
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { env } from '../config/env.js';
|
||||
import { BackendModel } from '../models/Backend.js';
|
||||
import { BackendModelSnapshotModel } from '../models/BackendModelSnapshot.js';
|
||||
import { ModelRewriteModel } from '../models/ModelRewrite.js';
|
||||
|
|
@ -44,8 +45,6 @@ interface RewriteConfig {
|
|||
force: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_REFRESH_MIN_MS = 5 * 60 * 1000;
|
||||
|
||||
export class ModelCatalogService {
|
||||
private static backendModelsByBackendId = new Map<
|
||||
number,
|
||||
|
|
@ -60,12 +59,7 @@ export class ModelCatalogService {
|
|||
private static initialized = false;
|
||||
|
||||
private static getRefreshMinMs(): number {
|
||||
const raw = process.env.MODEL_CATALOG_REFRESH_MIN_MS;
|
||||
if (!raw) return DEFAULT_REFRESH_MIN_MS;
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) && parsed >= 0
|
||||
? parsed
|
||||
: DEFAULT_REFRESH_MIN_MS;
|
||||
return env.MODEL_CATALOG_REFRESH_MIN_MS;
|
||||
}
|
||||
|
||||
private static normalizeModelId(modelId: string): string {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const DEFAULT_TIME_ZONE = 'UTC';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
function getFormatter(
|
||||
timeZone: string,
|
||||
|
|
@ -32,7 +32,7 @@ function getParts(date: Date, timeZone: string): Record<string, string> {
|
|||
}
|
||||
|
||||
export function getConfiguredTimeZone(): string {
|
||||
return process.env.TZ || DEFAULT_TIME_ZONE;
|
||||
return env.TIME_ZONE;
|
||||
}
|
||||
|
||||
export function getUtcTimestamp(date: Date = new Date()): string {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { beforeAll, afterAll } from 'vitest';
|
||||
import { afterAll, beforeAll } from 'vitest';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const moduleDir = import.meta.dirname;
|
||||
|
||||
const workerId =
|
||||
process.env.VITEST_POOL_ID ||
|
||||
process.env.VITEST_WORKER_ID ||
|
||||
String(process.pid);
|
||||
const TEST_DB_DIR = path.join(__dirname, '..', 'data', `test-db-${workerId}`);
|
||||
const TEST_DB_DIR = path.join(moduleDir, '..', 'data', `test-db-${workerId}`);
|
||||
|
||||
process.env.DB_DIR = TEST_DB_DIR;
|
||||
process.env.TZ = 'Asia/Seoul';
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const moduleDir = import.meta.dirname;
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
|
|
@ -14,7 +13,7 @@ export default defineConfig({
|
|||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@': path.resolve(moduleDir, './src'),
|
||||
},
|
||||
// Allow `.js` import specifiers in `.ts` files (NodeNext convention)
|
||||
extensionAlias: {
|
||||
|
|
|
|||
2
shared/index.ts
Normal file
2
shared/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './types.js';
|
||||
export * from './schemas.js';
|
||||
16
shared/package.json
Normal file
16
shared/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "@kyush/shared",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./schemas": "./schemas.ts",
|
||||
"./types": "./types.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
}
|
||||
}
|
||||
346
shared/schemas.ts
Normal file
346
shared/schemas.ts
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* Common
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const ErrorResponseSchema = z.object({
|
||||
error: z.string(),
|
||||
cause: z.string().optional(),
|
||||
backend: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
});
|
||||
export type ErrorResponse = z.infer<typeof ErrorResponseSchema>;
|
||||
|
||||
const trimmedString = (label: string, max = 256) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, `${label} is required`)
|
||||
.max(max, `${label} is too long`);
|
||||
|
||||
const optionalTrimmedString = (max = 256) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(max)
|
||||
.optional()
|
||||
.transform((value) => (value && value.length > 0 ? value : undefined));
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* Users
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const CreateUserInputSchema = z.object({
|
||||
name: trimmedString('Name'),
|
||||
email: optionalTrimmedString(),
|
||||
api_key: optionalTrimmedString(),
|
||||
detail_logging: z.boolean().optional(),
|
||||
});
|
||||
export type CreateUserInput = z.infer<typeof CreateUserInputSchema>;
|
||||
|
||||
export const UpdateUserInputSchema = z.object({
|
||||
name: z.string().trim().min(1, 'Name cannot be empty').max(256).optional(),
|
||||
email: optionalTrimmedString(),
|
||||
api_key: optionalTrimmedString(),
|
||||
is_active: z.boolean().optional(),
|
||||
detail_logging: z.boolean().optional(),
|
||||
});
|
||||
export type UpdateUserInput = z.infer<typeof UpdateUserInputSchema>;
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* Backends
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const CreateBackendInputSchema = z.object({
|
||||
name: trimmedString('Name'),
|
||||
base_url: trimmedString('Base URL', 2048),
|
||||
api_key: optionalTrimmedString(),
|
||||
detail_logging: z.boolean().optional(),
|
||||
});
|
||||
export type CreateBackendInput = z.infer<typeof CreateBackendInputSchema>;
|
||||
|
||||
export const UpdateBackendInputSchema = z.object({
|
||||
name: z.string().trim().min(1).max(256).optional(),
|
||||
base_url: z.string().trim().min(1).max(2048).optional(),
|
||||
api_key: optionalTrimmedString(),
|
||||
is_active: z.boolean().optional(),
|
||||
detail_logging: z.boolean().optional(),
|
||||
});
|
||||
export type UpdateBackendInput = z.infer<typeof UpdateBackendInputSchema>;
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* Permissions
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const CreatePermissionInputSchema = z.object({
|
||||
user_id: z.number().int().positive(),
|
||||
backend_id: z.number().int().positive(),
|
||||
});
|
||||
export type CreatePermissionInput = z.infer<typeof CreatePermissionInputSchema>;
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* Model rewrites
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const CreateModelRewriteInputSchema = z.object({
|
||||
source_model: trimmedString('Source model'),
|
||||
target_model: trimmedString('Target model'),
|
||||
is_active: z.boolean().optional(),
|
||||
force: z.boolean().optional(),
|
||||
note: optionalTrimmedString(1024),
|
||||
});
|
||||
export type CreateModelRewriteInput = z.infer<
|
||||
typeof CreateModelRewriteInputSchema
|
||||
>;
|
||||
|
||||
export const UpdateModelRewriteInputSchema = z.object({
|
||||
source_model: z.string().trim().min(1).max(256).optional(),
|
||||
target_model: z.string().trim().min(1).max(256).optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
force: z.boolean().optional(),
|
||||
note: optionalTrimmedString(1024),
|
||||
});
|
||||
export type UpdateModelRewriteInput = z.infer<
|
||||
typeof UpdateModelRewriteInputSchema
|
||||
>;
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* Scripts
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const ScriptTypeSchema = z.enum([
|
||||
'per-user-backend',
|
||||
'per-backend',
|
||||
'per-user',
|
||||
]);
|
||||
|
||||
const baseScriptFields = z.object({
|
||||
name: trimmedString('Name'),
|
||||
script_code: trimmedString('Script code', 1_000_000),
|
||||
is_active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const CreateScriptInputSchema = z.discriminatedUnion('script_type', [
|
||||
baseScriptFields.extend({
|
||||
script_type: z.literal('per-user-backend'),
|
||||
target_user_id: z.number().int().positive(),
|
||||
target_backend_id: z.number().int().positive(),
|
||||
}),
|
||||
baseScriptFields.extend({
|
||||
script_type: z.literal('per-backend'),
|
||||
target_user_id: z.null().optional(),
|
||||
target_backend_id: z.number().int().positive(),
|
||||
}),
|
||||
baseScriptFields.extend({
|
||||
script_type: z.literal('per-user'),
|
||||
target_user_id: z.number().int().positive(),
|
||||
target_backend_id: z.null().optional(),
|
||||
}),
|
||||
]);
|
||||
export type CreateScriptInput = z.infer<typeof CreateScriptInputSchema>;
|
||||
|
||||
export const UpdateScriptInputSchema = z.object({
|
||||
name: z.string().trim().min(1).max(256).optional(),
|
||||
script_type: ScriptTypeSchema.optional(),
|
||||
target_user_id: z.number().int().positive().nullable().optional(),
|
||||
target_backend_id: z.number().int().positive().nullable().optional(),
|
||||
script_code: z.string().min(1).max(1_000_000).optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
});
|
||||
export type UpdateScriptInput = z.infer<typeof UpdateScriptInputSchema>;
|
||||
|
||||
export const ScriptContextRequestSchema = z
|
||||
.object({
|
||||
method: z.string(),
|
||||
path: z.string(),
|
||||
headers: z.record(z.string()),
|
||||
// `unknown` is optional in the wire schema, but the script runtime always
|
||||
// receives a `body` field (it might just be undefined). The transform
|
||||
// re-adds the field so the result satisfies the strict
|
||||
// `ScriptContextData` shape downstream consumers expect.
|
||||
body: z.unknown(),
|
||||
isStream: z.boolean(),
|
||||
})
|
||||
.transform((value) => ({
|
||||
method: value.method,
|
||||
path: value.path,
|
||||
headers: value.headers,
|
||||
body: value.body,
|
||||
isStream: value.isStream,
|
||||
}));
|
||||
export type ScriptContextRequest = z.infer<typeof ScriptContextRequestSchema>;
|
||||
|
||||
export const ScriptContextResponseSchema = z.object({
|
||||
status: z.number().int(),
|
||||
headers: z.record(z.string()),
|
||||
body: z.unknown(),
|
||||
isStream: z.boolean(),
|
||||
});
|
||||
export type ScriptContextResponse = z.infer<typeof ScriptContextResponseSchema>;
|
||||
|
||||
export const ScriptTestInputSchema = z.object({
|
||||
user: z
|
||||
.object({
|
||||
id: z.number().int(),
|
||||
name: z.string(),
|
||||
email: z.string().optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
backend: z
|
||||
.object({
|
||||
id: z.number().int(),
|
||||
name: z.string(),
|
||||
base_url: z.string(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
request: ScriptContextRequestSchema,
|
||||
});
|
||||
export type ScriptTestInput = z.infer<typeof ScriptTestInputSchema>;
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* Admin auth
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const AdminLoginInputSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
export type AdminLoginInput = z.infer<typeof AdminLoginInputSchema>;
|
||||
|
||||
export const CreateAdminTokenInputSchema = z.object({
|
||||
name: trimmedString('Token name'),
|
||||
expiresInDays: z.number().int().positive().optional(),
|
||||
});
|
||||
export type CreateAdminTokenInput = z.infer<typeof CreateAdminTokenInputSchema>;
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* OpenAI v1
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const ChatRoleSchema = z.enum(['system', 'user', 'assistant']);
|
||||
export type ChatRole = z.infer<typeof ChatRoleSchema>;
|
||||
|
||||
export const ChatMessageSchema = z.object({
|
||||
role: ChatRoleSchema,
|
||||
content: z.string(),
|
||||
});
|
||||
export type ChatMessage = z.infer<typeof ChatMessageSchema>;
|
||||
|
||||
export const ChatCompletionRequestSchema = z
|
||||
.object({
|
||||
model: z.string(),
|
||||
messages: z.array(ChatMessageSchema),
|
||||
temperature: z.number().min(0).max(2).optional(),
|
||||
max_tokens: z.number().int().positive().optional(),
|
||||
top_p: z.number().min(0).max(1).optional(),
|
||||
frequency_penalty: z.number().optional(),
|
||||
presence_penalty: z.number().optional(),
|
||||
stream: z.boolean().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export type ChatCompletionRequest = z.infer<typeof ChatCompletionRequestSchema>;
|
||||
|
||||
export const ChatCompletionUsageSchema = z.object({
|
||||
prompt_tokens: z.number().int(),
|
||||
completion_tokens: z.number().int(),
|
||||
total_tokens: z.number().int(),
|
||||
});
|
||||
export type ChatCompletionUsage = z.infer<typeof ChatCompletionUsageSchema>;
|
||||
|
||||
export const ChatCompletionChoiceSchema = z.object({
|
||||
index: z.number().int(),
|
||||
message: ChatMessageSchema,
|
||||
finish_reason: z.string(),
|
||||
});
|
||||
export type ChatCompletionChoice = z.infer<typeof ChatCompletionChoiceSchema>;
|
||||
|
||||
export const ChatCompletionResponseSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
object: z.string(),
|
||||
created: z.number().int(),
|
||||
model: z.string(),
|
||||
choices: z.array(ChatCompletionChoiceSchema),
|
||||
usage: ChatCompletionUsageSchema,
|
||||
})
|
||||
.passthrough();
|
||||
export type ChatCompletionResponse = z.infer<
|
||||
typeof ChatCompletionResponseSchema
|
||||
>;
|
||||
|
||||
export const ModelEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
object: z.literal('model'),
|
||||
});
|
||||
export type ModelEntry = z.infer<typeof ModelEntrySchema>;
|
||||
|
||||
export const ModelListResponseSchema = z.object({
|
||||
object: z.literal('list'),
|
||||
data: z.array(ModelEntrySchema),
|
||||
});
|
||||
export type ModelListResponse = z.infer<typeof ModelListResponseSchema>;
|
||||
|
||||
export const ModelNotAvailableResponseSchema = z.object({
|
||||
error: z.string(),
|
||||
request_model: z.string(),
|
||||
routed_model: z.string(),
|
||||
});
|
||||
export type ModelNotAvailableResponse = z.infer<
|
||||
typeof ModelNotAvailableResponseSchema
|
||||
>;
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* Loose response message parser (used by ConversationTimeline et al.)
|
||||
* Tolerant: accepts unknown extras and unparseable bodies are skipped at the
|
||||
* call site.
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const LooseChatMessageSchema = z
|
||||
.object({
|
||||
role: z.string().optional(),
|
||||
content: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const LooseChatCompletionRequestSchema = z
|
||||
.object({
|
||||
model: z.string().optional(),
|
||||
temperature: z.number().optional(),
|
||||
messages: z.array(LooseChatMessageSchema).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const LooseChatCompletionChoiceSchema = z
|
||||
.object({
|
||||
message: z
|
||||
.object({
|
||||
role: z.string().optional(),
|
||||
content: z.unknown().optional(),
|
||||
reasoning_content: z.unknown().optional(),
|
||||
tool_calls: z.unknown().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
finish_reason: z.unknown().optional(),
|
||||
matched_stop: z.unknown().optional(),
|
||||
logprobs: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const LooseChatCompletionResponseSchema = z
|
||||
.object({
|
||||
created: z.number().optional(),
|
||||
choices: z.array(LooseChatCompletionChoiceSchema).optional(),
|
||||
usage: z
|
||||
.object({
|
||||
prompt_tokens: z.number().optional(),
|
||||
completion_tokens: z.number().optional(),
|
||||
total_tokens: z.number().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
17
shared/tsconfig.json
Normal file
17
shared/tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue