diff --git a/llumiverse b/llumiverse index 211beea77..a25726855 160000 --- a/llumiverse +++ b/llumiverse @@ -1 +1 @@ -Subproject commit 211beea779db480f845c5f5a2df1957d6b6e5f10 +Subproject commit a2572685527506d990933290c9abc7f649cd7398 diff --git a/packages/build-tools/tsconfig.json b/packages/build-tools/tsconfig.json index 8cf9a931e..6af4d6a06 100644 --- a/packages/build-tools/tsconfig.json +++ b/packages/build-tools/tsconfig.json @@ -16,6 +16,7 @@ "useDefineForClassFields": true, "module": "ESNext", "moduleResolution": "Bundler", + "ignoreDeprecations": "6.0", "skipLibCheck": true, "resolveJsonModule": true, "isolatedModules": true, diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 430bb77fd..1a569c6c3 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -24,6 +24,7 @@ import { useProfile, type CreateProfileOptions, } from './profiles/commands.js'; +import { configureGitAuth, serveGitCredential } from './profiles/git.js'; import { AVAILABLE_REGIONS, DEFAULT_REGION, getConfigFile } from './profiles/index.js'; import { listProjects, useProject } from './projects/index.js'; import runInteraction from './run/index.js'; @@ -58,7 +59,7 @@ const authRoot = program.command("auth") authRoot.command("login [profile]") .description("Authenticate a profile, creating it when it does not exist") - .option("-t, --target ", "The target environment for a new profile. Possible values are: local, dev-main, dev-preview, preview, prod or a custom URL.") + .option("-t, --target ", "The target environment for a new profile. Possible values are: local, preview, prod, or a custom URL.") .option("-r, --region ", `Deployment region for a new profile: ${AVAILABLE_REGIONS.join(', ')}. Defaults to ${DEFAULT_REGION}. Only applies to preview and prod targets.`) .option("-p, --project ", "Authenticate for the given project ID") .option("-a, --account ", "The account ID to use when creating a profile") @@ -89,6 +90,21 @@ authRoot.command("refresh") .option("-p, --project ", "Refresh the current profile token for the given project ID") .action((options: { project?: string }) => updateCurrentProfile(undefined, undefined, options)) +const authGit = authRoot.command("git") + .description("Configure Git to authenticate to Vertesia app repositories with the active profile token.") + .option("-p, --profile ", "Profile to use for Git credentials. Defaults to the active profile.") + .option("--url ", "Git server base URL, for example https://git.dev1.vertesia.io.") + .option("--no-alias", "Do not configure the vertesia:.git short alias.") + .action((options: { profile?: string; url?: string; alias?: boolean }) => configureGitAuth(options)); + +authGit.command("credential [action]") + .description("Git credential helper entrypoint. Called by git; users should run `vertesia auth git` instead.") + .action(function (this: import("commander").Command, action: string | undefined) { + // --profile is declared on the parent `auth git`, so read via globals. + const profile = this.optsWithGlobals().profile as string | undefined; + return serveGitCredential(action, { profile }); + }); + program.command("envs [envId]") .description("List the environments you have access to") .action((envId: string | undefined, options: Record) => { @@ -169,7 +185,7 @@ profilesRoot.command('use [name]') }); profilesRoot.command('add [name]') .alias('create') - .option("-t, --target ", "The target environment for the profile. Possible values are: local, dev-main, dev-preview, preview, prod or a custom URL.") + .option("-t, --target ", "The target environment for the profile. Possible values are: local, preview, prod, or a custom URL.") .option("-r, --region ", `Deployment region: ${AVAILABLE_REGIONS.join(', ')}. Defaults to ${DEFAULT_REGION}. Only applies to preview and prod targets.`) .option("-k, --apikey ", "The API key or auth token to use for the profile") .option("-p, --project ", "The project ID to use for the profile") diff --git a/packages/cli/src/profiles/commands.ts b/packages/cli/src/profiles/commands.ts index 0caf79264..858fbd7db 100644 --- a/packages/cli/src/profiles/commands.ts +++ b/packages/cli/src/profiles/commands.ts @@ -281,8 +281,8 @@ export async function createProfile(name?: string, options: CreateProfileOptions }); } if (!options.target) { - // only show dev environments in dev mode - const choices = config.isDevMode ? ['local', 'dev-main', 'dev-preview', 'preview', 'prod', 'custom'] : ['preview', 'prod']; + // Branch/dev deployments are custom URLs so profile config is explicit. + const choices = config.isDevMode ? ['local', 'preview', 'prod', 'custom'] : ['preview', 'prod', 'custom']; questions.push({ type: 'select', name: 'target', diff --git a/packages/cli/src/profiles/git.ts b/packages/cli/src/profiles/git.ts new file mode 100644 index 000000000..080affd88 --- /dev/null +++ b/packages/cli/src/profiles/git.ts @@ -0,0 +1,212 @@ +import { execFileSync } from 'node:child_process'; +import process from 'node:process'; +import { ensureProfileAccessToken } from './auth.js'; +import { config, type Profile } from './index.js'; + +interface GitAuthOptions { + profile?: string; + url?: string; + alias?: boolean; +} + +interface GitCredentialInput { + protocol?: string; + host?: string; + path?: string; +} + +export async function configureGitAuth(options: GitAuthOptions = {}) { + const profile = getRequestedProfile(options.profile); + const gitBaseUrl = normalizeGitBaseUrl(options.url || gitServerUrlForProfile(profile)); + const helper = `!vertesia auth git credential --profile ${shellQuote(profile.name)}`; + + gitConfig('--global', `credential.${gitBaseUrl}.helper`, helper); + gitConfig('--global', `credential.${gitBaseUrl}.useHttpPath`, 'true'); + + if (options.alias !== false) { + removeKnownVertesiaAliases(); + gitConfig('--global', `url.${gitBaseUrl}/.insteadOf`, 'vertesia:'); + } + + console.log(`Configured Git authentication for ${gitBaseUrl}`); + console.log(`Clone with: git clone ${gitBaseUrl}/.git`); + if (options.alias !== false) { + console.log('Short alias: git clone vertesia:.git'); + } +} + +export async function serveGitCredential(action: string | undefined, options: Pick = {}) { + if (!action || action === 'get') { + const envToken = process.env.VERTESIA_AUTH_TOKEN || process.env.VERTESIA_TOKEN; + if (envToken) { + writeCredential(envToken); + return; + } + + const input = await readCredentialInput(); + const profile = pickProfileForCredential(input, options.profile); + if (!profile) { + if (options.profile) { + throw new Error( + `Vertesia profile '${options.profile}' was not found. Run \`vertesia auth git\` from an active profile.`, + ); + } + throw new Error( + `No Vertesia profile matches git host ${input.host || ''}. Run \`vertesia auth git\`.`, + ); + } + + const token = await ensureProfileAccessToken(profile); + if (!token) { + throw new Error(`Profile ${profile.name} has no usable auth token. Run \`vertesia auth refresh\`.`); + } + writeCredential(token); + return; + } + + // Git may call helpers with store/erase. Tokens are managed by Vertesia CLI + // profile auth, so there is nothing useful to persist from Git. +} + +function getRequestedProfile(profileName?: string): Profile { + if (profileName) { + const profile = config.getProfile(profileName); + if (!profile) throw new Error(`Profile ${profileName} not found.`); + return profile; + } + if (!config.current) { + throw new Error( + 'No Vertesia profile is selected. Run `vertesia auth login` or `vertesia profiles use `.', + ); + } + return config.current; +} + +function pickProfileForCredential(input: GitCredentialInput, profileName?: string): Profile | undefined { + if (profileName) { + return config.getProfile(profileName); + } + const host = input.host?.toLowerCase(); + if (!host) return config.current; + if (config.current && gitHostForProfile(config.current) === host) { + return config.current; + } + return config.profiles.find(profile => gitHostForProfile(profile) === host); +} + +function gitHostForProfile(profile: Profile): string | undefined { + try { + return new URL(gitServerUrlForProfile(profile)).hostname.toLowerCase(); + } catch { + return undefined; + } +} + +function gitServerUrlForProfile(profile: Profile): string { + const override = process.env.VERTESIA_GIT_SERVER_URL + || process.env.APP_GIT_SERVER_URL + || process.env.APPGEN_GIT_SERVER_URL; + if (override) return normalizeGitBaseUrl(override); + + const sourceUrl = profile.studio_server_url || profile.zeno_server_url || profile.config_url; + try { + const url = new URL(sourceUrl); + const host = url.hostname.toLowerCase(); + if (host === 'localhost' || host === '127.0.0.1' || host.endsWith('.local')) { + return 'https://git.dev1.vertesia.io'; + } + if (host === 'api.vertesia.io' || host === 'api-preview.vertesia.io') { + return 'https://git.vertesia.io'; + } + if (host.startsWith('api-preview.')) { + return `https://${host.replace(/^api-preview\./, 'git.')}`; + } + if (host.startsWith('api.')) { + return `https://${host.replace(/^api\./, 'git.')}`; + } + + const parts = host.split('.'); + const apiIndex = parts.indexOf('api'); + const region = apiIndex >= 0 ? parts[apiIndex + 1] : undefined; + if (region && parts[apiIndex + 2] === 'vertesia') { + return `https://git.${region}.vertesia.io`; + } + } catch { + // Fall through to profile region/default below. + } + + if (profile.region) return `https://git.${profile.region}.vertesia.io`; + return 'https://git.dev1.vertesia.io'; +} + +function normalizeGitBaseUrl(value: string): string { + const normalized = value.replace(/\/+$/, ''); + if (!normalized.startsWith('http://') && !normalized.startsWith('https://')) { + throw new Error(`Git server URL must be an http(s) URL: ${value}`); + } + return normalized; +} + +function gitConfig(...args: string[]) { + execFileSync('git', ['config', ...args], { stdio: 'pipe' }); +} + +function removeKnownVertesiaAliases() { + let raw: string; + try { + raw = execFileSync('git', ['config', '--global', '--get-regexp', '^url\\..*\\.insteadOf$'], { + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + }); + } catch { + // No matching keys, or no global config — nothing to clean up. + return; + } + for (const line of raw.split('\n')) { + const space = line.indexOf(' '); + if (space < 0) continue; + const key = line.slice(0, space); + const value = line.slice(space + 1); + if (value !== 'vertesia:') continue; + try { + execFileSync('git', ['config', '--global', '--unset-all', key, '^vertesia:$'], { stdio: 'ignore' }); + } catch { + // Best-effort; ignore if the key disappears between read and unset. + } + } +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +async function readCredentialInput(): Promise { + const text = await readStdin(); + const input: Record = {}; + for (const line of text.split(/\r?\n/)) { + if (!line) break; + const separator = line.indexOf('='); + if (separator < 0) continue; + input[line.slice(0, separator)] = line.slice(separator + 1); + } + return input; +} + +function readStdin(): Promise { + return new Promise((resolve, reject) => { + let text = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => { + text += chunk; + }); + process.stdin.on('end', () => resolve(text)); + process.stdin.on('error', reject); + if (process.stdin.isTTY) { + resolve(''); + } + }); +} + +function writeCredential(token: string) { + process.stdout.write(`username=vertesia\npassword=${token}\n`); +} diff --git a/packages/cli/src/profiles/index.ts b/packages/cli/src/profiles/index.ts index d21624504..cd36145fa 100644 --- a/packages/cli/src/profiles/index.ts +++ b/packages/cli/src/profiles/index.ts @@ -21,18 +21,11 @@ export type Region = 'us1' | 'eu1' | 'jp1'; export const DEFAULT_REGION: Region = 'us1'; export const AVAILABLE_REGIONS: Region[] = ['us1', 'eu1', 'jp1']; -export type ConfigUrlRef = "local" | "dev-main" | "dev-preview" | "preview" | "prod" | string; +export type ConfigUrlRef = "local" | "preview" | "prod" | string; export function getConfigUrl(value: ConfigUrlRef, region: Region = DEFAULT_REGION): string { - if (isDevDeploymentTarget(value)) { - return `https://${value}.ui.dev1.vertesia.io/cli`; - } switch (value) { case "local": return "https://localhost:5173/cli"; - case "dev-main": - return "https://dev-main.ui.dev1.vertesia.io/cli"; - case "dev-preview": - return "https://dev-preview.ui.dev1.vertesia.io/cli"; case "preview": return `https://preview.cloud.${region}.vertesia.io/cli`; case "prod": @@ -46,28 +39,12 @@ export function getConfigUrl(value: ConfigUrlRef, region: Region = DEFAULT_REGIO } } export function getServerUrls(value: ConfigUrlRef, region: Region = DEFAULT_REGION): { studio_server_url: string; zeno_server_url: string } { - if (isDevDeploymentTarget(value)) { - return { - studio_server_url: `https://studio-server-${value}.api.dev1.vertesia.io`, - zeno_server_url: `https://zeno-server-${value}.api.dev1.vertesia.io`, - }; - } switch (value) { case "local": return { studio_server_url: "http://localhost:8091", zeno_server_url: "http://localhost:8092", }; - case "dev-main": - return { - studio_server_url: "https://studio-server-dev-main.api.dev1.vertesia.io", - zeno_server_url: "https://zeno-server-dev-main.api.dev1.vertesia.io", - }; - case "dev-preview": - return { - studio_server_url: "https://studio-server-dev-preview.api.dev1.vertesia.io", - zeno_server_url: "https://zeno-server-dev-preview.api.dev1.vertesia.io", - }; case "preview": return { studio_server_url: `https://api-preview.${region}.vertesia.io`, @@ -83,12 +60,8 @@ export function getServerUrls(value: ConfigUrlRef, region: Region = DEFAULT_REGI } } -function isDevDeploymentTarget(value: string): boolean { - return value.startsWith('dev-'); -} - export function getCloudTypeFromConfigUrl(url: string) { - if (url.startsWith("https://localhost")) { + if (url.startsWith("http://localhost") || url.startsWith("https://localhost")) { return "staging"; } else if (url.includes(".ui.dev1.vertesia.io")) { return "staging"; diff --git a/packages/cli/src/profiles/server/index.ts b/packages/cli/src/profiles/server/index.ts index 62fc4fd48..ea2cdfcb7 100644 --- a/packages/cli/src/profiles/server/index.ts +++ b/packages/cli/src/profiles/server/index.ts @@ -187,9 +187,17 @@ export async function startConfigSession( console.error("Invalid token"); process.exit(1); } + } else { + // Empty answer — enquirer 2.x resolves with `result: ''` on Ctrl-C + // rather than rejecting. Without this branch the HTTP server stays + // open and the process hangs. + cleanup(); + console.log("\nAuthentication cancelled."); + process.exit(130); } } catch (err: unknown) { // This could be thrown if the prompt is interrupted + cleanup(); if (signal?.aborted) { return; } diff --git a/packages/client/src/AppsApi.ts b/packages/client/src/AppsApi.ts index e3a75833e..29e11b547 100644 --- a/packages/client/src/AppsApi.ts +++ b/packages/client/src/AppsApi.ts @@ -8,11 +8,16 @@ import type { AppManifest, AppManifestData, AppPackage, + AppPackageScope, AppToolCollection, + AppVersionListQuery, + AppVersionRecord, + ActivateAppVersionResponse, CountResult, ProjectRef, RequireAtLeastOne, UpdateAppInstallationToolAllowlistPayload, + UpsertAppVersionRequest, ValidateUrlRequest, ValidateUrlResponse, } from "@vertesia/common"; @@ -35,6 +40,29 @@ export default class AppsApi extends ApiTopic { return this.put(`/${id}`, { payload: manifest }); } + listVersions(query?: AppVersionListQuery): Promise { + return this.get('/versions', { + query: { + ...(query?.app_id && { app_id: query.app_id }), + ...(query?.kind && { kind: query.kind }), + ...(query?.include_expired !== undefined && { include_expired: query.include_expired }), + ...(query?.limit !== undefined && { limit: query.limit }), + }, + }); + } + + upsertVersion(payload: UpsertAppVersionRequest): Promise { + return this.post('/versions', { payload }); + } + + getVersion(recordId: string): Promise { + return this.get(`/versions/${recordId}`); + } + + activateVersion(recordId: string): Promise { + return this.post(`/versions/${recordId}/activate`); + } + /** * Get the list if tools provided by the given app. * @param appId @@ -44,6 +72,17 @@ export default class AppsApi extends ApiTopic { return this.get(`/installations/${appInstallId}/tools`) } + /** + * Get package capabilities exposed by an app installation. + */ + getAppInstallationPackage(appInstallId: string, scope: AppPackageScope | AppPackageScope[] = 'all'): Promise { + return this.get(`/installations/${appInstallId}/package`, { + query: { + scope: Array.isArray(scope) ? scope.join(',') : scope, + }, + }); + } + /** * Fetch the always-on system tools package served by studio-server. * Tools and skills (`learn_*`) are returned on separate fields so UIs can diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 0684e799b..34ea015b4 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -102,7 +102,7 @@ export class VertesiaClient extends AbstractFetchClient { static async fromAuthToken( token: string, payload?: AuthTokenPayload, - endpoints?: { studio: string; store: string; token?: string } + endpoints?: { studio: string; store: string; token?: string; git?: string } ) { if (!payload) { payload = decodeJWT(token); @@ -454,6 +454,7 @@ function getEndpointsFromDomain(domain: string) { studio: `http://localhost:8091`, store: `http://localhost:8092`, token: getRuntimeStsUrl() ?? "https://sts.dev1.vertesia.io", + git: "https://git.dev1.vertesia.io", }; } else { const url = `https://${domain}`; @@ -463,6 +464,7 @@ function getEndpointsFromDomain(domain: string) { studio: url, store: url, token: url.replace("api", "sts"), + git: url.replace("api", "git"), }; } } diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 7963184fc..968a6866e 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -20,6 +20,7 @@ /* Bundler mode */ "module": "ESNext", "moduleResolution": "Bundler", + "ignoreDeprecations": "6.0", //"allowImportingTsExtensions": true, //"verbatimModuleSyntax": true, //"noEmit": false, diff --git a/packages/common/src/access-control.ts b/packages/common/src/access-control.ts index ba83807b5..92e0e71d2 100644 --- a/packages/common/src/access-control.ts +++ b/packages/common/src/access-control.ts @@ -17,6 +17,8 @@ export enum Permission { env_admin = "environment:admin", + app_manage = "app:manage", + project_admin = "project:admin", project_integration_read = "project:integration_read", project_settings_write = "project:settings_write", diff --git a/packages/common/src/apikey.ts b/packages/common/src/apikey.ts index 10fb2ea95..6856243b5 100644 --- a/packages/common/src/apikey.ts +++ b/packages/common/src/apikey.ts @@ -107,6 +107,7 @@ export interface AuthTokenPayload { studio: string; store: string; token?: string; + git?: string; }; iss: string; //issuer diff --git a/packages/common/src/apps.ts b/packages/common/src/apps.ts index b07e1c573..7ba5b8a7b 100644 --- a/packages/common/src/apps.ts +++ b/packages/common/src/apps.ts @@ -1,4 +1,5 @@ import { JSONObject, JSONSchema, ToolDefinition } from "@llumiverse/common"; +import type { AppDashboardDefinition } from "./data-platform.js"; import { CatalogInteractionRef } from "./interaction.js"; import { DSLActivityOptions, InCodeProcessDefinition, InCodeTypeDefinition } from "./store/index.js"; @@ -391,8 +392,110 @@ export interface RemoteActivityDefinition { options?: DSLActivityOptions; } -export type AppCapabilities = 'ui' | 'tools' | 'interactions' | 'types' | 'processes' | 'templates'; +export type AppCapabilities = 'ui' | 'tools' | 'interactions' | 'types' | 'processes' | 'templates' | 'dashboards'; export type AppAvailableIn = 'app_portal' | 'composite_app'; + +export type AppVersionKind = 'design' | 'preview' | 'published'; +export type AppVersionState = 'ready' | 'failed' | 'expired'; +export type AppVersionTarget = 'static' | 'service'; + +export interface AppVersionStorage { + tenant_id?: string; + app_prefix?: string; + artifacts_prefix?: string; + source_archive?: string; + source_git?: AppVersionGitSource; + build_prefix?: string; + manifest_path?: string; + service_archive?: string; + live_metadata_path?: string; +} + +export interface AppVersionGitSource { + url?: string; + remote?: string; + ref?: string; + commit?: string; +} + +export interface AppVersionUrls { + live_url?: string; + app_url?: string; + plugin_url?: string; + package_url?: string; + internal_preview_url?: string; +} + +export interface AppVersionRecord { + id: string; + account: string; + project: string; + app?: string; + app_id: string; + app_name: string; + version_id: string; + kind: AppVersionKind; + state: AppVersionState; + active?: boolean; + target?: AppVersionTarget; + agent_run_id?: string; + sandbox_id?: string; + title?: string; + description?: string; + storage?: AppVersionStorage; + urls?: AppVersionUrls; + manifest?: Record; + files?: string[]; + file_count?: number; + source_file_count?: number; + screenshot_artifact?: string; + checks?: string[]; + created_by?: string; + created_at: string; + updated_at: string; + published_at?: string; + checked_at?: string; + expires_at?: string; +} + +export interface UpsertAppVersionRequest { + app?: string; + app_id: string; + app_name?: string; + version_id: string; + kind: AppVersionKind; + state?: AppVersionState; + active?: boolean; + target?: AppVersionTarget; + agent_run_id?: string; + sandbox_id?: string; + title?: string; + description?: string; + storage?: AppVersionStorage; + urls?: AppVersionUrls; + manifest?: Record; + files?: string[]; + file_count?: number; + source_file_count?: number; + screenshot_artifact?: string; + checks?: string[]; + published_at?: string; + checked_at?: string; + expires_at?: string; +} + +export interface AppVersionListQuery { + app_id?: string; + kind?: AppVersionKind; + include_expired?: boolean; + limit?: number; +} + +export interface ActivateAppVersionResponse { + version: AppVersionRecord; + app?: AppManifest; +} + export interface AppManifestData { /** * The name of the app, used as the id in the system. @@ -477,8 +580,10 @@ export interface AppManifestData { * - ui * - tools * - interactions - * - types - * - processes + * - types + * - processes + * - templates + * - dashboards * - settings * - all (the default if no scope is provided) * You can also use comma-separated values to combine scopes (e.g. "ui,tools"). @@ -540,6 +645,8 @@ export interface Endpoints { token?: string; /** The browser-facing Studio UI (composable-ui) base URL */ ui?: string; + /** The Smart HTTP app source git server base URL */ + git?: string; } /** @@ -635,7 +742,18 @@ export function resolveManifestUrls( } } -export type AppPackageScope = 'ui' | 'tools' | 'interactions' | 'types' | 'processes' | 'templates' | 'settings' | 'widgets' | 'activities' | 'all'; +export type AppPackageScope = + | 'ui' + | 'tools' + | 'interactions' + | 'types' + | 'processes' + | 'templates' + | 'dashboards' + | 'settings' + | 'widgets' + | 'activities' + | 'all'; export interface AppPackage { /** * The UI configuration of the app @@ -675,6 +793,11 @@ export interface AppPackage { */ templates?: RenderingTemplateDefinitionRef[]; + /** + * Dashboards provided by the app. + */ + dashboards?: AppDashboardDefinition[]; + /** * Widgets provided by the app. */ diff --git a/packages/common/src/data-platform.ts b/packages/common/src/data-platform.ts index edc3bae4d..635c56cd7 100644 --- a/packages/common/src/data-platform.ts +++ b/packages/common/src/data-platform.ts @@ -810,6 +810,99 @@ export interface DashboardQueryParameters { defaults: Record; } +/** + * Elasticsearch DSL supported by dashboard data sources. + * Queries execute through Vertesia Store, so project/security filtering remains server-side. + */ +export interface DashboardElasticsearchDsl { + query?: Record; + aggs?: Record; + size?: number; + from?: number; + sort?: Array>; +} + +/** + * How an Elasticsearch DSL result should be converted into Vega rows. + */ +export type DashboardElasticsearchResultMapping = + | { + type: 'hits'; + } + | { + type: 'aggregation_buckets'; + /** Dot path under `aggregations` that contains a `buckets` array. */ + path: string; + /** Output field name for bucket key. Defaults to `key`. */ + keyField?: string; + /** Output field name for doc count. Defaults to `doc_count`. */ + countField?: string; + }; + +/** + * Dashboard data source backed by a Data Platform SQL query. + */ +export interface DashboardSqlDataSource { + kind: 'data_sql'; + /** SQL query that returns all rows for the dashboard. */ + query: string; + /** Maximum rows to return from the query. */ + queryLimit?: number; + /** Default values for SQL {{param}} placeholders. */ + queryParameters?: Record; +} + +/** + * Dashboard data source backed by Vertesia Store Elasticsearch DSL. + */ +export interface DashboardStoreElasticsearchDataSource { + kind: 'store_es_dsl'; + /** Elasticsearch DSL query executed through the secured Store query API. */ + dsl: DashboardElasticsearchDsl; + /** Result mapping. Defaults to `hits`. */ + result?: DashboardElasticsearchResultMapping; +} + +/** + * Data source for a Vega dashboard. + */ +export type DashboardDataSource = DashboardSqlDataSource | DashboardStoreElasticsearchDataSource; + +/** + * Dashboard definition contributed by an app package. + * + * App dashboard IDs are local to the app. The platform exposes them as + * `app::` when listing or retrieving dashboards. + */ +export interface AppDashboardDefinition { + /** Local app dashboard ID. */ + id: string; + /** Machine-friendly dashboard name. Defaults to `id`. */ + name?: string; + /** Display title. Defaults to `name` or `id`. */ + title?: string; + /** User-facing description. */ + description?: string; + /** Tags for discovery and filtering. */ + tags?: string[]; + /** Data source used to populate Vega `data.values`. */ + dataSource?: DashboardDataSource; + /** SQL query shortcut for app dashboards backed by data stores. */ + query?: string; + /** Maximum SQL rows to return. */ + queryLimit?: number; + /** Default values for SQL {{param}} placeholders. */ + queryParameters?: Record; + /** Complete Vega-Lite specification for the dashboard. */ + spec?: Record; + /** Legacy named SQL queries. */ + queries?: DashboardQuery[]; + /** Legacy panel definitions. */ + panels?: DashboardPanel[]; + /** Legacy dashboard layout. */ + layout?: DashboardLayout; +} + /** * Summary view of a dashboard (for listings). */ @@ -826,15 +919,22 @@ export interface DashboardItem extends BaseObject { last_rendered_at?: string; /** Tags for organization */ tags: string[]; + /** Source of the dashboard definition. Defaults to stored dashboards. */ + source?: 'stored' | 'app'; + /** App name when `source` is `app`. */ + app_name?: string; + /** App dashboards are read-only until cloned into a stored dashboard. */ + readonly?: boolean; } /** * Full dashboard with SQL query and Vega-Lite specification. * * **New architecture (v2):** - * - Single `query` field with SQL (use JOINs/CTEs for complex data needs) + * - `dataSource` field with either SQL or Store Elasticsearch DSL * - Single `spec` field with complete Vega-Lite spec (vconcat/hconcat for multiple panels) * - Cross-panel interactivity via Vega selections + * - Legacy top-level `query` fields are treated as a SQL data source * * **Legacy architecture (v1, deprecated):** * - Multiple `queries` with named data sources @@ -844,18 +944,26 @@ export interface DashboardItem extends BaseObject { */ export interface Dashboard extends DashboardItem { // ============= New architecture (v2) ============= + /** + * Data source used to populate Vega `data.values`. + * When omitted, top-level `query` is treated as a SQL data source for backwards compatibility. + */ + dataSource?: DashboardDataSource; /** * SQL query that returns all data for the dashboard. * Use JOINs, CTEs, or UNION ALL to combine data from multiple tables. * Can include {{param_name}} placeholders for dynamic values. + * @deprecated Use `dataSource: { kind: 'data_sql', query }` instead. */ query?: string; /** * Maximum rows to return from the query (default: 10000). + * @deprecated Use `dataSource.queryLimit` instead. */ queryLimit?: number; /** * Default values for SQL parameters. + * @deprecated Use `dataSource.queryParameters` instead. */ queryParameters?: Record; /** @@ -889,15 +997,17 @@ export interface Dashboard extends DashboardItem { /** * Payload for creating a new dashboard. - * Requires query (SQL) and spec (Vega-Lite). + * Requires a data source and spec (Vega-Lite). */ export interface CreateDashboardPayload { /** Dashboard name (unique within store) */ name: string; /** Dashboard summary */ summary?: string; - /** SQL query that returns all data for the dashboard */ - query: string; + /** Data source used to populate Vega `data.values`. */ + dataSource?: DashboardDataSource; + /** SQL query that returns all data for the dashboard. Deprecated shortcut for a SQL data source. */ + query?: string; /** Maximum rows to return from the query (default: 10000) */ queryLimit?: number; /** Default values for SQL {{param}} placeholders */ @@ -914,7 +1024,9 @@ export interface UpdateDashboardPayload { name?: string; /** Dashboard summary */ summary?: string; - /** SQL query that returns all data for the dashboard */ + /** Data source used to populate Vega `data.values`. */ + dataSource?: DashboardDataSource; + /** SQL query that returns all data for the dashboard. Deprecated shortcut for a SQL data source. */ query?: string; /** Maximum rows to return from the query (default: 10000) */ queryLimit?: number; @@ -931,6 +1043,8 @@ export interface UpdateDashboardPayload { */ export interface PreviewDashboardPayload { // ============= New architecture (v2) ============= + /** Data source used to populate Vega `data.values`. */ + dataSource?: DashboardDataSource; /** SQL query that returns all data for the dashboard */ query?: string; /** Maximum rows to return from the query (default: 10000) */ @@ -994,6 +1108,16 @@ export interface DashboardVersion { version_number: number; /** Commit message describing the change */ message: string; + /** Snapshot of v2 data source at this version */ + dataSource?: DashboardDataSource; + /** Snapshot of v2 SQL query at this version */ + query?: string; + /** Snapshot of v2 query limit at this version */ + queryLimit?: number; + /** Snapshot of v2 query parameters at this version */ + queryParameters?: Record; + /** Snapshot of v2 Vega-Lite spec at this version */ + spec?: Record; /** Snapshot of queries at this version */ queries: DashboardQuery[]; /** Snapshot of panels at this version */ diff --git a/packages/common/src/json-schema.ts b/packages/common/src/json-schema.ts index 5fdacb732..d3dd665b2 100644 --- a/packages/common/src/json-schema.ts +++ b/packages/common/src/json-schema.ts @@ -2,7 +2,6 @@ export type { JSONSchema, JSONSchemaArray, JSONSchemaObject, - JSONSchemaProperties, JSONSchemaType, JSONSchemaTypeName, } from "@llumiverse/common"; diff --git a/packages/common/tsconfig.json b/packages/common/tsconfig.json index 7c57abbe0..9db02139d 100644 --- a/packages/common/tsconfig.json +++ b/packages/common/tsconfig.json @@ -19,6 +19,7 @@ /* Bundler mode */ "module": "ESNext", "moduleResolution": "Bundler", + "ignoreDeprecations": "6.0", //"allowImportingTsExtensions": true, //"verbatimModuleSyntax": true, //"noEmit": false, @@ -47,4 +48,4 @@ "path": "../../llumiverse/common/tsconfig.json" }, ] -} \ No newline at end of file +} diff --git a/packages/converters/tsconfig.json b/packages/converters/tsconfig.json index 4e7942931..849404605 100644 --- a/packages/converters/tsconfig.json +++ b/packages/converters/tsconfig.json @@ -19,6 +19,7 @@ /* Bundler mode */ "module": "ESNext", "moduleResolution": "Bundler", + "ignoreDeprecations": "6.0", //"allowImportingTsExtensions": true, //"verbatimModuleSyntax": true, //"noEmit": false, @@ -42,4 +43,4 @@ "lib", "test" ], -} \ No newline at end of file +} diff --git a/packages/create-plugin/src/configuration.ts b/packages/create-plugin/src/configuration.ts index f9d19c9d5..0b76e21bd 100644 --- a/packages/create-plugin/src/configuration.ts +++ b/packages/create-plugin/src/configuration.ts @@ -27,18 +27,6 @@ export const config = { { name: 'Vertesia Plugin', repository: 'vertesia/composableai/templates/plugin-template' - }, - { - name: 'Vertesia Tool Server (deprecated)', - repository: 'vertesia/composableai/templates/tool-server-template' - }, - { - name: 'Vertesia UI Plugin (deprecated)', - repository: 'vertesia/composableai/templates/ui-plugin-template' - }, - { - name: 'Vertesia Workflow Worker', - repository: 'vertesia/composableai/templates/worker-template' } ] as TemplateDefinition[], diff --git a/packages/create-plugin/src/index.ts b/packages/create-plugin/src/index.ts index 7db3f87f7..c17b7d802 100644 --- a/packages/create-plugin/src/index.ts +++ b/packages/create-plugin/src/index.ts @@ -8,6 +8,7 @@ */ import chalk from 'chalk'; +import { execSync } from 'child_process'; import { Command } from 'commander'; import fs from 'fs'; import { config, validation } from './configuration.js'; @@ -39,6 +40,7 @@ async function main() { .option('-y, --yes', 'Non-interactive mode: use defaults for all prompts', false) .option('--dev', 'Use workspace dependencies (development mode)', false) .option('--local-templates ', 'Use local template directory instead of fetching from GitHub') + .option('--skip-install', 'Skip dependency installation after copying and configuring the template', false) .option('--package-manager ', 'Package manager to use: pnpm or npm (overrides template default and interactive selection)') .addHelpText('after', ` Available Templates: @@ -49,8 +51,8 @@ Documentation: ${config.docsUrl} .parse(); let projectName = program.args[0]; - const opts = program.opts<{ branch?: string; template?: string; yes: boolean; dev: boolean; localTemplates?: string; packageManager?: string }>(); - const { branch, template, yes: nonInteractive, dev, localTemplates, packageManager: packageManagerOverride } = opts; + const opts = program.opts<{ branch?: string; template?: string; yes: boolean; dev: boolean; localTemplates?: string; skipInstall: boolean; packageManager?: string }>(); + const { branch, template, yes: nonInteractive, dev, localTemplates, skipInstall, packageManager: packageManagerOverride } = opts; // Prompt for project name if not provided as CLI argument if (!projectName) { @@ -106,11 +108,16 @@ Documentation: ${config.docsUrl} // Step 5: Prompt user for configuration const answers = await promptUser(projectName, templateConfig, nonInteractive); + // Inject package-manager metadata so templates can reference it via {{PM}}, {{PM_RUN}}, {{PM_VERSION}} + answers.PM = packageManager; + answers.PM_RUN = `${packageManager} run`; + answers.PM_VERSION = execSync(`${packageManager} --version`, { encoding: 'utf8' }).trim(); + // Step 5: Replace variables in files replaceVariables(projectName, templateConfig, answers); // Step 6: Adjust package.json (name and workspace dependencies) - adjustPackageJson(projectName, answers, dev); + adjustPackageJson(projectName, answers, dev, packageManager); // Step 7: Handle conditional removes if (templateConfig.conditionalRemove) { @@ -137,6 +144,14 @@ Documentation: ${config.docsUrl} } // Step 10: Install dependencies + if (skipInstall) { + console.log(chalk.yellow('⚠️ Skipping dependency installation (--skip-install).\n')); + console.log(chalk.gray('You can install dependencies manually when needed:\n')); + console.log(chalk.gray(` cd ${projectName}`)); + console.log(chalk.gray(` ${packageManager} install\n`)); + skipDependencyInstall = true; + } + if (!skipDependencyInstall) { await installDependencies(projectName, packageManager); } @@ -163,7 +178,7 @@ function showSuccess(projectName: string, packageManager: string, templateName: console.log(chalk.green.bold('✅ Project created successfully!\n')); console.log(chalk.gray('Next steps:\n')); console.log(chalk.cyan(` cd ${projectName}`)); - console.log(chalk.cyan(` ${packageManager} dev`)); + console.log(chalk.cyan(` ${packageManager} run dev`)); console.log(); console.log(chalk.gray(`Documentation: ${config.docsUrl}`)); console.log(chalk.gray(`Template: ${templateName}`)); @@ -175,4 +190,4 @@ function showSuccess(projectName: string, packageManager: string, templateName: main().catch((error) => { console.error(chalk.red(`\n❌ Fatal error: ${error.message}\n`)); process.exit(1); -}); \ No newline at end of file +}); diff --git a/packages/create-plugin/src/process-template.ts b/packages/create-plugin/src/process-template.ts index 86fdcc2ff..34d51355f 100644 --- a/packages/create-plugin/src/process-template.ts +++ b/packages/create-plugin/src/process-template.ts @@ -172,7 +172,12 @@ export function replaceVariables( * 1. Sets the package name to PROJECT_NAME * 2. Resolves workspace:* dependencies to actual latest versions */ -export function adjustPackageJson(projectName: string, answers: Record, isDev: boolean): void { +export function adjustPackageJson( + projectName: string, + answers: Record, + isDev: boolean, + packageManager: string +): void { console.log(chalk.blue('📝 Adjusting package.json...\n')); const packageJsonPath = path.join(projectName, 'package.json'); @@ -193,6 +198,12 @@ export function adjustPackageJson(projectName: string, answers: Record; } /** diff --git a/packages/create-plugin/src/transforms.ts b/packages/create-plugin/src/transforms.ts index e6309fd57..40e9858ed 100644 --- a/packages/create-plugin/src/transforms.ts +++ b/packages/create-plugin/src/transforms.ts @@ -91,3 +91,15 @@ export function applyTransform(value: string, transform: string): string { export function concatValues(values: string[], separator: string = ''): string { return values.join(separator); } + +/** + * Look up a value in a map (used for the "map" transform) + * @throws Error if the key is not present in the map + */ +export function applyMapTransform(value: string, map: Record): string { + if (!(value in map)) { + const keys = Object.keys(map).join(', '); + throw new Error(`Value "${value}" has no entry in map (expected one of: ${keys})`); + } + return map[value]; +} diff --git a/packages/json/tsconfig.json b/packages/json/tsconfig.json index 7c57abbe0..9db02139d 100644 --- a/packages/json/tsconfig.json +++ b/packages/json/tsconfig.json @@ -19,6 +19,7 @@ /* Bundler mode */ "module": "ESNext", "moduleResolution": "Bundler", + "ignoreDeprecations": "6.0", //"allowImportingTsExtensions": true, //"verbatimModuleSyntax": true, //"noEmit": false, @@ -47,4 +48,4 @@ "path": "../../llumiverse/common/tsconfig.json" }, ] -} \ No newline at end of file +} diff --git a/packages/memory-commands/tsconfig.json b/packages/memory-commands/tsconfig.json index c92e81cb2..1cc142415 100644 --- a/packages/memory-commands/tsconfig.json +++ b/packages/memory-commands/tsconfig.json @@ -19,6 +19,7 @@ /* Bundler mode */ "module": "ESNext", "moduleResolution": "Bundler", + "ignoreDeprecations": "6.0", //"allowImportingTsExtensions": true, //"verbatimModuleSyntax": true, //"noEmit": false, @@ -47,4 +48,4 @@ "path": "../memory/tsconfig.json" } ] -} \ No newline at end of file +} diff --git a/packages/memory/tsconfig.json b/packages/memory/tsconfig.json index 6be53762b..ac97d8e71 100644 --- a/packages/memory/tsconfig.json +++ b/packages/memory/tsconfig.json @@ -19,6 +19,7 @@ /* Bundler mode */ "module": "ESNext", "moduleResolution": "Bundler", + "ignoreDeprecations": "6.0", //"allowImportingTsExtensions": true, //"verbatimModuleSyntax": true, //"noEmit": false, @@ -46,4 +47,4 @@ { "path": "../converters/tsconfig.json" }, { "path": "../json/tsconfig.json" } ] -} \ No newline at end of file +} diff --git a/packages/tools-admin-ui/src/AdminApp.tsx b/packages/tools-admin-ui/src/AdminApp.tsx index a9b2cd144..a56b0612b 100644 --- a/packages/tools-admin-ui/src/AdminApp.tsx +++ b/packages/tools-admin-ui/src/AdminApp.tsx @@ -49,7 +49,7 @@ export function AdminApp({ baseUrl = '/api' }: AdminAppProps) { const { data: serverInfo, isLoading: loadingInfo, error: infoError } = useServerInfo(baseUrl); const { data: resourceData, isLoading: loadingData, error: dataError } = useResourceData( baseUrl, - serverInfo?.endpoints.mcp, + serverInfo?.endpoints?.mcp, ); const isLoading = loadingInfo || loadingData; diff --git a/packages/tools-admin-ui/src/hooks.ts b/packages/tools-admin-ui/src/hooks.ts index e9c7baa22..16278bc19 100644 --- a/packages/tools-admin-ui/src/hooks.ts +++ b/packages/tools-admin-ui/src/hooks.ts @@ -7,12 +7,22 @@ import { useFetch } from '@vertesia/ui/core'; import type { ResourceData, ServerInfo } from './types.js'; import { buildResourceData } from './types.js'; +type ResourceDataArgs = Parameters; + +async function fetchJson(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Request failed: ${response.status} ${response.statusText}`); + } + return response.json() as Promise; +} + /** * Fetches the tool server info (message, version, endpoints). */ export function useServerInfo(baseUrl: string) { return useFetch(() => - fetch(baseUrl).then(r => r.json()), + fetchJson(baseUrl), [baseUrl] ); } @@ -23,14 +33,14 @@ export function useServerInfo(baseUrl: string) { */ export function useResourceData(baseUrl: string, mcpEndpoints?: string[]) { return useFetch(() => { - const fetchJson = (path: string) => fetch(`${baseUrl}/${path}`).then(r => r.json()); + const fetchResource = (path: string) => fetchJson(`${baseUrl}/${path}`); return Promise.all([ - fetchJson('interactions'), - fetchJson('tools'), - fetchJson('skills'), - fetchJson('activities'), - fetchJson('types'), - fetchJson('templates'), + fetchResource('interactions'), + fetchResource('tools'), + fetchResource('skills'), + fetchResource('activities'), + fetchResource('types'), + fetchResource('templates'), ]).then(([interactions, tools, skills, activities, types, templates]) => buildResourceData(interactions, tools, skills, activities, types, templates, mcpEndpoints) ); diff --git a/packages/tools-sdk/src/server.ts b/packages/tools-sdk/src/server.ts index 9b9b5f3d5..38f70691f 100644 --- a/packages/tools-sdk/src/server.ts +++ b/packages/tools-sdk/src/server.ts @@ -14,6 +14,7 @@ import { createTemplatesRoute } from "./server/templates.js"; import { createWidgetsRoute } from "./server/widgets.js"; import { createPackageRoute } from "./server/app-package.js"; import { createContentTypesRoute } from "./server/content-types.js"; +import { createDashboardsRoute } from "./server/dashboards.js"; import { createProcessesRoute } from "./server/processes.js"; // Schema for tool execution payload @@ -51,6 +52,7 @@ export function createToolServer(config: ToolServerConfig): Hono { skills = [], templates = [], activities = [], + dashboards = [], mcpProviders = [], disableHtml = false, } = config; @@ -102,6 +104,7 @@ export function createToolServer(config: ToolServerConfig): Hono { interactions: interactions.map(col => `${prefix}/interactions/${col.name}`), templates: templates.map(col => `${prefix}/templates/${col.name}`), processes: `${prefix}/processes`, + dashboards: dashboards.length > 0 ? `${prefix}/dashboards` : undefined, activities: activities.map(col => `${prefix}/activities/${col.name}`), mcp: mcpProviders.map(p => `${prefix}/mcp/${p.name}`), } @@ -118,6 +121,7 @@ export function createToolServer(config: ToolServerConfig): Hono { createTemplatesRoute(app, `${prefix}/templates`, config); createContentTypesRoute(app, `${prefix}/types`, config); createProcessesRoute(app, `${prefix}/processes`, config); + createDashboardsRoute(app, `${prefix}/dashboards`, config); createMcpRoute(app, `${prefix}/mcp`, config); diff --git a/packages/tools-sdk/src/server/app-package.ts b/packages/tools-sdk/src/server/app-package.ts index 4ce520bcd..ee318ab21 100644 --- a/packages/tools-sdk/src/server/app-package.ts +++ b/packages/tools-sdk/src/server/app-package.ts @@ -1,4 +1,13 @@ -import { AppPackage, AppPackageScope, AppWidgetInfo, CatalogInteractionRef, InCodeProcessDefinition, InCodeTypeDefinition, RemoteActivityDefinition } from "@vertesia/common"; +import type { + AppDashboardDefinition, + AppPackage, + AppPackageScope, + AppWidgetInfo, + CatalogInteractionRef, + InCodeProcessDefinition, + InCodeTypeDefinition, + RemoteActivityDefinition, +} from "@vertesia/common"; import { Context, Hono } from "hono"; import { ToolUseContext } from "../types.js"; import { ToolServerConfig } from "./types.js"; @@ -7,7 +16,9 @@ function getRequestPayload(c: Context): Promise { return c.req.method === "POST" ? c.req.json() : Promise.resolve(undefined); } -const builders: Record, (pkg: AppPackage, config: ToolServerConfig, c: Context) => Promise> = { +type AppPackageBuilder = (pkg: AppPackage, config: ToolServerConfig, c: Context) => Promise; + +const builders: Record, AppPackageBuilder> = { async tools(pkg: AppPackage, config: ToolServerConfig, c: Context) { const { tools: toolCollections = [], skills: skillCollections = [] } = config; @@ -79,6 +90,19 @@ const builders: Record, (pkg: AppPackage, config })) ); }, + async dashboards(pkg: AppPackage, config: ToolServerConfig) { + const seen = new Set(); + const dashboards: AppDashboardDefinition[] = []; + for (const dashboard of config.dashboards || []) { + if (seen.has(dashboard.id)) { + console.warn(`[app-package] Duplicate dashboard id "${dashboard.id}", skipping`); + continue; + } + seen.add(dashboard.id); + dashboards.push(dashboard); + } + pkg.dashboards = dashboards; + }, async widgets(pkg: AppPackage, config: ToolServerConfig) { const { skills: skillCollections = [] } = config; const widgets: Record = {}; @@ -134,6 +158,7 @@ async function handlePackageRequest(c: Context, config: ToolServerConfig) { await builders.types(pkg, config, c); await builders.processes(pkg, config, c); await builders.templates(pkg, config, c); + await builders.dashboards(pkg, config, c); await builders.widgets(pkg, config, c); await builders.ui(pkg, config, c); await builders.settings(pkg, config, c); @@ -154,6 +179,9 @@ async function handlePackageRequest(c: Context, config: ToolServerConfig) { if (scopes.has('templates')) { await builders.templates(pkg, config, c); } + if (scopes.has('dashboards')) { + await builders.dashboards(pkg, config, c); + } if (scopes.has('widgets')) { await builders.widgets(pkg, config, c); } diff --git a/packages/tools-sdk/src/server/dashboards.ts b/packages/tools-sdk/src/server/dashboards.ts new file mode 100644 index 000000000..a7ecf9096 --- /dev/null +++ b/packages/tools-sdk/src/server/dashboards.ts @@ -0,0 +1,35 @@ +import { AppDashboardDefinition } from "@vertesia/common"; +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { ToolServerConfig } from "./types.js"; + +export function createDashboardsRoute(app: Hono, basePath: string, config: ToolServerConfig) { + const { dashboards = [] } = config; + + app.get(basePath, (c) => { + return c.json({ + title: 'All Dashboards', + description: 'All available dashboard definitions', + dashboards, + }); + }); + + app.get(`${basePath}/:id`, (c) => { + const id = c.req.param('id'); + const dashboard = findDashboard(dashboards, id); + if (!dashboard) { + throw new HTTPException(404, { + message: "No dashboard found with id: " + id, + }); + } + return c.json(dashboard); + }); +} + +function findDashboard(dashboards: AppDashboardDefinition[], id: string): AppDashboardDefinition | undefined { + return dashboards.find(dashboard => + dashboard.id === id + || dashboard.name === id + || dashboard.title === id + ); +} diff --git a/packages/tools-sdk/src/server/types.ts b/packages/tools-sdk/src/server/types.ts index 2c5f72155..f8706e426 100644 --- a/packages/tools-sdk/src/server/types.ts +++ b/packages/tools-sdk/src/server/types.ts @@ -6,7 +6,7 @@ import { RenderingTemplateCollection } from "../RenderingTemplateCollection.js"; import { ToolCollection } from "../ToolCollection.js"; import { ToolExecutionPayload } from "../types.js"; import { JSONSchema } from "@llumiverse/common"; -import { AppUIConfig, InCodeProcessDefinition, ProjectConfiguration } from "@vertesia/common"; +import { AppDashboardDefinition, AppUIConfig, InCodeProcessDefinition, ProjectConfiguration } from "@vertesia/common"; import { ContentTypesCollection } from "../ContentTypesCollection.js"; /** @@ -66,6 +66,10 @@ export interface ToolServerConfig { * Process definitions to expose as app-contributed processes. */ processes?: InCodeProcessDefinition[]; + /** + * Dashboard definitions to expose as app-contributed dashboards. + */ + dashboards?: AppDashboardDefinition[]; /** * Skill collections to expose */ diff --git a/packages/tools-sdk/tsconfig.json b/packages/tools-sdk/tsconfig.json index 6c40dc684..4beabacf7 100644 --- a/packages/tools-sdk/tsconfig.json +++ b/packages/tools-sdk/tsconfig.json @@ -20,6 +20,7 @@ /* Bundler mode */ "module": "ESNext", "moduleResolution": "Bundler", + "ignoreDeprecations": "6.0", //"allowImportingTsExtensions": true, //"verbatimModuleSyntax": true, //"noEmit": false, @@ -53,4 +54,4 @@ , { "path": "../../llumiverse/common/tsconfig.json" } ] -} \ No newline at end of file +} diff --git a/packages/ui/llms.txt b/packages/ui/llms.txt index 023d945ed..e889e3dbc 100644 --- a/packages/ui/llms.txt +++ b/packages/ui/llms.txt @@ -5,14 +5,14 @@ ## Reference URLs -- Visual style guide: https://docs-ui-style-shadcn.vertesia.dev -- Markdown component guide: https://docs-ui-style-shadcn.vertesia.dev/components.md -- LLM reference: https://docs-ui-style-shadcn.vertesia.dev/llms.txt +- Visual style guide: https://design.vertesiahq.com +- Markdown component guide: https://design.vertesiahq.com/components.md +- LLM reference: https://design.vertesiahq.com/llms.txt # @vertesia/ui Component Reference > Markdown rendition of the Vertesia UI style guide for LLMs and text-first clients. -> Visual style guide: https://docs-ui-style-shadcn.vertesia.dev +> Visual style guide: https://design.vertesiahq.com Use this file when generating React UI for Vertesia apps or plugins. Prefer `@vertesia/ui` components over local duplicates, and use semantic color classes instead of hardcoded colors. @@ -31,65 +31,65 @@ import { useUserSession } from '@vertesia/ui/session'; ## Classes -- [Semantic](https://docs-ui-style-shadcn.vertesia.dev/semantic) -- [Background](https://docs-ui-style-shadcn.vertesia.dev/classes/background) -- [Text](https://docs-ui-style-shadcn.vertesia.dev/classes/text) -- [Border & Ring](https://docs-ui-style-shadcn.vertesia.dev/classes/border) -- [Icons](https://docs-ui-style-shadcn.vertesia.dev/classes/icons) +- [Semantic](https://design.vertesiahq.com/semantic) +- [Background](https://design.vertesiahq.com/classes/background) +- [Text](https://design.vertesiahq.com/classes/text) +- [Border & Ring](https://design.vertesiahq.com/classes/border) +- [Icons](https://design.vertesiahq.com/classes/icons) ## Components -- [Heading](https://docs-ui-style-shadcn.vertesia.dev/components/heading) -- [Buttons](https://docs-ui-style-shadcn.vertesia.dev/components/buttons) -- [Badges](https://docs-ui-style-shadcn.vertesia.dev/components/badges) -- [Dot Badges](https://docs-ui-style-shadcn.vertesia.dev/components/dotBadges) -- [Message Box](https://docs-ui-style-shadcn.vertesia.dev/components/messageBox) -- [Modal](https://docs-ui-style-shadcn.vertesia.dev/components/modal) -- [Confirm Modal](https://docs-ui-style-shadcn.vertesia.dev/components/confirmModal) -- [Delete Modal](https://docs-ui-style-shadcn.vertesia.dev/components/deleteModal) -- [Filter](https://docs-ui-style-shadcn.vertesia.dev/components/filter) -- [Tabs](https://docs-ui-style-shadcn.vertesia.dev/components/tabs) -- [Tooltips](https://docs-ui-style-shadcn.vertesia.dev/components/tooltips) -- [Switch](https://docs-ui-style-shadcn.vertesia.dev/components/switch) -- [Side Panel](https://docs-ui-style-shadcn.vertesia.dev/components/sidePanel) -- [SelectBox](https://docs-ui-style-shadcn.vertesia.dev/components/selectBox) -- [Overlay](https://docs-ui-style-shadcn.vertesia.dev/components/overlay) -- [Breadcrumbs](https://docs-ui-style-shadcn.vertesia.dev/components/breadcrumb) -- [Monaco](https://docs-ui-style-shadcn.vertesia.dev/components/monaco) -- [Card](https://docs-ui-style-shadcn.vertesia.dev/components/card) -- [Checkbox](https://docs-ui-style-shadcn.vertesia.dev/components/checkbox) -- [Collapsible](https://docs-ui-style-shadcn.vertesia.dev/components/collapsible) -- [Command](https://docs-ui-style-shadcn.vertesia.dev/components/command) -- [Dropdown](https://docs-ui-style-shadcn.vertesia.dev/components/dropdown) -- [Label](https://docs-ui-style-shadcn.vertesia.dev/components/label) -- [Panel](https://docs-ui-style-shadcn.vertesia.dev/components/panel) -- [Popover](https://docs-ui-style-shadcn.vertesia.dev/components/popover) -- [Radio Group](https://docs-ui-style-shadcn.vertesia.dev/components/radioGroup) -- [Resizeable](https://docs-ui-style-shadcn.vertesia.dev/components/resizeable) -- [Separator](https://docs-ui-style-shadcn.vertesia.dev/components/separator) -- [Text](https://docs-ui-style-shadcn.vertesia.dev/components/text) -- [Theme](https://docs-ui-style-shadcn.vertesia.dev/components/theme) +- [Heading](https://design.vertesiahq.com/components/heading) +- [Buttons](https://design.vertesiahq.com/components/buttons) +- [Badges](https://design.vertesiahq.com/components/badges) +- [Dot Badges](https://design.vertesiahq.com/components/dotBadges) +- [Message Box](https://design.vertesiahq.com/components/messageBox) +- [Modal](https://design.vertesiahq.com/components/modal) +- [Confirm Modal](https://design.vertesiahq.com/components/confirmModal) +- [Delete Modal](https://design.vertesiahq.com/components/deleteModal) +- [Filter](https://design.vertesiahq.com/components/filter) +- [Tabs](https://design.vertesiahq.com/components/tabs) +- [Tooltips](https://design.vertesiahq.com/components/tooltips) +- [Switch](https://design.vertesiahq.com/components/switch) +- [Side Panel](https://design.vertesiahq.com/components/sidePanel) +- [SelectBox](https://design.vertesiahq.com/components/selectBox) +- [Overlay](https://design.vertesiahq.com/components/overlay) +- [Breadcrumbs](https://design.vertesiahq.com/components/breadcrumb) +- [Monaco](https://design.vertesiahq.com/components/monaco) +- [Card](https://design.vertesiahq.com/components/card) +- [Checkbox](https://design.vertesiahq.com/components/checkbox) +- [Collapsible](https://design.vertesiahq.com/components/collapsible) +- [Command](https://design.vertesiahq.com/components/command) +- [Dropdown](https://design.vertesiahq.com/components/dropdown) +- [Label](https://design.vertesiahq.com/components/label) +- [Panel](https://design.vertesiahq.com/components/panel) +- [Popover](https://design.vertesiahq.com/components/popover) +- [Radio Group](https://design.vertesiahq.com/components/radioGroup) +- [Resizeable](https://design.vertesiahq.com/components/resizeable) +- [Separator](https://design.vertesiahq.com/components/separator) +- [Text](https://design.vertesiahq.com/components/text) +- [Theme](https://design.vertesiahq.com/components/theme) ## Layout & Structure -- [Avatar](https://docs-ui-style-shadcn.vertesia.dev/components/avatar) -- [Center](https://docs-ui-style-shadcn.vertesia.dev/components/center) -- [Divider](https://docs-ui-style-shadcn.vertesia.dev/components/divider) -- [Link](https://docs-ui-style-shadcn.vertesia.dev/components/link) -- [Portal](https://docs-ui-style-shadcn.vertesia.dev/components/portal) -- [Spinner](https://docs-ui-style-shadcn.vertesia.dev/components/spinner) +- [Avatar](https://design.vertesiahq.com/components/avatar) +- [Center](https://design.vertesiahq.com/components/center) +- [Divider](https://design.vertesiahq.com/components/divider) +- [Link](https://design.vertesiahq.com/components/link) +- [Portal](https://design.vertesiahq.com/components/portal) +- [Spinner](https://design.vertesiahq.com/components/spinner) ## Forms -- [FormItem](https://docs-ui-style-shadcn.vertesia.dev/components/formItem) -- [Inputs](https://docs-ui-style-shadcn.vertesia.dev/components/inputs) -- [Textarea](https://docs-ui-style-shadcn.vertesia.dev/components/textarea) -- [ComboBox](https://docs-ui-style-shadcn.vertesia.dev/components/comboBox) -- [InputList](https://docs-ui-style-shadcn.vertesia.dev/components/inputList) -- [MenuList](https://docs-ui-style-shadcn.vertesia.dev/components/menuList) -- [NumberInput](https://docs-ui-style-shadcn.vertesia.dev/components/numberInput) -- [SelectList](https://docs-ui-style-shadcn.vertesia.dev/components/selectList) -- [TagsInput](https://docs-ui-style-shadcn.vertesia.dev/components/tagsInput) +- [FormItem](https://design.vertesiahq.com/components/formItem) +- [Inputs](https://design.vertesiahq.com/components/inputs) +- [Textarea](https://design.vertesiahq.com/components/textarea) +- [ComboBox](https://design.vertesiahq.com/components/comboBox) +- [InputList](https://design.vertesiahq.com/components/inputList) +- [MenuList](https://design.vertesiahq.com/components/menuList) +- [NumberInput](https://design.vertesiahq.com/components/numberInput) +- [SelectList](https://design.vertesiahq.com/components/selectList) +- [TagsInput](https://design.vertesiahq.com/components/tagsInput) # Component Guidance @@ -97,7 +97,7 @@ import { useUserSession } from '@vertesia/ui/session'; Primary action primitive with Vertesia variants, loading state, disabled state, and tooltip support. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/buttons +- Route: https://design.vertesiahq.com/components/buttons - Import path: `@vertesia/ui/core` - Exports: `Button`, `CopyButton`, `buttonVariants` @@ -147,7 +147,7 @@ import { Button } from '@vertesia/ui/core'; Single-line text input with a simplified value-based `onChange` callback. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/inputs +- Route: https://design.vertesiahq.com/components/inputs - Import path: `@vertesia/ui/core` - Exports: `Input` @@ -184,7 +184,7 @@ const [name, setName] = useState(''); Multi-line text control using standard React textarea event handling. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/textarea +- Route: https://design.vertesiahq.com/components/textarea - Import path: `@vertesia/ui/core` - Exports: `Textarea` @@ -215,7 +215,7 @@ Multi-line text control using standard React textarea event handling. Field wrapper for labels, required indicators, and help tooltips. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/formItem +- Route: https://design.vertesiahq.com/components/formItem - Import path: `@vertesia/ui/core` - Exports: `FormItem` @@ -248,7 +248,7 @@ Field wrapper for labels, required indicators, and help tooltips. Accessible overlay dialog for focused tasks and forms. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/modal +- Route: https://design.vertesiahq.com/components/modal - Import path: `@vertesia/ui/core` - Exports: `Modal`, `ModalTitle`, `ModalBody`, `ModalFooter` @@ -293,7 +293,7 @@ Accessible overlay dialog for focused tasks and forms. Confirmation dialog for actions that need explicit user consent. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/confirmModal +- Route: https://design.vertesiahq.com/components/confirmModal - Import path: `@vertesia/ui/core` - Exports: `ConfirmModal` @@ -316,7 +316,7 @@ Confirmation dialog for actions that need explicit user consent. Delete-specific confirmation wrapper with async delete handling and toast feedback. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/deleteModal +- Route: https://design.vertesiahq.com/components/deleteModal - Import path: `@vertesia/ui/core` - Exports: `DeleteModal` @@ -342,7 +342,7 @@ Delete-specific confirmation wrapper with async delete handling and toast feedba Dropdown select for string or object options, with optional search, clear, and multi-select modes. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/selectBox +- Route: https://design.vertesiahq.com/components/selectBox - Import path: `@vertesia/ui/core` - Exports: `SelectBox` @@ -383,7 +383,7 @@ Dropdown select for string or object options, with optional search, clear, and m Tab container driven by an array of tab objects and companion bar/panel components. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/tabs +- Route: https://design.vertesiahq.com/components/tabs - Import path: `@vertesia/ui/core` - Exports: `Tabs`, `TabsBar`, `TabsPanel`, `VTabs`, `VTabsBar`, `VTabsPanel` @@ -408,7 +408,7 @@ Tab container driven by an array of tab objects and companion bar/panel componen Compact status and category labels. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/badges +- Route: https://design.vertesiahq.com/components/badges - Import path: `@vertesia/ui/core` - Exports: `Badge`, `DotBadge` @@ -425,7 +425,7 @@ Compact status and category labels. Semantic feedback box for success, attention, destructive, done, info, and muted messages. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/messageBox +- Route: https://design.vertesiahq.com/components/messageBox - Import path: `@vertesia/ui/core` - Exports: `MessageBox` @@ -437,7 +437,7 @@ Semantic feedback box for success, attention, destructive, done, info, and muted Choice controls for boolean values and mutually exclusive option sets. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/checkbox +- Route: https://design.vertesiahq.com/components/checkbox - Import path: `@vertesia/ui/core` - Exports: `Checkbox`, `Switch`, `RadioGroup`, `RadioGroupItem`, `RadioGroupOption` @@ -451,7 +451,7 @@ Choice controls for boolean values and mutually exclusive option sets. Overlay primitives for action menus, floating content, command palettes, and contextual help. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/dropdown +- Route: https://design.vertesiahq.com/components/dropdown - Import path: `@vertesia/ui/core` - Exports: `Dropdown`, `MenuGroup`, `MenuItem`, `Popover`, `Command`, `VTooltip`, `Tooltip` @@ -466,7 +466,7 @@ Overlay primitives for action menus, floating content, command palettes, and con Content containers for repeated items and framed tool surfaces. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/card +- Route: https://design.vertesiahq.com/components/card - Import path: `@vertesia/ui/core` - Exports: `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter`, `Panel` @@ -479,7 +479,7 @@ Content containers for repeated items and framed tool surfaces. Small primitives for display, separation, links, loading states, portals, and overlays. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/center +- Route: https://design.vertesiahq.com/components/center - Import path: `@vertesia/ui/core` - Exports: `Avatar`, `SvgAvatar`, `Center`, `Divider`, `Link`, `Portal`, `Spinner`, `Separator`, `Overlay` @@ -493,7 +493,7 @@ Small primitives for display, separation, links, loading states, portals, and ov Semantic Tailwind classes and approved lucide-react icon guidance for Vertesia apps. -- Route: https://docs-ui-style-shadcn.vertesia.dev/semantic +- Route: https://design.vertesiahq.com/semantic - Import path: `@vertesia/ui/core` - Exports: `ModeToggle` diff --git a/packages/ui/src/core/components/shadcn/filters/filterBar.tsx b/packages/ui/src/core/components/shadcn/filters/filterBar.tsx index 752117f5c..2ba4cd41a 100644 --- a/packages/ui/src/core/components/shadcn/filters/filterBar.tsx +++ b/packages/ui/src/core/components/shadcn/filters/filterBar.tsx @@ -357,7 +357,7 @@ const FilterClear = ({ className }: { className?: string }) => { return ( - + {zoom}% - - - - - - + + ); } \ No newline at end of file diff --git a/packages/ui/src/features/store/objects/components/ContentOverview.tsx b/packages/ui/src/features/store/objects/components/ContentOverview.tsx index dd0107e73..2a7f9981b 100644 --- a/packages/ui/src/features/store/objects/components/ContentOverview.tsx +++ b/packages/ui/src/features/store/objects/components/ContentOverview.tsx @@ -1,14 +1,15 @@ -import { memo, useEffect, useRef, useState, type RefObject } from "react"; +import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react"; -import { AUDIO_RENDITION_NAME, AudioMetadata, ContentNature, ContentObject, ContentObjectStatus, DocAnalyzerProgress, DocProcessorOutputFormat, DocumentMetadata, ImageRenditionFormat, MarkdownRenditionFormat, PDF_RENDITION_NAME, Permission, POSTER_RENDITION_NAME, VideoMetadata, WorkflowExecutionStatus } from "@vertesia/common"; +import { ContentNature, ContentObject, ContentObjectStatus, DocAnalyzerProgress, DocProcessorOutputFormat, DocumentMetadata, MarkdownRenditionFormat, PDF_RENDITION_NAME, Permission, WorkflowExecutionStatus } from "@vertesia/common"; import { Button, Dropdown, MenuItem, Portal, ResizableHandle, ResizablePanel, ResizablePanelGroup, Spinner, useToast } from "@vertesia/ui/core"; import { NavLink } from "@vertesia/ui/router"; import { useUserSession } from "@vertesia/ui/session"; -import { JSONDisplay, MarkdownRenderer, Progress, XMLViewer } from "@vertesia/ui/widgets"; +import { JSONDisplay, Progress } from "@vertesia/ui/widgets"; import { AlertTriangle, Copy, Download, FileSearch, SquarePen } from "lucide-react"; import { useUITranslation } from '../../../../i18n/index.js'; +import { UniversalDocumentViewer, type UniversalDocumentConverter, type UniversalDocumentSource } from "../../../document-viewer/UniversalDocumentViewer.js"; import { MagicPdfView } from "../../../magic-pdf"; -import { SimplePdfViewer } from "../../../pdf-viewer"; +import { AudioPanel, ImagePanel, VideoPanel } from "../../../media-viewer"; import { SecureButton } from "../../../permissions/SecureButton.js"; import { getWorkflowStatusColor, getWorkflowStatusName, isPreviewableAsPdf } from "../../../utils/index.js"; import { PropertiesEditorModal } from "./PropertiesEditorModal"; @@ -16,23 +17,6 @@ import { TextEditorPanel } from "./TextEditorPanel.js"; import { useObjectText, useOfficePdfConversion, usePdfProcessingStatus } from "./useContentPanelHooks.js"; import { useDownloadFile } from "./useDownloadFile.js"; -// Web-supported image formats for browser display -const WEB_SUPPORTED_IMAGE_FORMATS = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']; - -// Web-supported video formats for browser display -const WEB_SUPPORTED_VIDEO_FORMATS = ['video/mp4', 'video/webm']; - -// Web-supported audio formats for browser display -const WEB_SUPPORTED_AUDIO_FORMATS = [ - 'audio/mp4', // M4A/AAC (official MIME type) - 'audio/m4a', // M4A (alternative MIME type) - 'audio/x-m4a', // M4A (legacy MIME type) - 'audio/mpeg', // MP3 - 'audio/ogg', // Ogg Vorbis - 'audio/wav', // WAV - 'audio/webm', // WebM audio -]; - // ----- Type Definitions ----- interface TextActionsProps { @@ -54,11 +38,12 @@ interface TextPanelProps { } interface OfficePdfPreviewPanelProps { + object: ContentObject; pdfRendition?: { content?: { source?: string } }; officePdfUrl?: string; + converters: UniversalDocumentConverter[]; officePdfConverting: boolean; officePdfError?: string; - onConvert: () => void; } interface OfficePdfActionsProps { @@ -135,23 +120,6 @@ function getContentProcessorType(object: ContentObject): string | undefined { return (object.metadata as DocumentMetadata)?.content_processor?.type; } -/** - * Check if text content appears to be markdown based on common patterns. - */ -function looksLikeMarkdown(text: string | undefined): boolean { - if (!text) return false; - return ( - text.includes("\n# ") || - text.includes("\n## ") || - text.includes("\n### ") || - text.includes("\n* ") || - text.includes("\n- ") || - text.includes("\n+ ") || - text.includes("![") || - text.includes("](") - ); -} - /** * Helper function to get panel visibility className. * Returns empty string if visible, 'hidden' if not visible. @@ -313,6 +281,7 @@ function PropertiesPanel({ object, refetch, handleCopyContent }: { object: Conte } function DataPanel({ object, loadText, handleCopyContent, refetch }: { object: ContentObject, loadText: boolean, handleCopyContent: (content: string, type: "text" | "properties") => Promise, refetch?: () => Promise }) { + const { client } = useUserSession(); const { t } = useUITranslation(); const isImage = object?.metadata?.type === ContentNature.Image; const isVideo = object?.metadata?.type === ContentNature.Video; @@ -379,9 +348,34 @@ function DataPanel({ object, loadText, handleCopyContent, refetch }: { object: C pdfUrl: officePdfUrl, isConverting: officePdfConverting, error: officePdfError, - triggerConversion: triggerOfficePdfConversion, } = useOfficePdfConversion(object.id, isPreviewableAsPdfDoc); + const officePdfConverters = useMemo(() => [{ + id: 'office-pdf-rendition', + target: 'pdf', + canConvert: ({ contentType, extension }) => isPreviewableAsPdfDoc + || (contentType ? isPreviewableAsPdf(contentType) : false) + || ['doc', 'docx', 'ppt', 'pptx'].includes(extension), + convert: async () => { + const response = await client.objects.getRendition(object.id, { + format: MarkdownRenditionFormat.pdf, + generate_if_missing: true, + sign_url: true, + block_on_generation: true, + }); + + if (response.status === "found" && response.renditions?.length) { + return { + url: response.renditions[0], + contentType: 'application/pdf', + fileName: `${object.name || 'document'}.pdf`, + }; + } + + return null; + }, + }], [client, isPreviewableAsPdfDoc, object.id, object.name]); + // Load text once processing completes without triggering a full object refetch // (which would flash the page-level loading spinner). useEffect(() => { @@ -468,9 +462,6 @@ function DataPanel({ object, loadText, handleCopyContent, refetch }: { object: C alt={t('store.viewAsPdf')} onClick={() => { setCurrentPanel(PanelView.Pdf); - if (!pdfRendition && !officePdfUrl && !officePdfConverting) { - triggerOfficePdfConversion(); - } }} disabled={officePdfConverting} > @@ -528,11 +519,12 @@ function DataPanel({ object, loadText, handleCopyContent, refetch }: { object: C {isPreviewableAsPdfDoc && keepPdfPreviewMounted && (
)} @@ -713,21 +705,16 @@ const TextPanel = memo(({ textContainerRef, }: TextPanelProps) => { const { t } = useUITranslation(); - const content = object.content; const isCreatedOrProcessing = isCreatedOrProcessingStatus(object?.status); - - // Check content processor type for XML const contentProcessorType = getContentProcessorType(object); - const isXml = contentProcessorType === "xml"; - - // Check if content type is markdown or plain text - const isMarkdownOrText = - content && - content.type && - (content.type === "text/markdown" || content.type === "text/plain"); - - // Render as markdown if it's markdown/text type OR if text looks like markdown (but not if XML) - const shouldRenderAsMarkdown = !isXml && (isMarkdownOrText || looksLikeMarkdown(text)); + const source: UniversalDocumentSource = { + id: object.id, + title: object.name, + fileName: object.content?.name || object.name, + contentType: contentProcessorType === "xml" ? "text/xml" : object.content?.type, + content: text, + sourcePath: object.content?.source, + }; return ( text ? ( @@ -744,21 +731,12 @@ const TextPanel = memo(({ className={`max-w-7xl px-2 h-full overflow-auto`} ref={textContainerRef} > - {isXml ? ( -
- -
- ) : shouldRenderAsMarkdown ? ( -
- - {text} - -
- ) : ( -
-                            {text}
-                        
- )} + ) : @@ -768,267 +746,6 @@ const TextPanel = memo(({ ); }); -function ImagePanel({ object }: { object: ContentObject }) { - const { client } = useUserSession(); - const [imageUrl, setImageUrl] = useState(); - - const content = object.content; - const isImage = object.metadata && object.metadata.type === ContentNature.Image; - - useEffect(() => { - if (isImage) { - // Reset image URL when object changes - setImageUrl(undefined); - - const loadImage = async () => { - const isOriginalWebSupported = content?.type && WEB_SUPPORTED_IMAGE_FORMATS.includes(content.type); - - try { - const rendition = await client.objects.getRendition(object.id, { - format: ImageRenditionFormat.jpeg, - generate_if_missing: false, - sign_url: true, - }); - - if (rendition.status === "found" && rendition.renditions?.length) { - // Use rendition URL directly - setImageUrl(rendition.renditions[0]); - } else if (isOriginalWebSupported) { - // Fall back to original file only if web-supported - const downloadUrl = await client.files.getDownloadUrl(object.content.source!); - setImageUrl(downloadUrl.url); - } - } catch (error) { - // Fall back to original file only if web-supported - if (isOriginalWebSupported) { - const downloadUrl = await client.files.getDownloadUrl(object.content.source!); - setImageUrl(downloadUrl.url); - } - } - }; - - loadImage(); - } - }, [object.id, isImage, content?.type, content?.source, client]); - - return ( -
- {imageUrl ? ( - {object.name} - ) : ( - - )} -
- ); -} - -function VideoPanel({ object }: { object: ContentObject }) { - const { t } = useUITranslation(); - const { client } = useUserSession(); - const [videoUrl, setVideoUrl] = useState(); - const [posterUrl, setPosterUrl] = useState(); - const [isLoading, setIsLoading] = useState(true); - - const content = object.content; - const isVideo = object.metadata?.type === ContentNature.Video; - - // Check if there are mp4 or webm renditions available in metadata - const metadata = object.metadata as VideoMetadata; - const renditions = metadata?.renditions || []; - - // Find mp4 or webm rendition by mime type, preferring mp4 - const webRendition = renditions.find(r => r.content.type === 'video/mp4') || - renditions.find(r => r.content.type === 'video/webm'); - - // Check if original file is web-compatible - const isOriginalWebSupported = content?.type && WEB_SUPPORTED_VIDEO_FORMATS.includes(content.type); - - // Get poster - const poster = renditions.find(r => r.name === POSTER_RENDITION_NAME); - - // Reset state when object changes - useEffect(() => { - setVideoUrl(undefined); - setPosterUrl(undefined); - setIsLoading(true); - }, [object.id]); - - useEffect(() => { - const loadPoster = async () => { - if (poster?.content?.source) { - try { - const response = await client.files.getDownloadUrl(poster.content.source); - setPosterUrl(response.url); - } catch (error) { - console.error("Failed to load poster image", error); - } - } - }; - loadPoster(); - }, [poster, client]); - - useEffect(() => { - if (isVideo && (webRendition?.content?.source || isOriginalWebSupported)) { - const loadVideoUrl = async () => { - try { - let downloadUrl; - if (webRendition?.content?.source) { - // Use rendition if available - downloadUrl = await client.files.getDownloadUrl(webRendition.content.source); - } else if (isOriginalWebSupported && content?.source) { - // Fall back to original file if web-supported - downloadUrl = await client.files.getDownloadUrl(content.source); - } - if (downloadUrl) { - setVideoUrl(downloadUrl.url); - } - } catch (error) { - console.error("Failed to get video URL", error); - } finally { - setIsLoading(false); - } - }; - loadVideoUrl(); - } else { - setIsLoading(false); - } - }, [isVideo, webRendition, isOriginalWebSupported, content?.source, client]); - - return ( -
- {!webRendition && !isOriginalWebSupported ? ( -
-
-

{t('store.noVideoRendition')}

-

{t('store.videoFormatRequired')}

-
-
- ) : isLoading ? ( -
- -
- ) : videoUrl ? ( - - ) : ( -
- Failed to load video -
- )} -
- ); -} - -function AudioPanel({ object }: { object: ContentObject }) { - const { t } = useUITranslation(); - const { client } = useUserSession(); - const [audioUrl, setAudioUrl] = useState(); - const [isLoading, setIsLoading] = useState(true); - - const content = object.content; - const isAudio = object.metadata?.type === ContentNature.Audio; - - // Check if there are audio renditions available in metadata - const metadata = object.metadata as AudioMetadata; - const renditions = metadata?.renditions || []; - - // Find audio rendition by name (AUDIO_RENDITION_NAME = "Audio") - const audioRendition = renditions.find(r => r.name === AUDIO_RENDITION_NAME); - - // Check if original file is web-compatible - const isOriginalWebSupported = content?.type && WEB_SUPPORTED_AUDIO_FORMATS.includes(content.type); - - // Reset state when object changes - useEffect(() => { - setAudioUrl(undefined); - setIsLoading(true); - }, [object.id]); - - useEffect(() => { - if (isAudio && (audioRendition?.content?.source || isOriginalWebSupported)) { - const loadAudioUrl = async () => { - try { - let downloadUrl; - if (audioRendition?.content?.source) { - // Use rendition if available - downloadUrl = await client.files.getDownloadUrl(audioRendition.content.source); - } else if (isOriginalWebSupported && content?.source) { - // Fall back to original file if web-supported - downloadUrl = await client.files.getDownloadUrl(content.source); - } - if (downloadUrl) { - setAudioUrl(downloadUrl.url); - } - } catch (error) { - console.error("Failed to get audio URL", error); - } finally { - setIsLoading(false); - } - }; - loadAudioUrl(); - } else { - setIsLoading(false); - } - }, [isAudio, audioRendition, isOriginalWebSupported, content?.source, client]); - - return ( -
- {!audioRendition && !isOriginalWebSupported ? ( -
-
-

{t('store.noAudioRendition')}

-

{t('store.audioFormatRequired')}

-
-
- ) : isLoading ? ( -
- -
- ) : audioUrl ? ( -
- - {metadata?.duration && ( -
- Duration: {formatDuration(metadata.duration)} -
- )} -
- ) : ( -
- Failed to load audio -
- )} -
- ); -} - -function formatDuration(seconds: number): string { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = Math.floor(seconds % 60); - - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; - } - return `${minutes}:${secs.toString().padStart(2, '0')}`; -} - function TranscriptPanel({ object, handleCopyContent }: { object: ContentObject, handleCopyContent: (content: string, type: "text" | "properties") => Promise }) { const { t } = useUITranslation(); const transcript = object.transcript; @@ -1174,10 +891,18 @@ function OfficePdfActions({ } function PdfPreviewPanel({ object }: { object: ContentObject }) { + const source: UniversalDocumentSource = { + id: object.id, + title: object.name, + fileName: object.content?.name || object.name, + contentType: object.content?.type, + sourcePath: object.content?.source, + }; + return (
-
@@ -1189,11 +914,12 @@ function PdfPreviewPanel({ object }: { object: ContentObject }) { * Handles the various states: converting, error, showing PDF. */ function OfficePdfPreviewPanel({ + object, pdfRendition, officePdfUrl, + converters, officePdfConverting, officePdfError, - onConvert, }: OfficePdfPreviewPanelProps) { const { t } = useUITranslation(); if (officePdfConverting) { @@ -1215,26 +941,48 @@ function OfficePdfPreviewPanel({ } if (pdfRendition?.content?.source) { + const source: UniversalDocumentSource = { + id: object.id, + title: object.name, + fileName: `${object.name || 'document'}.pdf`, + contentType: 'application/pdf', + sourcePath: pdfRendition.content.source, + }; + return (
- +
); } if (officePdfUrl) { + const source: UniversalDocumentSource = { + id: object.id, + title: object.name, + fileName: `${object.name || 'document'}.pdf`, + contentType: 'application/pdf', + url: officePdfUrl, + }; + return (
- +
); } + const source: UniversalDocumentSource = { + id: object.id, + title: object.name, + fileName: object.content?.name || object.name, + contentType: object.content?.type, + sourcePath: object.content?.source, + }; + return ( -
- +
+
); } diff --git a/packages/ui/src/session/UserSessionProvider.tsx b/packages/ui/src/session/UserSessionProvider.tsx index 84b53e8b5..ef801ed81 100644 --- a/packages/ui/src/session/UserSessionProvider.tsx +++ b/packages/ui/src/session/UserSessionProvider.tsx @@ -57,6 +57,28 @@ export function UserSessionProvider({ children }: UserSessionProviderProps) { }, }); + if (Env.isLocalDev && Env.devAuthToken) { + session.setSession = setSession; + getComposableToken(selectedAccount, selectedProject, Env.devAuthToken) + .then((res) => { + session.login(res.rawToken).then(() => setSession(session.clone())); + }) + .catch((err) => { + console.error("Failed to initialize dev auth token", err); + Env.logger.error("Failed to initialize dev auth token", { + vertesia: { + account_id: selectedAccount, + project_id: selectedProject, + error: err, + }, + }); + session.isLoading = false; + session.authError = err instanceof Error ? err : new Error(String(err)); + setSession(session.clone()); + }); + return; + } + if (token && state) { session.setSession = setSession; const validationError = verifyState(state); diff --git a/packages/ui/src/session/auth/composable.ts b/packages/ui/src/session/auth/composable.ts index 9c3116fd9..e5545b22c 100644 --- a/packages/ui/src/session/auth/composable.ts +++ b/packages/ui/src/session/auth/composable.ts @@ -267,17 +267,22 @@ export async function getComposableToken(accountId?: string, projectId?: string, const selectedAccount = accountId ?? localStorage.getItem(LastSelectedAccountId_KEY) ?? undefined const selectedProject = projectId ?? localStorage.getItem(LastSelectedProjectId_KEY + '-' + selectedAccount) ?? undefined + const devAuthToken = Env.isLocalDev ? Env.devAuthToken : undefined; //token is still valid for more than 5 minutes if (!forceRefresh && AUTH_TOKEN_RAW && AUTH_TOKEN && AUTH_TOKEN.exp > (Date.now() / 1000 + 300)) { return { rawToken: AUTH_TOKEN_RAW, token: AUTH_TOKEN, error: false }; } + if (devAuthToken) { + AUTH_TOKEN_RAW = devAuthToken; + } + //token is close to expire, refresh it - if (!useInternalAuth && getFirebaseAuth().currentUser) { + if (!devAuthToken && !useInternalAuth && getFirebaseAuth().currentUser) { //we have a firebase user, get the token from there AUTH_TOKEN_RAW = await fetchComposableTokenFromFirebaseToken(selectedAccount, selectedProject); - } else if (initToken || AUTH_TOKEN_RAW) { + } else if (!devAuthToken && (initToken || AUTH_TOKEN_RAW)) { // we have a token already and no firebase user, refresh it AUTH_TOKEN_RAW = await fetchComposableToken(() => Promise.resolve(initToken ?? AUTH_TOKEN_RAW), selectedAccount, selectedProject); } diff --git a/packages/workflow/src/activities/executeInteraction.ts b/packages/workflow/src/activities/executeInteraction.ts index a6d070d10..86a1fe802 100644 --- a/packages/workflow/src/activities/executeInteraction.ts +++ b/packages/workflow/src/activities/executeInteraction.ts @@ -170,7 +170,7 @@ export async function executeInteraction(payload: DSLActivityExecutionPayload> { } get runId() { - const runId = activityInfo().workflowExecution.runId; + const runId = activityInfo().workflowExecution?.runId; if (!runId) { log.error("No runId found in activityInfo"); throw new WorkflowParamNotFoundError( @@ -88,7 +88,7 @@ export class ActivityContext> { } get workflowId() { - const workflowId = activityInfo().workflowExecution.workflowId; + const workflowId = activityInfo().workflowExecution?.workflowId; if (!workflowId) { log.error("No workflowId found in activityInfo"); throw new WorkflowParamNotFoundError( diff --git a/packages/workflow/src/utils/storage.ts b/packages/workflow/src/utils/storage.ts index a9c600f45..936a7e121 100644 --- a/packages/workflow/src/utils/storage.ts +++ b/packages/workflow/src/utils/storage.ts @@ -27,7 +27,7 @@ export async function saveAgentArtifact( mimeType: string = "application/json", storageId?: string, ) { - const id = storageId || activityInfo().workflowExecution.runId; + const id = storageId || activityInfo().workflowExecution!.runId; const ext = mime.getExtension(mimeType); if (!name) { throw ApplicationFailure.nonRetryable(`Name is required`); @@ -55,7 +55,7 @@ export async function saveAgentArtifact( } export async function fetchAgentArtifact(client: VertesiaClient, name: string, storageId?: string) { - const id = storageId || activityInfo().workflowExecution.runId; + const id = storageId || activityInfo().workflowExecution!.runId; const filePath = agentStoragePath(id) + "/" + name; return fetchBlobAsBuffer(client, filePath); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b383d684..688e44764 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -610,8 +610,8 @@ importers: specifier: workspace:* version: link:../common hono: - specifier: ^4.12.14 - version: 4.12.14 + specifier: ^4.12.18 + version: 4.12.18 jose: specifier: ^6.0.11 version: 6.1.3 @@ -1019,7 +1019,7 @@ importers: dependencies: '@hono/node-server': specifier: ^1.19.10 - version: 1.19.13(hono@4.12.14) + version: 1.19.13(hono@4.12.18) '@llumiverse/common': specifier: workspace:* version: link:../../llumiverse/common @@ -1043,17 +1043,23 @@ importers: version: 17.3.1 hono: specifier: ^4.12.14 - version: 4.12.14 + version: 4.12.18 + html-parse-stringify: + specifier: ^3.0.1 + version: 3.0.1 lucide-react: specifier: ^0.562.0 version: 0.562.0(react@19.2.3) + use-sync-external-store: + specifier: ^1.6.0 + version: 1.6.0(react@19.2.3) devDependencies: '@eslint/js': specifier: ^9.0.0 version: 9.39.4 '@hono/vite-dev-server': specifier: ^0.25.1 - version: 0.25.1(hono@4.12.14) + version: 0.25.1(hono@4.12.18) '@rollup/plugin-commonjs': specifier: ^28.0.8 version: 28.0.9(rollup@4.60.1) @@ -1143,7 +1149,7 @@ importers: dependencies: '@hono/node-server': specifier: ^1.19.10 - version: 1.19.13(hono@4.12.14) + version: 1.19.13(hono@4.12.18) '@llumiverse/common': specifier: workspace:* version: link:../../llumiverse/common @@ -1161,7 +1167,7 @@ importers: version: 17.3.1 hono: specifier: ^4.12.14 - version: 4.12.14 + version: 4.12.18 react: specifier: 19.2.3 version: 19.2.3 @@ -5733,8 +5739,8 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - hono@4.12.14: - resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==} + hono@4.12.18: + resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} engines: {node: '>=16.9.0'} html-parse-stringify@3.0.1: @@ -9455,14 +9461,14 @@ snapshots: protobufjs: 7.5.5 yargs: 17.7.2 - '@hono/node-server@1.19.13(hono@4.12.14)': + '@hono/node-server@1.19.13(hono@4.12.18)': dependencies: - hono: 4.12.14 + hono: 4.12.18 - '@hono/vite-dev-server@0.25.1(hono@4.12.14)': + '@hono/vite-dev-server@0.25.1(hono@4.12.18)': dependencies: - '@hono/node-server': 1.19.13(hono@4.12.14) - hono: 4.12.14 + '@hono/node-server': 1.19.13(hono@4.12.18) + hono: 4.12.18 minimatch: 9.0.9 '@humanfs/core@0.19.1': {} @@ -13509,7 +13515,7 @@ snapshots: dependencies: hermes-estree: 0.25.1 - hono@4.12.14: {} + hono@4.12.18: {} html-parse-stringify@3.0.1: dependencies: diff --git a/templates/plugin-template/.agents/skills/vertesia-api/SKILL.md b/templates/plugin-template/.agents/skills/vertesia-api/SKILL.md new file mode 100644 index 000000000..68894e3fa --- /dev/null +++ b/templates/plugin-template/.agents/skills/vertesia-api/SKILL.md @@ -0,0 +1,260 @@ +--- +name: vertesia-api +description: Reference for the Vertesia client API (@vertesia/client). Covers available APIs, common operations (objects, workflows, interactions, files), authentication helpers, and query patterns. Use when building tools or UI that call the Vertesia platform. +--- + +# Vertesia Client API + +The `@vertesia/client` package provides a typed client for the Vertesia platform. + +## Getting the Client + +```typescript +// In tool server code (inside tool run()) +const client = await context.getClient(); + +// In UI code (React component) +import { useUserSession } from "@vertesia/ui/session"; +const { client, user, project } = useUserSession(); +``` + +## Available APIs + +### Studio APIs + +| API | Description | +|-----|-------------| +| `client.projects` | Project management | +| `client.environments` | Environment configuration | +| `client.interactions` | Interaction execution and management | +| `client.prompts` | Prompt templates | +| `client.runs` | Run history and analytics | +| `client.account` | Current account operations | +| `client.analytics` | Analytics data | +| `client.apps` | Application management | +| `client.iam` | Identity and access management | +| `client.users` | User management | +| `client.apikeys` | API key management | +| `client.refs` | Reference management | +| `client.commands` | Command execution | + +### Store APIs + +Accessible via `client.store.*` or shortcuts: + +| API | Shortcut | Description | +|-----|----------|-------------| +| `client.store.objects` | `client.objects` | Content object CRUD, search, upload | +| `client.store.files` | `client.files` | File operations | +| `client.store.types` | `client.types` | Content type definitions | +| `client.store.workflows` | `client.workflows` | Workflow management, conversations | +| `client.store.collections` | — | Collection management | +| `client.store.embeddings` | — | Embedding operations | +| `client.store.agents` | — | Agent operations | + +## Object Operations + +```typescript +// Retrieve by ID +const object = await client.objects.retrieve(objectId); + +// List with pagination +const objects = await client.objects.list({ limit: 100, offset: 0 }); + +// Search by text +const results = await client.objects.search({ query: 'search terms' }); + +// Find with MongoDB-style queries +const objects = await client.objects.find({ + where: { status: 'active', type: 'article' }, + limit: 50, +}); + +// Count +const { count } = await client.objects.count({ where: { status: 'active' } }); + +// Upload content +await client.objects.upload(payload); + +// Get download URL +const { url } = await client.objects.getDownloadUrl(fileUri); + +// Analyze document +const analysis = await client.objects.analyze(objectId); +``` + +## Workflow / Conversation Operations + +```typescript +// List conversations for an interaction +const { runs } = await client.store.workflows.listConversations({ + interaction: 'app:my-app:collection:interaction-name', + page_size: 20, +}); +// Returns: { runs: WorkflowRun[], next_page_token?, has_more? } +// WorkflowRun has: run_id, workflow_id, started_at, status, topic +// NOTE: listConversations does NOT return input field — use getRunDetails for that + +// Get full run details (includes input, history) +const run = await client.store.workflows.getRunDetails(runId, workflowId); + +// Search runs with filters +const results = await client.store.workflows.searchRuns({ + interaction: 'interaction-name', + status: 'completed', + page_size: 10, +}); + +// Send signal to active workflow +await client.store.workflows.sendSignal(workflowId, runId, 'signal-name', payload); +``` + +## Interaction Execution + +```typescript +// Synchronous execution (wait for result) +const result = await client.interactions.execute({ + interaction: 'app:my-app:collection:interaction-name', + data: { input: 'some data' }, +}); + +// Asynchronous execution (start and get run IDs) +const result = await client.interactions.executeAsync({ + type: 'conversation', + interaction: 'app:my-app:collection:interaction-name', + interactive: true, + data: { user_prompt: 'Hello' }, +}); +// result: { runId: string, workflowId: string } +``` + +### Interaction naming convention + +Interactions registered by plugins follow this pattern: +``` +app::: +``` + +## Authentication Helpers + +```typescript +// Get raw JWT token +const jwt = await client.getRawJWT(); + +// Get decoded JWT payload +const payload = await client.getDecodedJWT(); + +// Get current project from JWT +const project = await client.getProject(); + +// Get current account from JWT +const account = await client.getAccount(); +``` + +## Client Initialization (standalone) + +When creating the client directly (outside Vertesia host): + +```typescript +import { VertesiaClient } from "@vertesia/client"; + +const client = new VertesiaClient({ + site: 'api.vertesia.io', // or 'api-preview.vertesia.io' + apikey: 'your-api-key', + sessionTags: ['your-session-tag'], +}); + +// Or from an auth token: +const client = await VertesiaClient.fromAuthToken(jwtToken); +``` + +## Query Patterns + +Use MongoDB-style query operators in `find()` and `count()`: + +```typescript +// Equality +{ where: { status: 'active' } } + +// Comparison +{ where: { score: { $gt: 80, $lte: 100 } } } + +// Logical +{ where: { $and: [{ status: 'active' }, { type: 'article' }] } } +{ where: { $or: [{ status: 'draft' }, { status: 'review' }] } } + +// In set +{ where: { category: { $in: ['news', 'blog'] } } } +``` + +**Security:** Never pass user input directly as query operators. Validate and whitelist allowed operators to prevent injection. + +## Security + +### Input Validation + +Always validate user inputs before passing to API calls: + +```typescript +// Validate ID format before querying +if (typeof objectId !== 'string' || !objectId.match(/^[0-9a-f]{24}$/)) { + throw new Error('Invalid object ID'); +} +const object = await client.objects.retrieve(objectId); +``` + +### Query Injection Prevention + +Never construct queries from raw user input: + +```typescript +// ❌ BAD — user could inject operators like $where, $regex +const filter = JSON.parse(userInput); +await client.objects.find({ where: filter }); + +// ✅ GOOD — whitelist allowed fields and values +await client.objects.find({ + where: { status: { $eq: validatedStatus }, type: { $eq: validatedType } } +}); +``` + +### Authorization + +Check authentication state before accessing protected resources: + +```typescript +const { client, user } = useUserSession(); + +// Verify user has access before performing actions +if (!user) { + throw new Error('Not authenticated'); +} +``` + +In tool server code, the SDK handles JWT validation automatically. Access the authenticated client: + +```typescript +async run(payload, context) { + const client = await context.getClient(); + // client is pre-authenticated — org/project scoped from the JWT +} +``` + +### Error Handling + +Never expose internal API details to users: + +```typescript +try { + await client.objects.delete(objectId); +} catch (error) { + console.error('Delete failed:', error); // Log full error server-side + throw new Error('Unable to delete item'); // Generic user-facing message +} +``` + +### Sensitive Data + +- Never log JWT tokens, API keys, or user credentials +- Use `VERTESIA_ALLOWED_ORGS` to restrict tool server access to specific organizations +- Store secrets in environment variables, never in code diff --git a/templates/plugin-template/.agents/skills/vertesia-demo-content/SKILL.md b/templates/plugin-template/.agents/skills/vertesia-demo-content/SKILL.md new file mode 100644 index 000000000..594bbfa05 --- /dev/null +++ b/templates/plugin-template/.agents/skills/vertesia-demo-content/SKILL.md @@ -0,0 +1,105 @@ +--- +name: vertesia-demo-content +description: Generate realistic reusable demo or seed content for Vertesia applications and upload it through the Vertesia CLI, API, or browser session. Use when building Vertesia custom apps, plugins, prototypes, repository workflows, AI extraction workflows, search/filter pages, or demos where the app should use real store objects instead of hardcoded mock arrays; includes checking the active CLI profile and asking before mutating a Vertesia project. +--- + +# Vertesia Demo Content + +Use this skill when a Vertesia app needs real content in the store so upload, search, filters, metadata extraction, workflows, and detail pages can be exercised end to end. + +## Core Rule + +Prefer realistic generated content uploaded to Vertesia over hardcoded UI mock data. Static arrays are acceptable only as temporary empty states, fallback examples, or tests that intentionally avoid network/project mutation. + +## Workflow + +1. Inspect the active target before mutation: + - Run `vertesia profiles show`. + - Identify the active/default profile, account, project, environment, and API base URL. + - Do not print full API tokens in the final answer. + - Ask the user to confirm the target project before uploading, deleting, or updating content. + +2. Confirm the target content type: + - Prefer the actual type id from Vertesia’s type catalog or the app/tool-server package. + - For app-contributed in-code types, do not assume the local type name is the runtime create id. + - Distinguish: + - query/search type name, for example `clm_contract` + - app in-code create id, for example `app::clm:ClmContract` + - If uncertain, inspect available types with CLI, project app-type APIs, and app endpoints before uploading. + +3. Generate useful files: + - Use Markdown for document-like demos unless the user requests DOCX, PDF, JSON, or another format. + - Include domain-specific details that make search, filters, extraction, summaries, and detail views meaningful. + - Include structured headings and bullet metadata so AI extraction has clear signals. + - Save generated files under `/tmp`, `/private/tmp`, or a user-approved fixture path. Do not commit generated seed data unless requested. + +4. Upload through the best available channel: + - Prefer `vertesia content post --type --mime text/markdown --name --path ` when the CLI profile is confirmed and the target type is a stored/system type or the CLI is known to accept the app-defined type. + - Use the browser/plugin session when upload must happen as the signed-in UI user. + - Use direct API when the CLI rejects an app-defined type but the project app-type APIs and runtime create path accept it. + +5. Wire the app to real objects: + - Replace static arrays with `store.objects.search`, `store.objects.list`, or collection search. + - Filter by full text plus metadata fields such as `properties.status`, `properties.owner`, `properties.category`, `properties.risk_level`, or app-specific fields. + - After upload, trigger a refetch or insert the created object into local state. + - For metadata extraction, read object text, run the relevant interaction, then update object properties. + +## App Type Rules + +When seeding app-defined content: + +- Use query/search names such as `clm_contract` for filters and object searches. +- Use the resolved app type code such as `app::clm:ClmContract` for object creation. +- If UI actions create child records, use the same explicit app type codes there too. +- If agent runs or workflow `executeInteraction` calls target app interactions, use the full app interaction id such as `app::clm:ExtractContractMetadata`. + +## CLI Limitation + +`vertesia content post` may validate only against `client.types.list()`, which can exclude app-defined in-code types. + +If the CLI says the type does not exist: + +1. verify the app package exposes the type +2. verify the project app-type APIs list and retrieve it +3. test direct object creation with the resolved app type code +4. use a repo-local uploader script or direct API if the project accepts the type but the CLI still rejects it + +Do not assume CLI rejection means the app type is missing from the project. + +## CLI Pattern + +Upload generated Markdown: + +```bash +vertesia content post /tmp/demo-seed/acme-doc.md \ + --type \ + --mime text/markdown \ + --name "Acme Demo Document" \ + --path /demo +``` + +List uploaded content: + +```bash +vertesia content list /demo +``` + +## Generator Script + +Use `scripts/generate_markdown_seed.py` to generate reusable Markdown seed data: + +```bash +python3 .claude/skills/vertesia-demo-content/scripts/generate_markdown_seed.py \ + --out /tmp/vertesia-demo \ + --domain generic \ + --count 3 +``` + +Supported built-in domains: + +- `generic`: reusable business documents for repository/search/extraction demos. +- `clm`: contract lifecycle documents with dates, values, renewal terms, obligations, and clauses. +- `support`: customer support cases with severity, owner, product, timeline, and resolution notes. +- `policy`: internal policy/procedure documents with owners, effective dates, controls, and exceptions. + +Use `--domain generic` unless the app domain is known. diff --git a/templates/plugin-template/.agents/skills/vertesia-demo-content/scripts/generate_markdown_seed.py b/templates/plugin-template/.agents/skills/vertesia-demo-content/scripts/generate_markdown_seed.py new file mode 100755 index 000000000..c215c30b3 --- /dev/null +++ b/templates/plugin-template/.agents/skills/vertesia-demo-content/scripts/generate_markdown_seed.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Generate realistic Markdown seed documents for Vertesia demos.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + + +Sample = dict[str, str] + + +SAMPLES: dict[str, list[Sample]] = { + "generic": [ + { + "slug": "acme-business-review", + "title": "Acme Business Review", + "category": "Customer Review", + "owner": "Customer Success", + "status": "In Review", + "date": "2026-05-15", + "summary": "Quarterly account review covering adoption, risks, renewal outlook, and executive follow-ups.", + "details": "Acme expanded usage across three departments. The main open risk is delayed security questionnaire completion before renewal.", + "actions": "Schedule executive sponsor meeting; complete security questionnaire; prepare renewal pricing proposal.", + }, + { + "slug": "northstar-implementation-plan", + "title": "Northstar Implementation Plan", + "category": "Project Plan", + "owner": "Professional Services", + "status": "Active", + "date": "2026-06-01", + "summary": "Implementation plan for a phased rollout with data migration, training, acceptance criteria, and launch support.", + "details": "Phase one covers workspace setup and identity configuration. Phase two covers content migration and automation testing.", + "actions": "Confirm migration sample set; validate SSO; schedule administrator training.", + }, + { + "slug": "globex-risk-memo", + "title": "Globex Risk Review Memo", + "category": "Risk Memo", + "owner": "Operations", + "status": "Escalated", + "date": "2026-05-22", + "summary": "Operational risk memo documenting supplier delay, customer impact, mitigation plan, and approval needs.", + "details": "Supplier lead time increased from four weeks to nine weeks. Customer launch impact is likely unless substitute inventory is approved.", + "actions": "Approve substitute supplier; notify account team; update delivery forecast.", + }, + ], + "clm": [ + { + "slug": "acme-enterprise-saas-agreement", + "title": "Acme Enterprise SaaS Agreement", + "category": "Customer MSA", + "owner": "Sales Ops", + "status": "In Review", + "date": "2026-05-15", + "summary": "Enterprise subscription agreement with negotiated liability, data processing, uptime SLA, and renewal terms.", + "details": "Counterparty: Acme Corp. Value: $425,000 USD. Risk: High. Expiration: 2026-11-30. Renewal notice: 2026-08-01.", + "actions": "Finance review for contract value; legal review for liability carveouts; security review for data processing exhibit.", + }, + { + "slug": "northstar-vendor-services", + "title": "Northstar Vendor Services Agreement", + "category": "Vendor Agreement", + "owner": "Procurement", + "status": "Active", + "date": "2026-02-01", + "summary": "Managed services agreement with quarterly service reviews, payment milestones, and vendor performance obligations.", + "details": "Counterparty: Northstar Services. Value: $98,000 USD. Risk: Medium. Expiration: 2026-07-15. Renewal notice: 2026-04-16.", + "actions": "Track monthly service report; review SLA performance; prepare renewal recommendation.", + }, + { + "slug": "globex-channel-partner-addendum", + "title": "Globex Channel Partner Addendum", + "category": "Partner Amendment", + "owner": "Partner Sales", + "status": "Renewal Pending", + "date": "2026-04-01", + "summary": "Channel amendment with non-standard exclusivity, revenue-share commitments, and accelerated renewal decision timing.", + "details": "Counterparty: Globex. Value: $720,000 USD. Risk: Critical. Expiration: 2026-06-30. Renewal notice: 2026-05-15.", + "actions": "Executive review for exclusivity; legal review for territory language; renewal decision by notice date.", + }, + ], + "support": [ + { + "slug": "acme-login-incident", + "title": "Acme Login Incident", + "category": "Support Case", + "owner": "Support Engineering", + "status": "Open", + "date": "2026-05-03", + "summary": "Priority support case for intermittent login failures affecting Acme administrators.", + "details": "Severity: High. Product area: Authentication. Error rate increased after SSO certificate rotation.", + "actions": "Validate IdP metadata; rotate cached certificate; provide customer incident summary.", + }, + { + "slug": "northstar-export-request", + "title": "Northstar Export Performance Request", + "category": "Support Case", + "owner": "Customer Support", + "status": "Pending Customer", + "date": "2026-05-07", + "summary": "Customer reports slow exports for large document collections during month-end reporting.", + "details": "Severity: Medium. Product area: Reporting. Export size exceeds 50,000 rows.", + "actions": "Request sample export parameters; propose async export workflow; monitor next month-end run.", + }, + { + "slug": "globex-api-rate-limit", + "title": "Globex API Rate Limit Review", + "category": "Support Case", + "owner": "Developer Support", + "status": "Escalated", + "date": "2026-05-10", + "summary": "Integration is hitting API rate limits during nightly synchronization.", + "details": "Severity: High. Product area: API. Current client retries aggressively without backoff.", + "actions": "Share retry guidance; evaluate rate limit increase; review integration logs.", + }, + ], + "policy": [ + { + "slug": "data-retention-policy", + "title": "Data Retention Policy", + "category": "Policy", + "owner": "Compliance", + "status": "Approved", + "date": "2026-01-01", + "summary": "Policy defining retention periods, archival requirements, deletion approvals, and exception handling.", + "details": "Customer records are retained for seven years unless a shorter contractual retention period applies.", + "actions": "Review exceptions quarterly; confirm deletion audit trail; update retention schedule annually.", + }, + { + "slug": "vendor-onboarding-procedure", + "title": "Vendor Onboarding Procedure", + "category": "Procedure", + "owner": "Procurement", + "status": "Active", + "date": "2026-03-01", + "summary": "Procedure for onboarding vendors with security review, contract approval, tax setup, and performance tracking.", + "details": "Critical vendors require security approval before contract execution and annual reassessment.", + "actions": "Collect W-9; complete security questionnaire; create vendor performance record.", + }, + { + "slug": "incident-response-standard", + "title": "Incident Response Standard", + "category": "Standard", + "owner": "Security", + "status": "In Review", + "date": "2026-04-15", + "summary": "Operational standard for detecting, triaging, communicating, and resolving security incidents.", + "details": "Critical incidents require executive notification within one hour and customer notification review within twenty-four hours.", + "actions": "Validate escalation matrix; run tabletop exercise; update postmortem template.", + }, + ], +} + + +def render(sample: Sample, domain: str) -> str: + return f"""# {sample['title']} + +## Metadata + +- Domain: {domain} +- Category: {sample['category']} +- Owner: {sample['owner']} +- Status: {sample['status']} +- Record Date: {sample['date']} + +## Summary + +{sample['summary']} + +## Details + +{sample['details']} + +## Actions + +{sample['actions']} + +## Extraction Hints + +This document is suitable for testing Vertesia upload, full-text search, metadata filters, AI summarization, metadata extraction, workflow routing, and object detail views. +""" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate Markdown seed documents for Vertesia demos.") + parser.add_argument("--out", required=True, help="Output directory.") + parser.add_argument("--domain", choices=sorted(SAMPLES), default="generic", help="Built-in sample domain.") + parser.add_argument("--count", type=int, default=3, help="Number of documents to generate.") + args = parser.parse_args() + + out = Path(args.out) + out.mkdir(parents=True, exist_ok=True) + + samples = SAMPLES[args.domain] + count = max(1, min(args.count, len(samples))) + for sample in samples[:count]: + path = out / f"{sample['slug']}.md" + path.write_text(render(sample, args.domain), encoding="utf-8") + print(path) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/templates/plugin-template/.agents/skills/vertesia-dsl-workflow/SKILL.md b/templates/plugin-template/.agents/skills/vertesia-dsl-workflow/SKILL.md new file mode 100644 index 000000000..99b1e2e6f --- /dev/null +++ b/templates/plugin-template/.agents/skills/vertesia-dsl-workflow/SKILL.md @@ -0,0 +1,212 @@ +--- +name: vertesia-dsl-workflow +description: Reference for DSL workflow definitions, remote activities, variable resolution, and conditions. Use when creating or debugging DSL workflows that call remote activities from plugins. +--- + +# DSL Workflows & Remote Activities + +DSL workflows are declarative step-by-step pipelines defined as JSON. They can call built-in activities and remote activities provided by plugins. + +## Key Source Files + +| File | Purpose | +|------|---------| +| `composableai/packages/workflow/src/dsl/dsl-workflow.ts` | DSL engine: variable init, step execution, remote activity calls | +| `composableai/packages/workflow/src/dsl/vars.ts` | `Vars` class: variable storage, resolution (`${varName}`), dotted path access | +| `composableai/packages/workflow/src/dsl/conditions.ts` | `matchCondition()`: operator-based condition matching | +| `composableai/packages/workflow/src/activities/getObjectFromStore.ts` | Built-in activity to load a ContentObject into workflow vars | +| `composableai/packages/tools-sdk/src/ActivityCollection.ts` | `ActivityCollection` class for registering plugin activities | + +## Workflow Definition Structure + +```typescript +await client.workflows.definitions.create({ + name: 'MyWorkflow', + description: 'Description', + debug_mode: true, + steps: [ + { name: 'activityName', type: 'activity', params: { ... } }, + // ... + ], + vars: {}, // initial workflow variables +}); +``` + +## Built-in Activities + +Common built-in activities available in all DSL workflows: + +| Activity | Purpose | Key Params | +|----------|---------|------------| +| `setDocumentStatus` | Set document status | `{ status: 'processing' \| 'completed' \| ... }` | +| `getObjectFromStore` | Load ContentObject into vars | `{ }` (uses objectId automatically) | +| `generateEmbeddings` | Compute embeddings | `{ type: 'text' \| 'properties' }` | + +### `getObjectFromStore` Pattern + +Use `output` to store the loaded object in a named variable, then access nested fields with dotted paths: + +```typescript +{ name: 'getObjectFromStore', type: 'activity', output: 'task' }, +// Now 'task' is in vars — access fields as 'task.properties.my_field' +``` + +## Remote Activities + +Plugin activities registered via `ActivityCollection` are invoked as DSL steps using the naming convention: + +``` +app::: +``` + +Example: `app:bpce-orchestrator:intake:categorize_task` + +App interactions used by `executeInteraction` follow a similar pattern: + +``` +app::: +``` + +Do not assume a bare interaction name like `ExtractContractMetadata` will resolve inside a workflow if the interaction is contributed by an app. + +### Variable Resolution for Remote Activities + +**CRITICAL**: Remote activity params are resolved in a **new `Vars` scope** (see `dsl-workflow.ts:422`). Workflow-level variables like `objectId` are NOT automatically available in remote activity params. + +You must use `import` to bring workflow variables into scope: + +```typescript +// WRONG - ${objectId} will be undefined +{ + name: 'app:my-plugin:collection:my_activity', + type: 'activity', + params: { task_id: '${objectId}' }, +} + +// CORRECT - import brings objectId into the new Vars scope +{ + name: 'app:my-plugin:collection:my_activity', + type: 'activity', + import: ['objectId'], + params: { task_id: '${objectId}' }, + output: 'result', +} +``` + +The `import` field tells the DSL engine to copy the listed variables from the workflow scope into the activity's `Vars` scope via `vars.createImportVars(activity.import)` at `dsl-workflow.ts:378`. + +### How `objectId` is Initialized + +At `dsl-workflow.ts:133`, the DSL engine initializes workflow vars with: +- `objectId` = first object ID from the trigger event (`objectIds[0]`) +- Plus any vars defined in the workflow definition's `vars` field + +## Conditions on Steps + +Use `condition` to skip steps based on workflow variable values. + +### Condition Syntax + +Conditions use operator objects from `conditions.ts`. The `matchCondition()` function iterates the keys of the condition value and looks them up in the `conditionFns` registry. + +**Available operators:** + +| Operator | Description | Example | +|----------|-------------|---------| +| `$eq` | Equals | `{ $eq: 'value' }` | +| `$ne` | Not equals | `{ $ne: 'value' }` | +| `$in` | In array | `{ $in: ['a', 'b'] }` | +| `$nin` | Not in array | `{ $nin: ['a', 'b'] }` | +| `$exists` | Field exists | `{ $exists: true }` | +| `$null` | Is null/undefined | `{ $null: true }` | +| `$gt`, `$gte`, `$lt`, `$lte` | Comparisons | `{ $gt: 50 }` | +| `$regexp` | Regex match | `{ $regexp: '^EPR-' }` | +| `$startsWith`, `$endsWith`, `$contains` | String ops | `{ $contains: 'text' }` | +| `$or` | OR conditions | `{ $or: [{ $eq: 'a' }, { $eq: 'b' }] }` | + +### CRITICAL: Always Use Operator Objects + +**NEVER pass plain values as conditions.** Plain strings/numbers cause `Unknown condition: 0` errors because `matchCondition()` iterates string characters as keys. + +```typescript +// WRONG - causes "Unknown condition: 0" error +condition: { 'task.properties.task_status': 'categorisation' } + +// CORRECT - use operator object +condition: { 'task.properties.task_status': { $eq: 'categorisation' } } +``` + +### Dotted Path Access in Conditions + +Conditions support dotted paths to access nested variables. The `vars.match()` method uses `getValue(path)` which resolves dotted paths like `task.properties.task_status`: + +```typescript +{ + name: 'app:my-plugin:collection:my_activity', + type: 'activity', + condition: { 'task.properties.field_name': { $eq: 'expected_value' } }, + import: ['objectId'], + params: { task_id: '${objectId}' }, +} +``` + +This requires a prior `getObjectFromStore` step with `output: 'task'` to populate the variable. + +## Complete Workflow Example + +A workflow that loads a task, conditionally processes it, computes embeddings, and sets status: + +```typescript +steps: [ + { name: 'setDocumentStatus', type: 'activity', params: { status: 'processing' } }, + { name: 'getObjectFromStore', type: 'activity', output: 'task' }, + { + name: 'app:my-plugin:intake:categorize_task', + type: 'activity', + condition: { 'task.properties.task_status': { $eq: 'categorisation' } }, + import: ['objectId'], + params: { task_id: '${objectId}' }, + output: 'categorize_result', + }, + { + name: 'app:my-plugin:intake:compute_priority', + type: 'activity', + condition: { 'task.properties.task_status': { $eq: 'categorisation' } }, + import: ['objectId'], + params: { task_id: '${objectId}' }, + output: 'priority_result', + }, + { name: 'generateEmbeddings', type: 'activity', params: { type: 'text' } }, + { name: 'generateEmbeddings', type: 'activity', params: { type: 'properties' } }, + { name: 'setDocumentStatus', type: 'activity', params: { status: 'completed' } }, +] +``` + +## Workflow Rules + +Workflow rules trigger workflows based on events. They match on event name and content type: + +```typescript +await client.workflows.rules.create({ + name: 'My Rule', + endpoint: `wf:${workflowDefinitionId}`, + match: { + '$and': [ + { 'event.name': 'create' }, + { 'event.data.type.name': 'my_content_type' }, + ] + }, +}); +``` + +When reusing a standard intake workflow such as `StandardDocumentIntake`, prefer staging custom enrichment on a later `update` event after the generic intake marks the object completed. This avoids two workflows racing on the same `create` event. + +## Common Pitfalls + +1. **`${objectId}` undefined in remote activity params**: Add `import: ['objectId']` to the step +2. **`Unknown condition: 0` error**: Condition value is a plain string, not an operator object — wrap with `{ $eq: ... }` +3. **Condition not matching nested fields**: Ensure a prior `getObjectFromStore` step with `output` set to populate the variable +4. **Activities not found**: Verify the naming convention `app:::` and that the `ActivityCollection` is registered in `config.ts` +5. **App interaction not found from `executeInteraction`**: Use the full app interaction id `app:::`, not a bare interaction name +6. **App child record creation fails with app type not found**: Use the resolved app type code in `client.objects.create`, not `client.types.getTypeByName()` if the type is app-contributed in-code +7. **Custom CLM/business enrichment races standard intake**: move the custom rule off `create` and trigger on a later `update` condition such as `status=completed` diff --git a/templates/plugin-template/.agents/skills/vertesia-gap-assessment/SKILL.md b/templates/plugin-template/.agents/skills/vertesia-gap-assessment/SKILL.md new file mode 100644 index 000000000..f66836cd2 --- /dev/null +++ b/templates/plugin-template/.agents/skills/vertesia-gap-assessment/SKILL.md @@ -0,0 +1,187 @@ +--- +name: vertesia-gap-assessment +description: Assess the gap between arbitrary requirements and existing Vertesia capabilities before proposing implementation work. Use when reviewing requirement documents, discovery notes, demo asks, or feature requests for a Vertesia app, plugin, workflow, or agent so you can separate native platform support, installed project capabilities, current app implementation, and true custom work. +--- + +# Vertesia Gap Assessment + +Use this skill before proposing implementation plans for a Vertesia solution. The goal is to avoid inventing custom work too early and to ground recommendations in four sources of truth: + +1. live project state +2. official Vertesia docs +3. current codebase +4. related local examples + +Read `references/sources-and-checks.md` at the start of the assessment. + +## What To Produce + +Produce a compact assessment with these sections: + +- Requirement summary +- Existing Vertesia capabilities relevant to the ask +- Existing project or app assets that can be reused +- Gap matrix: + - requirement + - native support + - project support + - app support + - custom work needed +- Recommended implementation path +- Risks or unknowns requiring verification + +Keep the distinction explicit between: + +- what Vertesia supports in general +- what the target project has installed or configured +- what the current app or plugin already uses + +## Workflow + +### 1. Normalize the requirements + +Extract the requested outcomes and group them into a short capability list. Use whatever groups fit the ask, but these are the default buckets: + +- intake +- repository and search +- workflow and approvals +- lifecycle and reminders +- reporting and analytics +- integrations +- assistant and agent behavior +- mobile or responsive UX +- admin and settings + +Do not jump to architecture until the requirement list is normalized. + +### 2. Check sources in the right order + +#### A. Live project state first + +Use the CLI or API to inspect what is actually present in the target Vertesia project: + +- active CLI profile +- installed workflow definitions +- installed workflow rules +- installed apps +- interactions available in the current project +- content objects or uploaded demo data when relevant + +If a command fails due to expired auth, refresh the profile and retry. + +If you inspect profiles with `vertesia profiles show`, do not echo API keys or tokens back to the user. Summarize only the safe fields you need. + +When the question involves app-defined types, interactions, templates, or remote activities, verify the exact runtime identifiers through the project APIs or the app package, not only through local code. App resources often have two useful names: + +- a local/query name used in search filters, such as `clm_contract` +- an app in-code identifier used in create/execute paths, such as `app::clm:ClmContract` + +#### B. Official docs second + +Use the Vertesia docs to determine platform-native capabilities before proposing custom code. The high-value pages are: + +- workflow activities catalog +- agent built-in tools +- agent runner overview +- plugin/custom app docs +- CLI reference + +Prefer standard workflows, built-in workflow activities, and built-in agent tools when they cover the requirement. + +#### C. Current codebase third + +Inspect what the current app already implements: + +- content types +- interactions +- tools and activities +- workflow specs +- UI routes and pages +- local skills and project docs + +For UI requirements, inspect both the local UI code and the available `@vertesia/ui` primitives before proposing custom components. A gap in the current app is not automatically a gap in the shared UI library. + +Do not confuse "possible in Vertesia" with "already wired into this app". + +#### D. Related local examples fourth + +Look for reusable patterns in: + +- nearby plugin repos +- project-local skills +- installed standard workflows in the target project +- prior implementations of similar flows + +Treat installed standard workflows as real reuse candidates, not as theoretical examples. + +### 3. Classify every requirement + +For each requirement, classify it as one of: + +- already implemented +- available natively in Vertesia +- partially covered and should be composed from native pieces +- requires custom plugin or app code +- requires external integration or project configuration + +Use the most conservative label that is still accurate. + +### 4. Recommend the right architectural home + +Use these defaults unless the evidence points elsewhere: + +- deterministic intake and side effects -> DSL workflow +- structured extraction -> interaction executed by workflow +- open-ended review, search, explanation, analysis -> agent +- standard repository operations -> built-in workflow activities or built-in agent tools +- domain-specific routing, linked-record creation, adapter logic -> custom plugin code only if native pieces do not cover it +- UX-only visualization or dashboards -> UI composition +- third-party system access -> integration or adapter layer + +For list/detail UI requirements, also decide where state must live. If users are expected to go from a table to a detail view and back without losing context, page-local state is usually the wrong recommendation. + +Do not assume an agent is the best orchestration model. Check built-in workflow activities first. + +Do not invent custom activities too early. Check the workflow activities catalog first. + +### 5. Write the gap matrix + +For each normalized requirement, capture: + +- requirement +- evidence or source +- current support status +- recommended implementation approach +- custom work still needed +- open unknowns + +Be explicit when the right answer is hybrid, for example: + +- standard workflow + custom enrichment +- built-in tools + custom UI +- deterministic workflow + agent assistant + +## Decision Rules + +- Docs are not enough. Verify installed definitions and project state with the CLI. +- Installed project capabilities are not the same as code already used by this app. +- Existing standard workflows should be inspected before creating new ones. +- Prefer reuse over replacement when a standard workflow covers the generic part and custom code only needs to handle domain enrichment. +- If a requirement depends on configuration, credentials, or app installation settings, call that out separately from code work. +- If standard intake is already installed, prefer adding domain enrichment after the standard workflow completes rather than starting a second workflow on the same `create` event and letting them race. +- CLI support and project runtime support are not the same thing. A CLI command may reject an app-defined type even when direct project APIs and the app runtime accept it. +- For app-defined interactions in workflows, `executeInteraction` typically needs the full app interaction id, not a bare interaction name. +- For app-defined child records created from custom activities or UI actions, prefer the resolved app type code used by the runtime create path. +- For UI work, classify "needs custom UI code" only after checking whether the shared `@vertesia/ui` library already provides the needed primitive. +- For list/detail UI work, inspect the route boundary and the persistence behavior of shared filter components. If `FilterProvider` or URL-backed filters are involved and state also survives navigation, plan a normalization or dedupe step so filter restoration does not create duplicates. +- For sortable, facet-driven repository tables, prefer one backend search path that owns rows, sort, and facets instead of mixing a simpler find path with a richer search path. + +## Output Style + +Keep the assessment compact and implementation-oriented. + +Prefer short grouped bullets and a small matrix over long prose. + +When useful, summarize the recommendation in one sentence: + +`Use for the generic path, then add for the domain-specific gap.` diff --git a/templates/plugin-template/.agents/skills/vertesia-gap-assessment/references/sources-and-checks.md b/templates/plugin-template/.agents/skills/vertesia-gap-assessment/references/sources-and-checks.md new file mode 100644 index 000000000..aa7dac324 --- /dev/null +++ b/templates/plugin-template/.agents/skills/vertesia-gap-assessment/references/sources-and-checks.md @@ -0,0 +1,198 @@ +# Sources And Checks + +Use this file as the concrete checklist when assessing requirements against Vertesia capabilities. + +## 1. Live Project State + +Check project reality before reading docs deeply. + +### CLI commands verified in this environment + +```bash +vertesia profiles show +vertesia profiles refresh +vertesia workflows definitions list +vertesia workflows definitions get +vertesia workflows rules list +vertesia workflows rules get +vertesia apps list-installed +vertesia apps get-installation +vertesia interactions +vertesia content list +vertesia content get +``` + +### Notes + +- `vertesia profiles show` includes sensitive tokens. Never repeat them in user-facing output. +- If workflow or app queries fail with `401 Token expired`, run `vertesia profiles refresh` and retry. +- After auth refresh, verify the existing dev server port, public tunnel, and installed app manifest endpoint before starting replacements. +- Installed workflows matter. In this environment, inspecting installed definitions surfaced reusable system workflows such as `StandardDocumentIntake`. + +### Questions to answer + +- Which profile and project are active? +- Which workflow definitions are installed? +- Which workflow rules are installed? +- Which apps are installed in the project? +- Which interactions are already available? +- Is there existing content/demo data that changes the implementation plan? + +### Resource identity checks + +For app-defined resources, verify the exact ids used by the runtime create/execute paths. + +- Confirm the installed app manifest endpoint is reachable and points to the expected package URL. +- For local development against a cloud project, verify the tunnel URL and the manifest `endpoint` before debugging missing app resources. +- List app types from the project, not just from local code. +- Check the direct app-type detail endpoint, not only the listing endpoint. +- Distinguish: + - query/search type name, for example `clm_contract` + - app in-code type id, for example `app::clm:ClmContract` + - app interaction id, for example `app::clm:ExtractContractMetadata` + +Do not assume the lowercase local type name is also the correct runtime create id. + +### CLI vs API checks + +- `vertesia content post` may validate only against stored/system types and reject app-defined types. +- If the CLI rejects an app-defined type, verify the project app-type APIs before assuming the app is broken. +- When the runtime accepts the app-defined type but the CLI does not, use a direct API uploader or an app-side upload flow. + +## 2. Official Vertesia Docs + +Use docs to determine what the platform supports natively. + +### Priority docs + +- Workflow activities catalog +- Agent built-in tools +- Agent runner overview +- Plugin/custom app docs +- CLI docs + +### What to look for + +- Standard workflow activities that replace custom orchestration code +- Built-in agent tools that replace custom repository/search wrappers +- Existing platform patterns for workflows, agents, search, scheduling, updates, and integrations + +### Reusable lessons + +- Built-in workflow activities often already cover extraction, interaction execution, document updates, progress messages, and embeddings. +- Built-in agent tools often already cover search, fetch, document CRUD, collection management, and optional workflow scheduling. +- Standard intake workflows often cover generic text extraction, property generation, and embeddings. Domain-specific enrichment should usually be staged after standard intake completes. + +## 3. Current Codebase + +Inspect the local app/plugin to see what is already implemented. + +### Default paths to inspect + +```text +src/tool-server/types/ +src/tool-server/interactions/ +src/tool-server/tools/ +src/tool-server/activities/ +src/tool-server/config.ts +src/ui/routes.tsx +src/ui/pages.tsx +workflow-specs/ +.claude/skills/ +``` + +### Questions to answer + +- Which content types already exist? +- Which interactions already exist? +- Which tools or remote activities already exist? +- Are there workflow specs in the repo, or only installed workflows in the project? +- Which UI pages already expose the behavior? +- Does the UI use the same identifiers for query/search and create/execute paths, or are those concerns currently conflated? +- For list/detail UX, where does the list state actually live relative to the route boundary? +- If filters are URL-backed, can the current state model remount and re-append the same filters on back-navigation? + +## 4. Related Examples + +Use nearby examples to reduce custom work. + +### Good sources + +- neighboring plugin repos +- project-local skills +- installed standard workflows +- local implementation examples that use the same Vertesia primitives + +### Example reuse patterns + +- standard workflow + custom post-processing +- built-in interaction execution + custom linked-record persistence +- built-in agent tools + custom assistant prompt +- standard document intake + post-intake enrichment rule on `update` when `status=completed` + +## 5. Classification Framework + +For each requirement, classify it as: + +- already implemented +- available natively in Vertesia +- partially covered and composable from native pieces +- requires custom plugin/app code +- requires external integration or project configuration + +Also record three support layers separately: + +- native platform support +- installed project support +- current app support + +## 6. Architecture Heuristics + +Use these defaults unless evidence shows a better fit: + +- deterministic intake, updates, retries, audit-friendly side effects -> workflow +- structured extraction with schema output -> interaction executed by workflow +- exploratory review, explanation, search assistance -> agent +- standard repository operations -> built-in workflow activities or built-in agent tools +- domain-specific routing and child-record creation -> custom plugin logic if native pieces stop short +- scheduling and outbound communication -> verify native support and project configuration before proposing custom code +- list/detail UX with preserved context -> lift state above the route boundary, persist scroll explicitly, and normalize URL-restored filters + +## 7. Debugging Heuristics + +When a create or execute path fails with "app type not found" or "interaction not found": + +1. verify the installed app manifest points to the correct `/api/package` endpoint +2. verify the public tunnel or deployment URL is reachable and returns the expected app package +3. verify the app package endpoint exposes the resource +4. verify the project app-resource listing endpoint sees it +5. verify the project app-resource detail endpoint resolves it +6. compare the id shape used in the failing payload with the id shape returned by the detail endpoint +7. test direct API create/execute before blaming the app logic + +When debugging locally against a cloud project, app-manifest and tunnel issues are often mistaken for type or interaction bugs. + +When back-navigation in a list/detail UI behaves badly: + +1. check whether filters, sort, and search live inside the list page component +2. check whether a shared filter component also restores from the URL on mount +3. dedupe or normalize filter writes if both persisted React state and URL restoration are active +4. persist scroll position in history state and restore it after the list layout is ready +5. if hover-reveal buttons are invisible, verify Tailwind variant classes are literal strings and not dynamically assembled template fragments + +When auth expires mid-debugging: + +1. refresh auth +2. verify the existing local dev server still responds +3. verify the current tunnel host still resolves +4. verify the installed app manifest still points to that live tunnel +5. only create a new dev server or tunnel if the current pair is invalid + +Do not let multiple quick tunnels and drifting local ports accumulate during one debugging session. + +When two workflows act on the same object: + +1. check installed rules, not just repo workflow specs +2. confirm which event each rule matches +3. avoid `create`-time races when a standard intake workflow already exists +4. move custom enrichment to a later `update` condition if the generic workflow should finish first diff --git a/templates/plugin-template/.agents/skills/vertesia-plugin/REFERENCE.md b/templates/plugin-template/.agents/skills/vertesia-plugin/REFERENCE.md new file mode 100644 index 000000000..79ea6b7d4 --- /dev/null +++ b/templates/plugin-template/.agents/skills/vertesia-plugin/REFERENCE.md @@ -0,0 +1,151 @@ +# Vertesia Plugin Reference + +Additional details for the plugin architecture. Referenced from SKILL.md. + +## Table of Contents + +- [Admin UI](#admin-ui) +- [CSS Customization](#css-customization) +- [Deployment](#deployment) + +For resource creation code examples (tools, skills, interactions, types, templates), use the **vertesia-tool-server-resource** skill. + +--- + +## Admin UI + +The admin UI (`@vertesia/tools-admin-ui`) provides a browsable interface for all plugin resources. It is mounted alongside the plugin app in dev mode. + +### Integration in `main.tsx` + +```typescript +import { AdminApp } from '@vertesia/tools-admin-ui' + +const routes: Route[] = [ + { path: "*", Component: AdminApp }, // Admin UI at / + { path: "app/*", Component: AppWrapper }, // Plugin app at /app/ +] +``` + +### API endpoints fetched + +| Endpoint | Data | +|----------|------| +| `GET /api` | Server info (name, version, endpoints) | +| `GET /api/interactions` | Interaction collections and refs | +| `GET /api/tools` | Tool collections and definitions | +| `GET /api/skills` | Skill collections (exposed as tools) | +| `GET /api/types` | Content type collections and schemas | +| `GET /api/templates` | Template collections and refs | +| `GET /api/package?scope=widgets` | Widget info per skill collection | + +### Routes + +| Route | Shows | +|-------|-------| +| `/` | Collection cards grouped by type, or search results | +| `/tools/:collection` | Tool definitions with input schemas | +| `/skills/:collection` | Skill list with widgets summary | +| `/skills/:collection/:name` | Full skill: widgets, scripts, instructions, schema | +| `/interactions/:collection` | Interaction list | +| `/interactions/:collection/:name` | Prompts, result schema, agent runner flags | +| `/types/:collection` | Content type list | +| `/types/:collection/:name` | Object schema, table layout, flags | +| `/templates/:collection` | Template list | +| `/templates/:collection/:name` | Instructions, assets, type | + +### Standalone development + +```bash +cd composableai/packages/tools-admin-ui +cp .env.local.example .env.local # Set VITE_API_BASE_URL to your running tool server +pnpm dev # Vite dev server on http://localhost:5174 +``` + +--- + +## CSS Customization + +Override CSS custom properties **after** the shared `@vertesia/ui` import in `index.css`: + +```css +@layer base { + :root { + --primary: oklch(55% 0.2 145); /* light mode */ + --primary-background: oklch(97% 0.02 145); + } + .dark { + --primary: oklch(75% 0.18 145); /* dark mode */ + --primary-background: oklch(75% 0.18 145 / 0.2); + } +} +``` + +Available tokens: `--primary`, `--success`, `--attention`, `--destructive`, `--done`, `--info`, `--muted` (each with a `-background` variant), plus `--background`, `--foreground`, `--card-*`, `--sidebar-*`, `--topnav-*`, `--border`, `--input`, `--ring`. See `@vertesia/ui/src/css/color.css` for all values. + +--- + +## Deployment + +### Vercel (Primary) + +The `vercel.json` routes `/api/*` to the serverless function in `api/index.js`. Static files are served from `dist/`. + +```bash +pnpm build && vercel deploy +``` + +### Node.js / Docker + +```bash +pnpm build && pnpm start # Runs on port 3000 (or PORT env var) +``` + +The Node server (`server-node.ts`) serves static files from `dist/` via `@hono/node-server/serve-static`. + +### Tunnel-Based Dev Testing + +When testing a local plugin against a cloud Vertesia project: + +1. start the local HTTPS dev server +2. expose it publicly with a tunnel, for example: + +```bash +npx cloudflared tunnel --url https://localhost:5174 --no-tls-verify +``` + +3. update the installed app manifest so `endpoint` points to: + +```text +https:///api/package +``` + +4. verify: + +- the app manifest now shows the tunnel endpoint +- `GET /api/package?scope=types,interactions,activities` returns the expected app package + +If the project cannot resolve app-defined resources, treat tunnel reachability and manifest endpoint correctness as first-line checks. + +### Auth Expiry Discipline + +When the CLI or project auth expires during tunnel-based testing: + +1. refresh auth first +2. verify whether the current local dev server is still alive on its existing port +3. verify whether the current tunnel host is still reachable +4. verify whether the installed app manifest still points to that live tunnel +5. only create a new tunnel if the current one is actually dead +6. if a new tunnel is created, update the installed app manifest immediately + +Avoid creating a fresh `pnpm dev` process and a fresh quick tunnel by reflex. That leaves multiple ports and stale manifest endpoints in play, which commonly shows up as `Failed to fetch type` or `interaction not found` even though the local app code is fine. + +### Organization Access Restriction + +Set `VERTESIA_ALLOWED_ORGS` to restrict to specific orgs: + +```bash +VERTESIA_ALLOWED_ORGS=org_abc123,org_def456 +``` + +Enforced by `@vertesia/tools-sdk`'s `authorize()` middleware — no code changes needed. Requests from unlisted orgs get `403 Forbidden`. diff --git a/templates/plugin-template/.agents/skills/vertesia-plugin/SKILL.md b/templates/plugin-template/.agents/skills/vertesia-plugin/SKILL.md new file mode 100644 index 000000000..4b19a216a --- /dev/null +++ b/templates/plugin-template/.agents/skills/vertesia-plugin/SKILL.md @@ -0,0 +1,180 @@ +--- +name: vertesia-plugin +description: Reference for plugin architecture, dual build system, import hooks, and deployment. Use when understanding plugin structure or build configuration. For creating resources use vertesia-tool-server-resource; for UI use vertesia-ui; for client API use vertesia-api. +--- + +# Vertesia Plugin Development + +This project is a Vertesia plugin with a dual architecture: a **tool server** (Hono backend) and a **UI plugin** (React frontend), built and deployed as a single unit. + +The `src/tool-server/tools/examples/`, `src/tool-server/skills/examples/`, `src/tool-server/interactions/examples/`, `src/tool-server/types/examples/`, and `src/tool-server/templates/examples/` directories contain starter code demonstrating each resource type. Use them as reference, then replace with your own implementations. + +For extended documentation see `README-tools.md` (tool server), `README-ui.md` (UI plugin), and `TESTING-tools.md` (testing guide) at the project root. + +For full code examples of all resource types, see REFERENCE.md. + +## Project Structure + +``` +src/ + tool-server/ # Backend (Hono) - compiled by Rollup + server.ts # Hono server entry (createToolServer from @vertesia/tools-sdk) + server-node.ts # Node.js HTTP adapter for local dev + config.ts # Server configuration - registers all collections here + settings.ts # JSON Schema for plugin settings + tools/ # Tool collections + / + index.ts # Exports ToolCollection with tool list + / + index.ts # Tool definition (satisfies Tool) + schema.ts # Input JSON Schema + TypeScript params interface + .ts # Implementation logic + skills/ # Skill collections + / + index.ts # Exports SkillCollection (uses ?skills auto-discovery) + / + SKILL.md # Skill definition (YAML frontmatter + markdown body) + properties.ts # Optional: runtime properties (isEnabled function) + *.tsx # Optional: widgets (compiled to dist/widgets/) + *.py, *.js # Optional: scripts (copied to dist/scripts/) + interactions/ # Interaction collections + / + index.ts # Exports InteractionCollection + / + index.ts # InteractionSpec with prompts array + prompt.hbs # Handlebars prompt template (imported with ?prompt) + prompt_schema.ts + result_schema.ts + types/ # Content type collections + / + index.ts # Exports ContentTypesCollection + .ts # InCodeTypeSpec with object_schema + table_layout + templates/ # Template collections (for document/presentation generation) + / + index.ts # Exports RenderingTemplateCollection (uses ?templates auto-discovery) + / + TEMPLATE.md # Template definition (YAML frontmatter + markdown body) + *.svg, *.latex, *.png # Asset files (auto-discovered, copied to dist/templates/) + mcp/ # MCP provider integrations + index.ts # Exports MCPProviderConfig[] + ui/ # Frontend (React) - compiled by Vite + plugin.tsx # Library entry - default export component for Vertesia host + main.tsx # Standalone app entry for dev mode + app.tsx # Main App component + routes.tsx # Route definitions (NestedRouterProvider) + pages.tsx # Page components + env.ts # Environment config (VITE_APP_NAME) + assets.ts # Asset URL resolution (plugin mode vs dev mode) + index.css # Tailwind CSS 4 with @source directives +api/ + index.js # Vercel serverless adapter (forwards to Hono fetch) +dist/ # Build outputs +lib/ # Compiled server JS (ESM) +``` + +## Build System + +Two independent build pipelines: + +| Build | Tool | Entry | Output | Config Files | +|-------|------|-------|--------|--------------| +| Tool Server | Rollup | `src/tool-server/server.ts` | `lib/*.js` (ESM) | `rollup.config.js`, `tsconfig.tool-server.json` | +| UI Plugin (library) | Vite | `src/ui/plugin.tsx` | `dist/lib/plugin.js` | `vite.config.ts --mode lib`, `tsconfig.ui.json` | +| UI App (standalone) | Vite | `src/ui/main.tsx` | `dist/ui/` | `vite.config.ts --mode app`, `tsconfig.ui.json` | +| Skill Widgets | Rollup (via build-tools) | `skills/**/*.tsx` | `dist/widgets/*.js` | `tsconfig.widgets.json` | + +### Commands + +```bash +pnpm build # Full build: server + UI (lib and app) +pnpm build:server # Rollup server only +pnpm dev # Build server + start on port 3000 +pnpm dev:ui # Vite dev server with HMR (https://localhost:5173) +pnpm build:ui:lib # Plugin library build (dist/lib/plugin.js) +pnpm build:ui:app # Standalone app build (dist/ui/) +pnpm start # Run compiled server +pnpm start:watch # Run with --watch (auto-restart on lib/ changes) +pnpm start:debug # Run with --inspect for Node debugger +``` + +## Import Hooks (@vertesia/build-tools) + +These Rollup import transformations only work in `src/tool-server/` code: + +| Import | Produces | +|--------|----------| +| `import x from './my-skill/SKILL.md'` | `SkillDefinition` object (convention-based) | +| `import x from './definition.md?skill'` | `SkillDefinition` object (query-based) | +| `import x from './all?skills'` | `SkillDefinition[]` (auto-discovers subdirs with SKILL.md) | +| `import x from './my-template/TEMPLATE.md'` | `RenderingTemplateDefinition` object | +| `import x from './all?templates'` | `RenderingTemplateDefinition[]` (auto-discovers TEMPLATE.md) | +| `import x from './prompt.hbs?prompt'` | `PromptDefinition { role, content, content_type, schema? }` | +| `import x from './file.html?raw'` | Raw string content | + +## Creating Resources + +To create tools, skills, interactions, content types, or templates, use the **vertesia-tool-server-resource** skill. It provides step-by-step scaffolding with full code examples. + +Each resource follows the same pattern: create files → export from collection → register in `config.ts`. + +## UI Plugin + +For UI component APIs, routing, layout, styling, and agent conversation patterns, use the **vertesia-ui** skill. + +When the task includes UI work, do not stop at "it renders". The UI pass should start with a `@vertesia/ui` component inventory and end with a conformance check for duplicated primitives such as raw tables, native selects, local page headers, and inline styles. + +Key entry points: +- `src/ui/plugin.tsx` — Library entry for Vertesia host (exports default component receiving `{ slot }`) +- `src/ui/main.tsx` — Standalone dev entry (VertesiaShell + AdminApp at `/`, plugin at `/app/`) +- `src/ui/routes.tsx` — Route definitions +- `src/ui/assets.ts` — `useAsset(path)` for URLs relative to the plugin bundle + +## Authentication + +Tool endpoints receive JWT tokens via `Authorization: Bearer {token}`. The SDK validates automatically. Access the client via `const client = await context.getClient()` in tool `run()`. For full client API reference, use the **vertesia-api** skill. + +For organization access restriction and deployment details, see REFERENCE.md. + +## Local Runtime Exposure + +For project-connected testing, the local plugin server is not enough by itself. The installed Vertesia app manifest must point to a reachable package endpoint. + +Use this sequence: + +1. run the local HTTPS dev server +2. expose it with a tunnel such as Cloudflare Tunnel +3. update the installed app manifest `endpoint` to `/api/package` +4. verify the app package from the public URL before debugging project-side failures + +If the project does not see updated types, interactions, or activities, check the manifest endpoint before changing app code. + +When auth expires during this flow: + +1. refresh auth first +2. verify the currently running dev server port still works +3. verify the current public tunnel still resolves +4. verify the installed app manifest still points to that live tunnel +5. only then decide whether a new server or tunnel is needed + +Do not keep spawning new local ports and quick tunnels after an auth failure. The project can easily end up pointing at a dead tunnel even while the local app appears healthy. + +## Key Dependencies + +| Package | Role | +|---------|------| +| `@vertesia/tools-sdk` | Tool server framework: `createToolServer`, `ToolCollection`, `SkillCollection`, auth | +| `@vertesia/tools-admin-ui` | Admin UI: browsable interface for all plugin resources | +| `@vertesia/build-tools` | Rollup import plugins: `?skill`, `?skills`, `?template`, `?templates`, `?prompt`, `?raw` | +| `@vertesia/plugin-builder` | Vite plugin for UI library builds (CSS extraction/injection) | +| `@vertesia/client` | Vertesia API client for tool implementations | +| `@vertesia/common` | Shared types: `InteractionSpec`, `InCodeTypeSpec`, etc. | +| `@vertesia/ui` | UI component library: `core`, `features`, `router`, `layout`, `session`, `shell` | +| `hono` | Lightweight web framework for the tool server | + +## Code Conventions + +- ESM with `.js` import extensions: `import { x } from "./foo.js"` +- Type-safe definitions with inference: `{} satisfies Tool`, `{} satisfies InCodeTypeSpec` +- Icons are SVG strings exported as default from `.ts` files +- All collections must be registered in `src/tool-server/config.ts` +- Skills use YAML frontmatter in `SKILL.md`; templates in `TEMPLATE.md`; prompts in `.hbs`/`.jst`/`.md` with `?prompt` diff --git a/templates/plugin-template/.claude/skills/write-tool-server-resource/SKILL.md b/templates/plugin-template/.agents/skills/vertesia-tool-server-resource/REFERENCE.md similarity index 61% rename from templates/plugin-template/.claude/skills/write-tool-server-resource/SKILL.md rename to templates/plugin-template/.agents/skills/vertesia-tool-server-resource/REFERENCE.md index dd6ac37f4..c8491c89a 100644 --- a/templates/plugin-template/.claude/skills/write-tool-server-resource/SKILL.md +++ b/templates/plugin-template/.agents/skills/vertesia-tool-server-resource/REFERENCE.md @@ -1,41 +1,24 @@ ---- -name: write-tool-server-resource -description: Creates tools, skills, interactions, content types, and rendering templates for the Vertesia plugin tool server. Handles file scaffolding, collection registration, and config.ts wiring. Use when adding new tool server resources to this plugin. ---- - -# Write Tool Server Resource +# Write Tool Server Resource — Code Reference -Step-by-step guide for creating tool server resources. Each resource follows the same workflow: -1. Create files in the appropriate `src/tool-server///` directory -2. Export from the collection's `index.ts` -3. Register the collection in `config.ts` (if new collection) +Full code examples for each resource type. SKILL.md has the workflow and decision-points; this file has the templates you copy from. -**Important conventions:** -- All imports use `.js` extensions: `import { x } from "./foo.js"` -- Use `satisfies` for type validation -- Icons are SVG strings exported as default from `.ts` files -- Import hooks (`?skill`, `?skills`, `?prompt`, `?template`, `?templates`) only work in Rollup-compiled code -- Snake_case for resource names (`my_tool`), PascalCase for TypeScript exports (`MyTool`) +## Table of Contents -For full code examples, see the `examples/` directories under each resource type. +- [Tool](#tool) +- [Skill](#skill) +- [Interaction (template-based)](#interaction-template-based) +- [Interaction (code-based)](#interaction-code-based) +- [Content Type](#content-type) +- [Rendering Template](#rendering-template) +- [Collection registration & icons](#collection-registration--icons) --- -## Creating a Tool +## Tool -### File structure - -``` -src/tool-server/tools/// - schema.ts # TypeScript interface + JSONSchema - index.ts # Tool definition (satisfies Tool) - .ts # Implementation logic -``` - -### Step 1: Define the schema +### `schema.ts` ```typescript -// schema.ts import { JSONSchema } from "@llumiverse/common"; export interface MyToolParams { @@ -53,10 +36,9 @@ export const Schema = { } satisfies JSONSchema; ``` -### Step 2: Implement the logic +### `.ts` ```typescript -// my-impl.ts import { ToolExecutionContext, ToolExecutionPayload } from "@vertesia/tools-sdk"; import { ToolResultContent } from "@vertesia/common"; import { type MyToolParams } from "./schema.js"; @@ -80,10 +62,9 @@ export async function myToolRun( } ``` -### Step 3: Define the tool +### `index.ts` (tool definition) ```typescript -// index.ts import { Tool } from "@vertesia/tools-sdk"; import { myToolRun } from "./my-impl.js"; import { MyToolParams, Schema } from "./schema.js"; @@ -96,10 +77,9 @@ export const MyTool = { } satisfies Tool; ``` -### Step 4: Add to collection +### Collection (`tools//index.ts`) ```typescript -// src/tool-server/tools//index.ts import { ToolCollection } from "@vertesia/tools-sdk"; import { MyTool } from "./my-tool/index.js"; import icon from "./icon.svg.js"; @@ -113,25 +93,11 @@ export const MyTools = new ToolCollection({ }); ``` -### Step 5: Register in config.ts - -Add the collection to the `tools` array in `src/tool-server/tools/index.ts`, which is imported by `config.ts`. - --- -## Creating a Skill +## Skill -### File structure - -``` -src/tool-server/skills/// - SKILL.md # Skill definition (YAML frontmatter + instructions) - properties.ts # Optional: runtime properties (isEnabled) - *.tsx # Optional: widgets (compiled to dist/widgets/) - *.py, *.js # Optional: scripts (copied to dist/scripts/) -``` - -### Step 1: Write SKILL.md +### `SKILL.md` ```markdown --- @@ -152,18 +118,18 @@ Describe the behavior, output format, and constraints. ``` **Frontmatter fields:** -- `name` (required): Snake_case identifier -- `description` (required): What the skill does -- `title`: Display name -- `keywords`: Trigger auto-activation when matched -- `tools`: Related tools to unlock when skill is active -- `language`/`packages`: For code execution skills + +- `name` (required): snake_case identifier +- `description` (required): what the skill does +- `title`: display name +- `keywords`: trigger auto-activation when matched +- `tools`: related tools to unlock when skill is active +- `language` / `packages`: for code-execution skills - `widgets`: UI widgets to render -### Step 2 (optional): Add runtime properties +### Optional `properties.ts` ```typescript -// properties.ts import { SkillDefinition, ToolUseContext } from "@vertesia/tools-sdk"; export default { @@ -174,10 +140,10 @@ export default { } satisfies Partial; ``` -### Step 3: Collection uses auto-discovery +### Collection auto-discovery ```typescript -// src/tool-server/skills//index.ts +// skills//index.ts import { SkillCollection } from "@vertesia/tools-sdk"; import skills from "./all?skills"; @@ -189,27 +155,12 @@ export const MySkills = new SkillCollection({ }); ``` -Skills are auto-discovered — no need to manually import each one. - --- -## Creating an Interaction - -Two approaches: **template-based** (prompt in `.hbs` file) or **code-based** (prompts inline). +## Interaction (template-based) -### File structure (template-based) +### `prompt.hbs` -``` -src/tool-server/interactions/// - index.ts # InteractionSpec - prompt.hbs # Handlebars prompt template with YAML frontmatter - prompt_schema.ts # JSONSchema for template variables - result_schema.ts # JSONSchema for structured output -``` - -### Template-based interaction - -**Prompt template:** ```handlebars {{!-- prompt.hbs --}} --- @@ -221,9 +172,9 @@ Analyze the following content: {{input}} Please provide a {{format}} summary. ``` -**Prompt schema:** +### `prompt_schema.ts` + ```typescript -// prompt_schema.ts import { JSONSchema } from "@llumiverse/common"; export default { @@ -236,9 +187,9 @@ export default { } satisfies JSONSchema; ``` -**Result schema:** +### `result_schema.ts` + ```typescript -// result_schema.ts import { JSONSchema } from "@llumiverse/common"; export default { @@ -251,9 +202,9 @@ export default { } satisfies JSONSchema; ``` -**Interaction spec:** +### `index.ts` (interaction spec) + ```typescript -// index.ts import { InteractionSpec } from "@vertesia/common"; import PROMPT from "./prompt.hbs?prompt"; import result_schema from "./result_schema.js"; @@ -268,10 +219,13 @@ export default { } satisfies InteractionSpec; ``` -### Code-based interaction (for agents/conversations) +--- + +## Interaction (code-based) + +For agents and conversational interactions (no `.hbs` file): ```typescript -// index.ts import { PromptRole } from "@llumiverse/common"; import type { InteractionSpec } from "@vertesia/common"; import { TemplateType } from "@vertesia/common"; @@ -299,10 +253,9 @@ export default { } satisfies InteractionSpec; ``` -### Add to collection +### Collection (`interactions//index.ts`) ```typescript -// src/tool-server/interactions//index.ts import { InteractionCollection } from "@vertesia/tools-sdk"; import analyzeContent from "./analyze_content/index.js"; import icon from "./icon.svg.js"; @@ -318,21 +271,11 @@ export const MyInteractions = new InteractionCollection({ --- -## Creating a Content Type +## Content Type -### File structure - -``` -src/tool-server/types// - index.ts # ContentTypesCollection - icon.svg.ts # Collection icon - .ts # InCodeTypeSpec (one file per type) -``` - -### Type definition +### `.ts` ```typescript -// my-type.ts import { InCodeTypeSpec } from "@vertesia/common"; export const MyType = { @@ -359,10 +302,9 @@ export const MyType = { } satisfies InCodeTypeSpec; ``` -### Collection +### Collection (`types//index.ts`) ```typescript -// src/tool-server/types//index.ts import { ContentTypesCollection } from "@vertesia/tools-sdk"; import { MyType } from "./my-type.js"; import icon from "./icon.svg.js"; @@ -378,17 +320,9 @@ export const MyTypes = new ContentTypesCollection({ --- -## Creating a Rendering Template - -### File structure +## Rendering Template -``` -src/tool-server/templates/// - TEMPLATE.md # Template definition (YAML frontmatter + instructions) - *.svg, *.latex # Asset files (auto-discovered, copied to dist/templates/) -``` - -### TEMPLATE.md +### `TEMPLATE.md` ```markdown --- @@ -404,23 +338,24 @@ Instructions for the document generation system. ## Available Variables -- `{{title}}` - Report title -- `{{author}}` - Author name -- `{{date}}` - Report date +- `{{title}}` — Report title +- `{{author}}` — Author name +- `{{date}}` — Report date ``` **Frontmatter fields:** -- `description` (required): What this template generates + +- `description` (required): what this template generates - `type` (required): `'document'` or `'presentation'` -- `title`: Display name -- `tags`: Categorization tags +- `title`: display name +- `tags`: categorization tags Asset files (SVG, LaTeX, PNG) in the same directory are auto-discovered and copied to `dist/templates/`. -### Collection uses auto-discovery +### Collection auto-discovery ```typescript -// src/tool-server/templates//index.ts +// templates//index.ts import { RenderingTemplateCollection } from "@vertesia/tools-sdk"; import templates from './all?templates'; @@ -434,13 +369,9 @@ export const MyTemplates = new RenderingTemplateCollection({ --- -## Collection Registration - -All collections must be wired into the server through `config.ts`. +## Collection registration & icons -### Adding to an existing collection type - -Add your collection to the array in `src/tool-server//index.ts`: +### Adding a collection to its type's index ```typescript // src/tool-server/tools/index.ts @@ -450,26 +381,14 @@ import { MyTools } from "./my-collection/index.js"; export const tools = [ExampleTools, MyTools]; ``` -### Creating a new collection type (first of its kind) - -If `src/tool-server//index.ts` doesn't have your collection yet, add it there. The `config.ts` already imports from these index files. +`config.ts` already imports from these per-type index files, so no further wiring is needed once the new collection is in the array. -### Icon file +### `icon.svg.ts` Each collection needs an SVG icon as a default string export: ```typescript -// icon.svg.ts export default ` `; ``` - -## Verification - -After creating a resource: - -1. Build: `pnpm build:server` -2. Start: `pnpm start` -3. Check the admin UI at `http://localhost:3000/` — your resource should appear -4. Check the API endpoint: `curl http://localhost:3000/api/tools` (or `/api/skills`, `/api/interactions`, `/api/types`, `/api/templates`) diff --git a/templates/plugin-template/.agents/skills/vertesia-tool-server-resource/SKILL.md b/templates/plugin-template/.agents/skills/vertesia-tool-server-resource/SKILL.md new file mode 100644 index 000000000..242ed6949 --- /dev/null +++ b/templates/plugin-template/.agents/skills/vertesia-tool-server-resource/SKILL.md @@ -0,0 +1,109 @@ +--- +name: vertesia-tool-server-resource +description: Creates tools, skills, interactions, content types, and rendering templates for the Vertesia plugin tool server. Handles file scaffolding, collection registration, and config.ts wiring. Use when adding new tool server resources to this plugin. +--- + +# Vertesia Tool Server Resource + +Step-by-step guide for creating tool server resources. Each resource follows the same workflow: + +1. Create files in the appropriate `src/tool-server///` directory +2. Export from the collection's `index.ts` +3. Register the collection in `src/tool-server//index.ts` (only when adding a new collection) + +For full code templates of every resource type, see `REFERENCE.md`. + +## Conventions + +- All imports use `.js` extensions: `import { x } from "./foo.js"` +- Use `satisfies` for type validation (`{} satisfies Tool`, `{} satisfies InCodeTypeSpec`, …) +- Icons are SVG strings exported as default from `.ts` files +- Import hooks (`?skill`, `?skills`, `?prompt`, `?template`, `?templates`, `?raw`) only work in Rollup-compiled tool-server code, **not** in UI code +- Snake_case for resource names (`my_tool`, `my_type`); PascalCase for TypeScript exports (`MyTool`, `MyType`) +- See `src/tool-server//examples/` for working starter code + +## Resource types + +### Tool + +Three files in `src/tool-server/tools///`: + +| File | Purpose | +|------|---------| +| `schema.ts` | TypeScript interface + JSONSchema (`satisfies JSONSchema`) | +| `.ts` | The `run` function — uses `ToolExecutionPayload

`, returns `ToolResultContent` | +| `index.ts` | `Tool

` definition (`satisfies Tool`) | + +Then export from `tools//index.ts` as a `ToolCollection`. + +→ Code in `REFERENCE.md` § Tool. + +### Skill + +Files in `src/tool-server/skills///`: + +| File | Required | Purpose | +|------|----------|---------| +| `SKILL.md` | yes | YAML frontmatter + instructions for the agent | +| `properties.ts` | no | Runtime gating (`isEnabled`) | +| `*.tsx` | no | Widgets (compiled to `dist/widgets/`) | +| `*.py`, `*.js` | no | Scripts (copied to `dist/scripts/`) | + +Skills are auto-discovered: the collection imports `./all?skills` — no per-skill imports needed. + +→ Code in `REFERENCE.md` § Skill. + +### Interaction + +Two flavors: + +- **Template-based** — `prompt.hbs` + `prompt_schema.ts` + `result_schema.ts` + `index.ts` (`InteractionSpec` importing the prompt via `?prompt`). +- **Code-based** — `index.ts` only, with `prompts: [{ role, content, content_type }, …]` inline. Use this for agents/conversations. + +Then export from `interactions//index.ts` as an `InteractionCollection`. + +→ Code in `REFERENCE.md` § Interaction (template-based) and § Interaction (code-based). + +### Content Type + +One file per type in `src/tool-server/types//.ts` (`InCodeTypeSpec`), then a `ContentTypesCollection` in `types//index.ts`. + +Key fields: `name` (snake_case), `object_schema` (JSON Schema with `additionalProperties: false`), `table_layout` (columns for the UI), `is_chunkable`, `strict_mode`. + +→ Code in `REFERENCE.md` § Content Type. + +### Rendering Template + +Folder per template in `src/tool-server/templates///`: + +- `TEMPLATE.md` with YAML frontmatter (`description`, `type: 'document' | 'presentation'`, optional `title`, `tags`) +- Asset files (SVG, LaTeX, PNG) — auto-discovered, copied to `dist/templates/` + +Templates are auto-discovered: the collection imports `./all?templates`. + +→ Code in `REFERENCE.md` § Rendering Template. + +## Collection registration + +Once a collection is exported from `src/tool-server///index.ts`, add it to the array in `src/tool-server//index.ts`: + +```typescript +// src/tool-server/tools/index.ts +import { ExampleTools } from "./examples/index.js"; +import { MyTools } from "./my-collection/index.js"; + +export const tools = [ExampleTools, MyTools]; +``` + +`config.ts` already wires those per-type index files into the server — no further changes needed there. + +Each collection needs an SVG `icon.svg.ts` (default string export). Code in `REFERENCE.md` § Collection registration & icons. + +## Verification + +After creating a resource: + +1. `pnpm build:server` +2. `pnpm start` +3. Check the admin UI at `http://localhost:3000/` — your resource should appear. +4. Or hit the API: `curl http://localhost:3000/api/tools` (or `/skills`, `/interactions`, `/types`, `/templates`). diff --git a/templates/plugin-template/.agents/skills/vertesia-ui/SKILL.md b/templates/plugin-template/.agents/skills/vertesia-ui/SKILL.md new file mode 100644 index 000000000..d6f7c8c59 --- /dev/null +++ b/templates/plugin-template/.agents/skills/vertesia-ui/SKILL.md @@ -0,0 +1,466 @@ +--- +name: vertesia-ui +description: Reference for building UIs with @vertesia/ui. Covers component API (Input, Button, VModal, VTabs, Table), list/detail tables with infinite scroll, sortable headers, FilterProvider with URL persistence and inline row filters, layout (Sidebar, FullHeightLayout, GenericPageNavHeader), routing (NestedRouterProvider, useParams, useNavigate), agent conversation (ModernAgentConversation), styling, and security. Use when creating or modifying React UI pages or components. +--- + +# Vertesia UI Development + +React UI built with React 19, Tailwind CSS 4, and `@vertesia/ui` components. + +For the full component API reference, see also `composableai/packages/ui/llms.txt` (shipped with the npm package). + +For a reusable list/detail table example with backend search, sort, facets, inline filters, and back-navigation preservation, see `references/generic-table-pattern.md`. + +## Required First Step: Component Inventory + +Before writing or refactoring UI code, explicitly check whether `@vertesia/ui` already provides the surface you need. + +At minimum, search for an existing component in these buckets: + +- `@vertesia/ui/core` +- `@vertesia/ui/layout` +- `@vertesia/ui/features` + +If a suitable component exists, use it. Do not reimplement it with raw HTML just because the local version is faster to type. + +This is especially strict for: + +- tables +- page headers +- filters +- modals +- tabs +- badges +- buttons +- selects +- text inputs +- side panels +- empty/loading/error states + +If you still introduce a custom wrapper, state which existing `@vertesia/ui` component you checked and why it was insufficient. + +## Import Paths + +```tsx +import { Button, Card, Input, VModal, VTabs } from '@vertesia/ui/core'; +import { useFetch, useToast, useIntersectionObserver } from '@vertesia/ui/core'; +import { FilterProvider, FilterBtn, FilterBar, FilterClear } from '@vertesia/ui/core'; +import { useNavigate, useParams, NavLink, NestedRouterProvider } from '@vertesia/ui/router'; +import { useUserSession } from '@vertesia/ui/session'; +import { Sidebar, SidebarSection, SidebarItem, useSidebarToggle, FullHeightLayout } from '@vertesia/ui/layout'; +import { GenericPageNavHeader, ModernAgentConversation } from '@vertesia/ui/features'; +import { VertesiaShell, StandaloneApp } from '@vertesia/ui/shell'; +``` + +## Key Component APIs + +### Input — Value-based onChange (NOT event-based) + +```tsx +// CORRECT — passes value directly + + setName(value.trim())} /> + +// WRONG — this will NOT work + setName(e.target.value)} /> // ❌ +``` + +**Textarea** uses standard React events: `onChange={(e) => setText(e.target.value)}` + +### Button + +```tsx + + + +// Variants: default, destructive, outline, secondary, ghost, link +// Sizes: default, sm, lg, icon +``` + +### VModal + +```tsx + setOpen(false)} size="md"> + Title + Content + + {/* Primary first */} + + + +``` + +**Note:** VModalFooter uses `flex-row-reverse` — primary button first in code, appears on the right. + +### VTabs + +```tsx + }, + { name: 'tab2', label: 'Second', content: }, +]}> + + + +``` + +### Table + +```tsx +import { Table, TBody, THead, Th, Tr, Td } from '@vertesia/ui/core'; + + + + + + + + + + {items.map(item => ( + + + + + ))} + +
NameStatus
{item.name}{item.status}
+``` + +`TBody` renders loading skeletons when `isLoading=true`. **Only use `isLoading` for initial empty-state load**, not for "load more" — show a separate `` below the table for appending. + +Use this instead of custom `` wrappers unless there is a documented gap. + +### Infinite Scroll (Lazy Loading) + +```tsx +import { useIntersectionObserver } from '@vertesia/ui/core'; + +const loadMoreRef = useRef(null); +const fetchGenRef = useRef(0); +const [isReady, setIsReady] = useState(false); + +// Fetch with stale-request prevention +const fetchItems = useCallback((currentOffset: number, append: boolean) => { + const gen = ++fetchGenRef.current; + setIsLoading(true); + client.someApi.list({ limit: PAGE_SIZE, offset: currentOffset }) + .then(data => { + if (gen !== fetchGenRef.current) return; // stale + setItems(prev => append ? [...prev, ...data.items] : data.items); + setHasMore(data.hasNext); + setOffset(currentOffset + data.items.length); + setIsReady(true); + }) + .finally(() => { if (gen === fetchGenRef.current) setIsLoading(false); }); +}, [deps]); + +// Trigger load when sentinel is visible +useIntersectionObserver(loadMoreRef, () => { + if (isReady && hasMore && !isLoading) { + setIsReady(false); + fetchItems(offset, true); + } +}, { threshold: 0.1, deps: [isReady, hasMore, isLoading, offset] }); + +// Reset helper — reuse for initial load, filter changes, refresh +const resetAndFetch = useCallback(() => { + setItems([]); setOffset(0); setHasMore(true); setIsReady(false); + fetchItems(0, false); +}, [fetchItems]); +``` + +```tsx +{/* After the table */} +{isLoading && items.length > 0 &&
} +
+{!isLoading && items.length === 0 &&
No items found
} +``` + +**Always** use a generation counter (`fetchGenRef`) to prevent stale responses from race conditions. + +### Filter System + +```tsx +import { FilterProvider, FilterBtn, FilterBar, FilterClear } from '@vertesia/ui/core'; + +const filterGroups: FilterGroup[] = [ + { name: 'status', placeholder: 'Status', type: 'select', multiple: true, + options: [ + { value: 'active', label: 'Active' }, + { value: 'archived', label: 'Archived' }, + ] }, + { name: 'search', placeholder: 'Search', type: 'text', multiple: false }, +]; + + +
+ + + +
+
+``` + +Sort select options alphabetically by label. `FilterProvider` handles URL persistence automatically. + +### Spinner, ErrorBox, Badge + +```tsx +import { Spinner, ErrorBox, Badge } from '@vertesia/ui/core'; + +if (isLoading) return ; +if (error) return {error.message}; +Status +``` + +## Routing + +Use `NestedRouterProvider` for plugin routing (nested within host app): + +```tsx +const routes = [ + { path: '/', Component: HomePage }, + { path: '/items/:id', Component: ItemPage }, + { path: '*', Component: NotFound }, +]; + +``` + +### Navigation hooks + +```tsx +const navigate = useNavigate(); +navigate('/items/123'); // Push +navigate(-1); // Go back + +const { id } = useParams(); // From /items/:id +const [searchParams] = useSearchParams(); +const path = useLocation().pathname; // Current path +``` + +### NavLink + +```tsx +Go to Items +``` + +### List / Detail Preservation + +If a list page links to a detail page and users are expected to go back, do not keep the list state inside the list page component. + +Preserve these concerns above the route boundary when they matter: + +- active filters +- search query +- sort +- pagination or loaded results +- row selection +- scroll position + +Use a provider above `NestedRouterProvider` or above the list/detail route split. Do not use a module-level singleton. + +If `FilterProvider` is involved, remember that it restores filters from the URL on mount. If your list state also survives route changes, normalize or dedupe filter writes at the provider boundary so the same filter does not get appended repeatedly on back-navigation. + +For scroll restoration: + +- persist the list scroll position in provider state and `window.history.state.data` +- restore it in `useLayoutEffect` +- wait until the list has rendered before restoring, typically with `requestAnimationFrame` + +Do not treat list/detail navigation as a cosmetic issue. If filters and scroll reset on back, the UX is wrong. + +### Search vs Find For Table Surfaces + +For table/list surfaces that need any combination of: + +- full-text search +- backend sort +- backend facets + +use one backend `search` path consistently. + +Do not mix `find` for the default state with `search` for filtered states if the page exposes sort and facet-driven filtering. That creates different backend behavior depending on UI state and weakens the table contract. + +Use `find` only for simple exact-match fetches that do not require backend sort, full-text behavior, or facets. + +## Completion Check + +Before calling a UI task complete, run a short conformance pass: + +1. any raw `
` where `Table` / `THead` / `TBody` should be used? +2. any native ` + + + + + + + navigate(`/items/${id}`)} + /> + + ); +} +``` + +## Table Pattern + +Use `Table`, `THead`, and `TBody` directly. + +```tsx +
+ + + + + + + + + {items.map((item) => ( + onOpen(item.id)} + > + + + ))} + +
+
+ {item.title} + onFilterValue("title", item.title)} + /> +
+
+``` + +## Inline Filter Button Pattern + +Do not assemble Tailwind hover classes dynamically. + +Bad: + +```tsx +className={`opacity-0 group-hover/${groupName}:opacity-100`} +``` + +Good: + +```tsx +const hoverClass = { + title: "group-hover/title:opacity-100", + owner: "group-hover/owner:opacity-100", +}[groupName]; +``` + +```tsx +function InlineFilterButton({ + tooltip, + hoverClass, + onClick, +}: { + tooltip: string; + hoverClass: string; + onClick: () => void; +}) { + return ( + + + + ); +} +``` + +## Filter Deduplication Rule + +If both of these are true: + +- the list state survives route changes +- `FilterProvider` restores filters from the URL on mount + +then filter writes must be normalized or deduped. + +Otherwise, going to detail and back can duplicate chips and query params. + +```tsx +function dedupeFilters(filters: Filter[]) { + const seen = new Set(); + const deduped: Filter[] = []; + + for (const filter of filters) { + const normalizedValue = Array.isArray(filter.value) + ? filter.value.map((entry) => typeof entry === "string" ? entry : `${entry.value}|${entry.label || ""}`) + : []; + const key = [ + filter.name, + filter.type, + filter.multiple ? "multi" : "single", + ...normalizedValue, + ].join("::"); + + if (seen.has(key)) continue; + seen.add(key); + deduped.push(filter); + } + + return deduped; +} +``` + +## Scroll Persistence Pattern + +```tsx +function persistScrollTop(scrollTop: number) { + const state = window.history.state || {}; + window.history.replaceState({ + ...state, + data: { + ...(state.data || {}), + listScrollTop: scrollTop, + }, + }, ""); +} + +function readScrollTop() { + const state = window.history.state as { data?: { listScrollTop?: number } } | null; + return state?.data?.listScrollTop; +} +``` + +Restore in `useLayoutEffect`, not plain `useEffect`. + +## Checklist + +Before calling a list/detail table done: + +1. does the table use `Table`, `THead`, `TBody` directly? +2. does backend search own rows, sort, and facets? +3. is list state above the route boundary? +4. are URL-restored filters deduped or normalized? +5. is scroll restored on back-navigation? +6. are hover-reveal classes literal/static so Tailwind emits them? diff --git a/templates/plugin-template/.agents/skills/vertesia-ui/references/security.md b/templates/plugin-template/.agents/skills/vertesia-ui/references/security.md new file mode 100644 index 000000000..d1f9f052a --- /dev/null +++ b/templates/plugin-template/.agents/skills/vertesia-ui/references/security.md @@ -0,0 +1,89 @@ +# Security Practices for `@vertesia/ui` Pages + +Quick rules and the patterns they correspond to. Apply these to any page or component that takes user input or makes API calls. + +## XSS Prevention + +React escapes JSX content automatically. Be careful only with `dangerouslySetInnerHTML`. + +```tsx +// NEVER use dangerouslySetInnerHTML with unsanitized input +

// ❌ + +// If you must render HTML, sanitize first +import DOMPurify from 'dompurify'; +
// ✅ +``` + +## URL Validation + +Validate user-provided URLs before rendering as links: + +```tsx +function isValidUrl(url: string): boolean { + try { + const parsed = new URL(url); + return ['http:', 'https:'].includes(parsed.protocol); + } catch { return false; } +} + +{isValidUrl(userUrl) && Link} +``` + +## Error Handling + +Never expose internal API details to users. Log full details to console; show generic messages with `useToast`. + +```tsx +try { + await client.objects.retrieve(objectId); +} catch (error) { + console.error('Object retrieval failed:', error); // full details + toast({ status: 'error', title: 'Unable to load data. Please try again.' }); +} +``` + +## Secrets + +- Never hardcode API keys or tokens — use environment variables. +- Prefix client-side env vars with `VITE_` (they are embedded in the bundle). +- Keep sensitive keys server-side only (tool server code, not UI). +- Never commit `.env` files — use `.env.example` for documentation. + +## Form Security + +Validate inputs before submitting (Zod or similar). Validate file uploads (type, size, extension): + +```tsx +const MAX_SIZE = 10 * 1024 * 1024; // 10MB +const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'application/pdf']; + +if (file.size > MAX_SIZE) throw new Error('File too large'); +if (!ALLOWED_TYPES.includes(file.type)) throw new Error('Invalid file type'); +``` + +## Client-Side Throttling + +Disable buttons after the first click and re-enable in a `finally` block. Apply to **every** button that triggers an async action — form submissions, deletions, API calls. + +```tsx +const [isSubmitting, setIsSubmitting] = useState(false); + +async function handleSubmit() { + if (isSubmitting) return; + setIsSubmitting(true); + try { + await client.someApi.doAction(payload); + toast({ status: 'success', title: 'Item saved' }); + } catch (error) { + console.error('Action failed:', error); + toast({ status: 'error', title: 'Unable to save. Please try again.' }); + } finally { + setIsSubmitting(false); + } +} + + +``` diff --git a/templates/plugin-template/.claude/settings.json b/templates/plugin-template/.claude/settings.json new file mode 100644 index 000000000..2991c5845 --- /dev/null +++ b/templates/plugin-template/.claude/settings.json @@ -0,0 +1,52 @@ +{ + "permissions": { + "allow": [ + "Read", + "Glob", + "Grep", + "Bash(git status:*)", + "Bash(git log:*)", + "Bash(git diff:*)", + "Bash(git branch:*)", + "Bash(git show:*)", + "Bash(git ls-remote:*)", + "Bash(git rev-parse:*)", + "Bash(git remote:*)", + "Bash(git tag:*)", + "Bash(git stash list:*)", + "Bash(git blame:*)", + "Bash(git fetch:*)", + "Bash(gh pr list:*)", + "Bash(gh pr view:*)", + "Bash(gh pr checks:*)", + "Bash(gh pr diff:*)", + "Bash(gh issue list:*)", + "Bash(gh issue view:*)", + "Bash(gh repo view:*)", + "Bash(gh run list:*)", + "Bash(gh run view:*)", + "Bash(gh run watch:*)", + "Bash(gh workflow list:*)", + "Bash(gh workflow view:*)", + "Bash(gh api:*)", + "Bash(gcloud * list:*)", + "Bash(gcloud * describe:*)", + "Bash(gcloud * get:*)", + "Bash(cd *)", + "Bash(ls:*)", + "Bash(pwd:*)", + "Bash(pnpm build:*)", + "Bash(pnpm run build:*)", + "Bash(pnpm turbo build:*)", + "Bash(turbo build:*)", + "Bash(pnpm tsc:*)", + "Bash(pnpm lint:*)", + "Bash(pnpm test:*)", + "Bash(pnpm vitest:*)", + "Bash(pnpm vitest run:*)", + "Bash(npx vitest run:*)", + "WebSearch", + "WebFetch" + ] + } +} \ No newline at end of file diff --git a/templates/plugin-template/.claude/skills/vertesia-api/SKILL.md b/templates/plugin-template/.claude/skills/vertesia-api/SKILL.md index 68894e3fa..090f6292b 100644 --- a/templates/plugin-template/.claude/skills/vertesia-api/SKILL.md +++ b/templates/plugin-template/.claude/skills/vertesia-api/SKILL.md @@ -109,6 +109,65 @@ const results = await client.store.workflows.searchRuns({ await client.store.workflows.sendSignal(workflowId, runId, 'signal-name', payload); ``` +## Agent Runs (`client.agents`) + +For listing/searching the runs that show up in chat sidebars and conversation pages. + +```typescript +// List recent runs (date-sorted; supports filter + cursor pagination) +const { items, total_count, next_cursor } = await client.agents.list({ + limit: 100, + sort: 'started_at', // ⚠️ ONLY 'started_at' or 'updated_at' — no other fields + order: 'desc', + interaction: 'sys:my_agent', // optional, single interaction code + status: 'completed', // optional, single or array +}); + +// Backend full-text search (Elasticsearch) +const { hits, total } = await client.agents.search({ + query: 'invoice review', + interaction: 'sys:my_agent', + status: ['running', 'completed'], + limit: 50, +}); +``` + +### Field-name gotchas (the ones that will bite you) + +`AgentRunResponse = AgentRun | ProcessRun`. Only `AgentRun` (`run_kind === 'agent'`) has `interaction`, `topic`, `data`. Common mistakes: + +| Field | What it is | Trap | +|---|---|---| +| `run.interaction` | The interaction **code** (e.g. `"sys:GeneralAgent"`) — always set on agent runs | This is what the backend filter accepts and what stable IDs should use. | +| `run.interaction_name` | A *human-readable display name* — **optional, often empty** | Don't filter or key on this; it'll silently drop most rows. Use it as a label fallback only. | +| `run.topic` | Long topic generated by topic analysis — **optional, set asynchronously** | Empty for new conversations. | +| `run.title` | Short title — **optional, may be empty** | Often unset. | +| `run.data.user_prompt` | The user's first message — usually present | Best fallback for a label when topic+title are missing. | + +**Display-label fallback chain** for conversation rows / sidebars: + +```typescript +const isAgent = run.run_kind === 'agent'; +const label = + (isAgent ? run.topic : undefined) || + run.title || + (isAgent && typeof run.data === 'object' && run.data + ? String((run.data as { user_prompt?: unknown }).user_prompt ?? '').trim() || undefined + : undefined) || + 'Untitled conversation'; +``` + +**Agent display + filter values** — use `interaction` as the value, `interaction_name || interaction` as the label: + +```typescript +const value = isAgent ? run.interaction : undefined; // for filters / keys +const label = run.interaction_name || run.interaction; // for display +``` + +### Sort limitations + +`agents.list` only sorts by `'started_at' | 'updated_at'`. To sort by any other column (topic, status, agent code), apply the sort **client-side** to the loaded page. `agents.search` doesn't accept a sort parameter at all (results come back by relevance). + ## Interaction Execution ```typescript diff --git a/templates/plugin-template/.claude/skills/vertesia-demo-content/SKILL.md b/templates/plugin-template/.claude/skills/vertesia-demo-content/SKILL.md new file mode 100644 index 000000000..594bbfa05 --- /dev/null +++ b/templates/plugin-template/.claude/skills/vertesia-demo-content/SKILL.md @@ -0,0 +1,105 @@ +--- +name: vertesia-demo-content +description: Generate realistic reusable demo or seed content for Vertesia applications and upload it through the Vertesia CLI, API, or browser session. Use when building Vertesia custom apps, plugins, prototypes, repository workflows, AI extraction workflows, search/filter pages, or demos where the app should use real store objects instead of hardcoded mock arrays; includes checking the active CLI profile and asking before mutating a Vertesia project. +--- + +# Vertesia Demo Content + +Use this skill when a Vertesia app needs real content in the store so upload, search, filters, metadata extraction, workflows, and detail pages can be exercised end to end. + +## Core Rule + +Prefer realistic generated content uploaded to Vertesia over hardcoded UI mock data. Static arrays are acceptable only as temporary empty states, fallback examples, or tests that intentionally avoid network/project mutation. + +## Workflow + +1. Inspect the active target before mutation: + - Run `vertesia profiles show`. + - Identify the active/default profile, account, project, environment, and API base URL. + - Do not print full API tokens in the final answer. + - Ask the user to confirm the target project before uploading, deleting, or updating content. + +2. Confirm the target content type: + - Prefer the actual type id from Vertesia’s type catalog or the app/tool-server package. + - For app-contributed in-code types, do not assume the local type name is the runtime create id. + - Distinguish: + - query/search type name, for example `clm_contract` + - app in-code create id, for example `app::clm:ClmContract` + - If uncertain, inspect available types with CLI, project app-type APIs, and app endpoints before uploading. + +3. Generate useful files: + - Use Markdown for document-like demos unless the user requests DOCX, PDF, JSON, or another format. + - Include domain-specific details that make search, filters, extraction, summaries, and detail views meaningful. + - Include structured headings and bullet metadata so AI extraction has clear signals. + - Save generated files under `/tmp`, `/private/tmp`, or a user-approved fixture path. Do not commit generated seed data unless requested. + +4. Upload through the best available channel: + - Prefer `vertesia content post --type --mime text/markdown --name --path ` when the CLI profile is confirmed and the target type is a stored/system type or the CLI is known to accept the app-defined type. + - Use the browser/plugin session when upload must happen as the signed-in UI user. + - Use direct API when the CLI rejects an app-defined type but the project app-type APIs and runtime create path accept it. + +5. Wire the app to real objects: + - Replace static arrays with `store.objects.search`, `store.objects.list`, or collection search. + - Filter by full text plus metadata fields such as `properties.status`, `properties.owner`, `properties.category`, `properties.risk_level`, or app-specific fields. + - After upload, trigger a refetch or insert the created object into local state. + - For metadata extraction, read object text, run the relevant interaction, then update object properties. + +## App Type Rules + +When seeding app-defined content: + +- Use query/search names such as `clm_contract` for filters and object searches. +- Use the resolved app type code such as `app::clm:ClmContract` for object creation. +- If UI actions create child records, use the same explicit app type codes there too. +- If agent runs or workflow `executeInteraction` calls target app interactions, use the full app interaction id such as `app::clm:ExtractContractMetadata`. + +## CLI Limitation + +`vertesia content post` may validate only against `client.types.list()`, which can exclude app-defined in-code types. + +If the CLI says the type does not exist: + +1. verify the app package exposes the type +2. verify the project app-type APIs list and retrieve it +3. test direct object creation with the resolved app type code +4. use a repo-local uploader script or direct API if the project accepts the type but the CLI still rejects it + +Do not assume CLI rejection means the app type is missing from the project. + +## CLI Pattern + +Upload generated Markdown: + +```bash +vertesia content post /tmp/demo-seed/acme-doc.md \ + --type \ + --mime text/markdown \ + --name "Acme Demo Document" \ + --path /demo +``` + +List uploaded content: + +```bash +vertesia content list /demo +``` + +## Generator Script + +Use `scripts/generate_markdown_seed.py` to generate reusable Markdown seed data: + +```bash +python3 .claude/skills/vertesia-demo-content/scripts/generate_markdown_seed.py \ + --out /tmp/vertesia-demo \ + --domain generic \ + --count 3 +``` + +Supported built-in domains: + +- `generic`: reusable business documents for repository/search/extraction demos. +- `clm`: contract lifecycle documents with dates, values, renewal terms, obligations, and clauses. +- `support`: customer support cases with severity, owner, product, timeline, and resolution notes. +- `policy`: internal policy/procedure documents with owners, effective dates, controls, and exceptions. + +Use `--domain generic` unless the app domain is known. diff --git a/templates/plugin-template/.claude/skills/vertesia-demo-content/scripts/generate_markdown_seed.py b/templates/plugin-template/.claude/skills/vertesia-demo-content/scripts/generate_markdown_seed.py new file mode 100755 index 000000000..c215c30b3 --- /dev/null +++ b/templates/plugin-template/.claude/skills/vertesia-demo-content/scripts/generate_markdown_seed.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Generate realistic Markdown seed documents for Vertesia demos.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + + +Sample = dict[str, str] + + +SAMPLES: dict[str, list[Sample]] = { + "generic": [ + { + "slug": "acme-business-review", + "title": "Acme Business Review", + "category": "Customer Review", + "owner": "Customer Success", + "status": "In Review", + "date": "2026-05-15", + "summary": "Quarterly account review covering adoption, risks, renewal outlook, and executive follow-ups.", + "details": "Acme expanded usage across three departments. The main open risk is delayed security questionnaire completion before renewal.", + "actions": "Schedule executive sponsor meeting; complete security questionnaire; prepare renewal pricing proposal.", + }, + { + "slug": "northstar-implementation-plan", + "title": "Northstar Implementation Plan", + "category": "Project Plan", + "owner": "Professional Services", + "status": "Active", + "date": "2026-06-01", + "summary": "Implementation plan for a phased rollout with data migration, training, acceptance criteria, and launch support.", + "details": "Phase one covers workspace setup and identity configuration. Phase two covers content migration and automation testing.", + "actions": "Confirm migration sample set; validate SSO; schedule administrator training.", + }, + { + "slug": "globex-risk-memo", + "title": "Globex Risk Review Memo", + "category": "Risk Memo", + "owner": "Operations", + "status": "Escalated", + "date": "2026-05-22", + "summary": "Operational risk memo documenting supplier delay, customer impact, mitigation plan, and approval needs.", + "details": "Supplier lead time increased from four weeks to nine weeks. Customer launch impact is likely unless substitute inventory is approved.", + "actions": "Approve substitute supplier; notify account team; update delivery forecast.", + }, + ], + "clm": [ + { + "slug": "acme-enterprise-saas-agreement", + "title": "Acme Enterprise SaaS Agreement", + "category": "Customer MSA", + "owner": "Sales Ops", + "status": "In Review", + "date": "2026-05-15", + "summary": "Enterprise subscription agreement with negotiated liability, data processing, uptime SLA, and renewal terms.", + "details": "Counterparty: Acme Corp. Value: $425,000 USD. Risk: High. Expiration: 2026-11-30. Renewal notice: 2026-08-01.", + "actions": "Finance review for contract value; legal review for liability carveouts; security review for data processing exhibit.", + }, + { + "slug": "northstar-vendor-services", + "title": "Northstar Vendor Services Agreement", + "category": "Vendor Agreement", + "owner": "Procurement", + "status": "Active", + "date": "2026-02-01", + "summary": "Managed services agreement with quarterly service reviews, payment milestones, and vendor performance obligations.", + "details": "Counterparty: Northstar Services. Value: $98,000 USD. Risk: Medium. Expiration: 2026-07-15. Renewal notice: 2026-04-16.", + "actions": "Track monthly service report; review SLA performance; prepare renewal recommendation.", + }, + { + "slug": "globex-channel-partner-addendum", + "title": "Globex Channel Partner Addendum", + "category": "Partner Amendment", + "owner": "Partner Sales", + "status": "Renewal Pending", + "date": "2026-04-01", + "summary": "Channel amendment with non-standard exclusivity, revenue-share commitments, and accelerated renewal decision timing.", + "details": "Counterparty: Globex. Value: $720,000 USD. Risk: Critical. Expiration: 2026-06-30. Renewal notice: 2026-05-15.", + "actions": "Executive review for exclusivity; legal review for territory language; renewal decision by notice date.", + }, + ], + "support": [ + { + "slug": "acme-login-incident", + "title": "Acme Login Incident", + "category": "Support Case", + "owner": "Support Engineering", + "status": "Open", + "date": "2026-05-03", + "summary": "Priority support case for intermittent login failures affecting Acme administrators.", + "details": "Severity: High. Product area: Authentication. Error rate increased after SSO certificate rotation.", + "actions": "Validate IdP metadata; rotate cached certificate; provide customer incident summary.", + }, + { + "slug": "northstar-export-request", + "title": "Northstar Export Performance Request", + "category": "Support Case", + "owner": "Customer Support", + "status": "Pending Customer", + "date": "2026-05-07", + "summary": "Customer reports slow exports for large document collections during month-end reporting.", + "details": "Severity: Medium. Product area: Reporting. Export size exceeds 50,000 rows.", + "actions": "Request sample export parameters; propose async export workflow; monitor next month-end run.", + }, + { + "slug": "globex-api-rate-limit", + "title": "Globex API Rate Limit Review", + "category": "Support Case", + "owner": "Developer Support", + "status": "Escalated", + "date": "2026-05-10", + "summary": "Integration is hitting API rate limits during nightly synchronization.", + "details": "Severity: High. Product area: API. Current client retries aggressively without backoff.", + "actions": "Share retry guidance; evaluate rate limit increase; review integration logs.", + }, + ], + "policy": [ + { + "slug": "data-retention-policy", + "title": "Data Retention Policy", + "category": "Policy", + "owner": "Compliance", + "status": "Approved", + "date": "2026-01-01", + "summary": "Policy defining retention periods, archival requirements, deletion approvals, and exception handling.", + "details": "Customer records are retained for seven years unless a shorter contractual retention period applies.", + "actions": "Review exceptions quarterly; confirm deletion audit trail; update retention schedule annually.", + }, + { + "slug": "vendor-onboarding-procedure", + "title": "Vendor Onboarding Procedure", + "category": "Procedure", + "owner": "Procurement", + "status": "Active", + "date": "2026-03-01", + "summary": "Procedure for onboarding vendors with security review, contract approval, tax setup, and performance tracking.", + "details": "Critical vendors require security approval before contract execution and annual reassessment.", + "actions": "Collect W-9; complete security questionnaire; create vendor performance record.", + }, + { + "slug": "incident-response-standard", + "title": "Incident Response Standard", + "category": "Standard", + "owner": "Security", + "status": "In Review", + "date": "2026-04-15", + "summary": "Operational standard for detecting, triaging, communicating, and resolving security incidents.", + "details": "Critical incidents require executive notification within one hour and customer notification review within twenty-four hours.", + "actions": "Validate escalation matrix; run tabletop exercise; update postmortem template.", + }, + ], +} + + +def render(sample: Sample, domain: str) -> str: + return f"""# {sample['title']} + +## Metadata + +- Domain: {domain} +- Category: {sample['category']} +- Owner: {sample['owner']} +- Status: {sample['status']} +- Record Date: {sample['date']} + +## Summary + +{sample['summary']} + +## Details + +{sample['details']} + +## Actions + +{sample['actions']} + +## Extraction Hints + +This document is suitable for testing Vertesia upload, full-text search, metadata filters, AI summarization, metadata extraction, workflow routing, and object detail views. +""" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate Markdown seed documents for Vertesia demos.") + parser.add_argument("--out", required=True, help="Output directory.") + parser.add_argument("--domain", choices=sorted(SAMPLES), default="generic", help="Built-in sample domain.") + parser.add_argument("--count", type=int, default=3, help="Number of documents to generate.") + args = parser.parse_args() + + out = Path(args.out) + out.mkdir(parents=True, exist_ok=True) + + samples = SAMPLES[args.domain] + count = max(1, min(args.count, len(samples))) + for sample in samples[:count]: + path = out / f"{sample['slug']}.md" + path.write_text(render(sample, args.domain), encoding="utf-8") + print(path) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/templates/plugin-template/.claude/skills/vertesia-dsl-workflow/SKILL.md b/templates/plugin-template/.claude/skills/vertesia-dsl-workflow/SKILL.md new file mode 100644 index 000000000..99b1e2e6f --- /dev/null +++ b/templates/plugin-template/.claude/skills/vertesia-dsl-workflow/SKILL.md @@ -0,0 +1,212 @@ +--- +name: vertesia-dsl-workflow +description: Reference for DSL workflow definitions, remote activities, variable resolution, and conditions. Use when creating or debugging DSL workflows that call remote activities from plugins. +--- + +# DSL Workflows & Remote Activities + +DSL workflows are declarative step-by-step pipelines defined as JSON. They can call built-in activities and remote activities provided by plugins. + +## Key Source Files + +| File | Purpose | +|------|---------| +| `composableai/packages/workflow/src/dsl/dsl-workflow.ts` | DSL engine: variable init, step execution, remote activity calls | +| `composableai/packages/workflow/src/dsl/vars.ts` | `Vars` class: variable storage, resolution (`${varName}`), dotted path access | +| `composableai/packages/workflow/src/dsl/conditions.ts` | `matchCondition()`: operator-based condition matching | +| `composableai/packages/workflow/src/activities/getObjectFromStore.ts` | Built-in activity to load a ContentObject into workflow vars | +| `composableai/packages/tools-sdk/src/ActivityCollection.ts` | `ActivityCollection` class for registering plugin activities | + +## Workflow Definition Structure + +```typescript +await client.workflows.definitions.create({ + name: 'MyWorkflow', + description: 'Description', + debug_mode: true, + steps: [ + { name: 'activityName', type: 'activity', params: { ... } }, + // ... + ], + vars: {}, // initial workflow variables +}); +``` + +## Built-in Activities + +Common built-in activities available in all DSL workflows: + +| Activity | Purpose | Key Params | +|----------|---------|------------| +| `setDocumentStatus` | Set document status | `{ status: 'processing' \| 'completed' \| ... }` | +| `getObjectFromStore` | Load ContentObject into vars | `{ }` (uses objectId automatically) | +| `generateEmbeddings` | Compute embeddings | `{ type: 'text' \| 'properties' }` | + +### `getObjectFromStore` Pattern + +Use `output` to store the loaded object in a named variable, then access nested fields with dotted paths: + +```typescript +{ name: 'getObjectFromStore', type: 'activity', output: 'task' }, +// Now 'task' is in vars — access fields as 'task.properties.my_field' +``` + +## Remote Activities + +Plugin activities registered via `ActivityCollection` are invoked as DSL steps using the naming convention: + +``` +app::: +``` + +Example: `app:bpce-orchestrator:intake:categorize_task` + +App interactions used by `executeInteraction` follow a similar pattern: + +``` +app::: +``` + +Do not assume a bare interaction name like `ExtractContractMetadata` will resolve inside a workflow if the interaction is contributed by an app. + +### Variable Resolution for Remote Activities + +**CRITICAL**: Remote activity params are resolved in a **new `Vars` scope** (see `dsl-workflow.ts:422`). Workflow-level variables like `objectId` are NOT automatically available in remote activity params. + +You must use `import` to bring workflow variables into scope: + +```typescript +// WRONG - ${objectId} will be undefined +{ + name: 'app:my-plugin:collection:my_activity', + type: 'activity', + params: { task_id: '${objectId}' }, +} + +// CORRECT - import brings objectId into the new Vars scope +{ + name: 'app:my-plugin:collection:my_activity', + type: 'activity', + import: ['objectId'], + params: { task_id: '${objectId}' }, + output: 'result', +} +``` + +The `import` field tells the DSL engine to copy the listed variables from the workflow scope into the activity's `Vars` scope via `vars.createImportVars(activity.import)` at `dsl-workflow.ts:378`. + +### How `objectId` is Initialized + +At `dsl-workflow.ts:133`, the DSL engine initializes workflow vars with: +- `objectId` = first object ID from the trigger event (`objectIds[0]`) +- Plus any vars defined in the workflow definition's `vars` field + +## Conditions on Steps + +Use `condition` to skip steps based on workflow variable values. + +### Condition Syntax + +Conditions use operator objects from `conditions.ts`. The `matchCondition()` function iterates the keys of the condition value and looks them up in the `conditionFns` registry. + +**Available operators:** + +| Operator | Description | Example | +|----------|-------------|---------| +| `$eq` | Equals | `{ $eq: 'value' }` | +| `$ne` | Not equals | `{ $ne: 'value' }` | +| `$in` | In array | `{ $in: ['a', 'b'] }` | +| `$nin` | Not in array | `{ $nin: ['a', 'b'] }` | +| `$exists` | Field exists | `{ $exists: true }` | +| `$null` | Is null/undefined | `{ $null: true }` | +| `$gt`, `$gte`, `$lt`, `$lte` | Comparisons | `{ $gt: 50 }` | +| `$regexp` | Regex match | `{ $regexp: '^EPR-' }` | +| `$startsWith`, `$endsWith`, `$contains` | String ops | `{ $contains: 'text' }` | +| `$or` | OR conditions | `{ $or: [{ $eq: 'a' }, { $eq: 'b' }] }` | + +### CRITICAL: Always Use Operator Objects + +**NEVER pass plain values as conditions.** Plain strings/numbers cause `Unknown condition: 0` errors because `matchCondition()` iterates string characters as keys. + +```typescript +// WRONG - causes "Unknown condition: 0" error +condition: { 'task.properties.task_status': 'categorisation' } + +// CORRECT - use operator object +condition: { 'task.properties.task_status': { $eq: 'categorisation' } } +``` + +### Dotted Path Access in Conditions + +Conditions support dotted paths to access nested variables. The `vars.match()` method uses `getValue(path)` which resolves dotted paths like `task.properties.task_status`: + +```typescript +{ + name: 'app:my-plugin:collection:my_activity', + type: 'activity', + condition: { 'task.properties.field_name': { $eq: 'expected_value' } }, + import: ['objectId'], + params: { task_id: '${objectId}' }, +} +``` + +This requires a prior `getObjectFromStore` step with `output: 'task'` to populate the variable. + +## Complete Workflow Example + +A workflow that loads a task, conditionally processes it, computes embeddings, and sets status: + +```typescript +steps: [ + { name: 'setDocumentStatus', type: 'activity', params: { status: 'processing' } }, + { name: 'getObjectFromStore', type: 'activity', output: 'task' }, + { + name: 'app:my-plugin:intake:categorize_task', + type: 'activity', + condition: { 'task.properties.task_status': { $eq: 'categorisation' } }, + import: ['objectId'], + params: { task_id: '${objectId}' }, + output: 'categorize_result', + }, + { + name: 'app:my-plugin:intake:compute_priority', + type: 'activity', + condition: { 'task.properties.task_status': { $eq: 'categorisation' } }, + import: ['objectId'], + params: { task_id: '${objectId}' }, + output: 'priority_result', + }, + { name: 'generateEmbeddings', type: 'activity', params: { type: 'text' } }, + { name: 'generateEmbeddings', type: 'activity', params: { type: 'properties' } }, + { name: 'setDocumentStatus', type: 'activity', params: { status: 'completed' } }, +] +``` + +## Workflow Rules + +Workflow rules trigger workflows based on events. They match on event name and content type: + +```typescript +await client.workflows.rules.create({ + name: 'My Rule', + endpoint: `wf:${workflowDefinitionId}`, + match: { + '$and': [ + { 'event.name': 'create' }, + { 'event.data.type.name': 'my_content_type' }, + ] + }, +}); +``` + +When reusing a standard intake workflow such as `StandardDocumentIntake`, prefer staging custom enrichment on a later `update` event after the generic intake marks the object completed. This avoids two workflows racing on the same `create` event. + +## Common Pitfalls + +1. **`${objectId}` undefined in remote activity params**: Add `import: ['objectId']` to the step +2. **`Unknown condition: 0` error**: Condition value is a plain string, not an operator object — wrap with `{ $eq: ... }` +3. **Condition not matching nested fields**: Ensure a prior `getObjectFromStore` step with `output` set to populate the variable +4. **Activities not found**: Verify the naming convention `app:::` and that the `ActivityCollection` is registered in `config.ts` +5. **App interaction not found from `executeInteraction`**: Use the full app interaction id `app:::`, not a bare interaction name +6. **App child record creation fails with app type not found**: Use the resolved app type code in `client.objects.create`, not `client.types.getTypeByName()` if the type is app-contributed in-code +7. **Custom CLM/business enrichment races standard intake**: move the custom rule off `create` and trigger on a later `update` condition such as `status=completed` diff --git a/templates/plugin-template/.claude/skills/vertesia-gap-assessment/SKILL.md b/templates/plugin-template/.claude/skills/vertesia-gap-assessment/SKILL.md new file mode 100644 index 000000000..f66836cd2 --- /dev/null +++ b/templates/plugin-template/.claude/skills/vertesia-gap-assessment/SKILL.md @@ -0,0 +1,187 @@ +--- +name: vertesia-gap-assessment +description: Assess the gap between arbitrary requirements and existing Vertesia capabilities before proposing implementation work. Use when reviewing requirement documents, discovery notes, demo asks, or feature requests for a Vertesia app, plugin, workflow, or agent so you can separate native platform support, installed project capabilities, current app implementation, and true custom work. +--- + +# Vertesia Gap Assessment + +Use this skill before proposing implementation plans for a Vertesia solution. The goal is to avoid inventing custom work too early and to ground recommendations in four sources of truth: + +1. live project state +2. official Vertesia docs +3. current codebase +4. related local examples + +Read `references/sources-and-checks.md` at the start of the assessment. + +## What To Produce + +Produce a compact assessment with these sections: + +- Requirement summary +- Existing Vertesia capabilities relevant to the ask +- Existing project or app assets that can be reused +- Gap matrix: + - requirement + - native support + - project support + - app support + - custom work needed +- Recommended implementation path +- Risks or unknowns requiring verification + +Keep the distinction explicit between: + +- what Vertesia supports in general +- what the target project has installed or configured +- what the current app or plugin already uses + +## Workflow + +### 1. Normalize the requirements + +Extract the requested outcomes and group them into a short capability list. Use whatever groups fit the ask, but these are the default buckets: + +- intake +- repository and search +- workflow and approvals +- lifecycle and reminders +- reporting and analytics +- integrations +- assistant and agent behavior +- mobile or responsive UX +- admin and settings + +Do not jump to architecture until the requirement list is normalized. + +### 2. Check sources in the right order + +#### A. Live project state first + +Use the CLI or API to inspect what is actually present in the target Vertesia project: + +- active CLI profile +- installed workflow definitions +- installed workflow rules +- installed apps +- interactions available in the current project +- content objects or uploaded demo data when relevant + +If a command fails due to expired auth, refresh the profile and retry. + +If you inspect profiles with `vertesia profiles show`, do not echo API keys or tokens back to the user. Summarize only the safe fields you need. + +When the question involves app-defined types, interactions, templates, or remote activities, verify the exact runtime identifiers through the project APIs or the app package, not only through local code. App resources often have two useful names: + +- a local/query name used in search filters, such as `clm_contract` +- an app in-code identifier used in create/execute paths, such as `app::clm:ClmContract` + +#### B. Official docs second + +Use the Vertesia docs to determine platform-native capabilities before proposing custom code. The high-value pages are: + +- workflow activities catalog +- agent built-in tools +- agent runner overview +- plugin/custom app docs +- CLI reference + +Prefer standard workflows, built-in workflow activities, and built-in agent tools when they cover the requirement. + +#### C. Current codebase third + +Inspect what the current app already implements: + +- content types +- interactions +- tools and activities +- workflow specs +- UI routes and pages +- local skills and project docs + +For UI requirements, inspect both the local UI code and the available `@vertesia/ui` primitives before proposing custom components. A gap in the current app is not automatically a gap in the shared UI library. + +Do not confuse "possible in Vertesia" with "already wired into this app". + +#### D. Related local examples fourth + +Look for reusable patterns in: + +- nearby plugin repos +- project-local skills +- installed standard workflows in the target project +- prior implementations of similar flows + +Treat installed standard workflows as real reuse candidates, not as theoretical examples. + +### 3. Classify every requirement + +For each requirement, classify it as one of: + +- already implemented +- available natively in Vertesia +- partially covered and should be composed from native pieces +- requires custom plugin or app code +- requires external integration or project configuration + +Use the most conservative label that is still accurate. + +### 4. Recommend the right architectural home + +Use these defaults unless the evidence points elsewhere: + +- deterministic intake and side effects -> DSL workflow +- structured extraction -> interaction executed by workflow +- open-ended review, search, explanation, analysis -> agent +- standard repository operations -> built-in workflow activities or built-in agent tools +- domain-specific routing, linked-record creation, adapter logic -> custom plugin code only if native pieces do not cover it +- UX-only visualization or dashboards -> UI composition +- third-party system access -> integration or adapter layer + +For list/detail UI requirements, also decide where state must live. If users are expected to go from a table to a detail view and back without losing context, page-local state is usually the wrong recommendation. + +Do not assume an agent is the best orchestration model. Check built-in workflow activities first. + +Do not invent custom activities too early. Check the workflow activities catalog first. + +### 5. Write the gap matrix + +For each normalized requirement, capture: + +- requirement +- evidence or source +- current support status +- recommended implementation approach +- custom work still needed +- open unknowns + +Be explicit when the right answer is hybrid, for example: + +- standard workflow + custom enrichment +- built-in tools + custom UI +- deterministic workflow + agent assistant + +## Decision Rules + +- Docs are not enough. Verify installed definitions and project state with the CLI. +- Installed project capabilities are not the same as code already used by this app. +- Existing standard workflows should be inspected before creating new ones. +- Prefer reuse over replacement when a standard workflow covers the generic part and custom code only needs to handle domain enrichment. +- If a requirement depends on configuration, credentials, or app installation settings, call that out separately from code work. +- If standard intake is already installed, prefer adding domain enrichment after the standard workflow completes rather than starting a second workflow on the same `create` event and letting them race. +- CLI support and project runtime support are not the same thing. A CLI command may reject an app-defined type even when direct project APIs and the app runtime accept it. +- For app-defined interactions in workflows, `executeInteraction` typically needs the full app interaction id, not a bare interaction name. +- For app-defined child records created from custom activities or UI actions, prefer the resolved app type code used by the runtime create path. +- For UI work, classify "needs custom UI code" only after checking whether the shared `@vertesia/ui` library already provides the needed primitive. +- For list/detail UI work, inspect the route boundary and the persistence behavior of shared filter components. If `FilterProvider` or URL-backed filters are involved and state also survives navigation, plan a normalization or dedupe step so filter restoration does not create duplicates. +- For sortable, facet-driven repository tables, prefer one backend search path that owns rows, sort, and facets instead of mixing a simpler find path with a richer search path. + +## Output Style + +Keep the assessment compact and implementation-oriented. + +Prefer short grouped bullets and a small matrix over long prose. + +When useful, summarize the recommendation in one sentence: + +`Use for the generic path, then add for the domain-specific gap.` diff --git a/templates/plugin-template/.claude/skills/vertesia-gap-assessment/references/sources-and-checks.md b/templates/plugin-template/.claude/skills/vertesia-gap-assessment/references/sources-and-checks.md new file mode 100644 index 000000000..aa7dac324 --- /dev/null +++ b/templates/plugin-template/.claude/skills/vertesia-gap-assessment/references/sources-and-checks.md @@ -0,0 +1,198 @@ +# Sources And Checks + +Use this file as the concrete checklist when assessing requirements against Vertesia capabilities. + +## 1. Live Project State + +Check project reality before reading docs deeply. + +### CLI commands verified in this environment + +```bash +vertesia profiles show +vertesia profiles refresh +vertesia workflows definitions list +vertesia workflows definitions get +vertesia workflows rules list +vertesia workflows rules get +vertesia apps list-installed +vertesia apps get-installation +vertesia interactions +vertesia content list +vertesia content get +``` + +### Notes + +- `vertesia profiles show` includes sensitive tokens. Never repeat them in user-facing output. +- If workflow or app queries fail with `401 Token expired`, run `vertesia profiles refresh` and retry. +- After auth refresh, verify the existing dev server port, public tunnel, and installed app manifest endpoint before starting replacements. +- Installed workflows matter. In this environment, inspecting installed definitions surfaced reusable system workflows such as `StandardDocumentIntake`. + +### Questions to answer + +- Which profile and project are active? +- Which workflow definitions are installed? +- Which workflow rules are installed? +- Which apps are installed in the project? +- Which interactions are already available? +- Is there existing content/demo data that changes the implementation plan? + +### Resource identity checks + +For app-defined resources, verify the exact ids used by the runtime create/execute paths. + +- Confirm the installed app manifest endpoint is reachable and points to the expected package URL. +- For local development against a cloud project, verify the tunnel URL and the manifest `endpoint` before debugging missing app resources. +- List app types from the project, not just from local code. +- Check the direct app-type detail endpoint, not only the listing endpoint. +- Distinguish: + - query/search type name, for example `clm_contract` + - app in-code type id, for example `app::clm:ClmContract` + - app interaction id, for example `app::clm:ExtractContractMetadata` + +Do not assume the lowercase local type name is also the correct runtime create id. + +### CLI vs API checks + +- `vertesia content post` may validate only against stored/system types and reject app-defined types. +- If the CLI rejects an app-defined type, verify the project app-type APIs before assuming the app is broken. +- When the runtime accepts the app-defined type but the CLI does not, use a direct API uploader or an app-side upload flow. + +## 2. Official Vertesia Docs + +Use docs to determine what the platform supports natively. + +### Priority docs + +- Workflow activities catalog +- Agent built-in tools +- Agent runner overview +- Plugin/custom app docs +- CLI docs + +### What to look for + +- Standard workflow activities that replace custom orchestration code +- Built-in agent tools that replace custom repository/search wrappers +- Existing platform patterns for workflows, agents, search, scheduling, updates, and integrations + +### Reusable lessons + +- Built-in workflow activities often already cover extraction, interaction execution, document updates, progress messages, and embeddings. +- Built-in agent tools often already cover search, fetch, document CRUD, collection management, and optional workflow scheduling. +- Standard intake workflows often cover generic text extraction, property generation, and embeddings. Domain-specific enrichment should usually be staged after standard intake completes. + +## 3. Current Codebase + +Inspect the local app/plugin to see what is already implemented. + +### Default paths to inspect + +```text +src/tool-server/types/ +src/tool-server/interactions/ +src/tool-server/tools/ +src/tool-server/activities/ +src/tool-server/config.ts +src/ui/routes.tsx +src/ui/pages.tsx +workflow-specs/ +.claude/skills/ +``` + +### Questions to answer + +- Which content types already exist? +- Which interactions already exist? +- Which tools or remote activities already exist? +- Are there workflow specs in the repo, or only installed workflows in the project? +- Which UI pages already expose the behavior? +- Does the UI use the same identifiers for query/search and create/execute paths, or are those concerns currently conflated? +- For list/detail UX, where does the list state actually live relative to the route boundary? +- If filters are URL-backed, can the current state model remount and re-append the same filters on back-navigation? + +## 4. Related Examples + +Use nearby examples to reduce custom work. + +### Good sources + +- neighboring plugin repos +- project-local skills +- installed standard workflows +- local implementation examples that use the same Vertesia primitives + +### Example reuse patterns + +- standard workflow + custom post-processing +- built-in interaction execution + custom linked-record persistence +- built-in agent tools + custom assistant prompt +- standard document intake + post-intake enrichment rule on `update` when `status=completed` + +## 5. Classification Framework + +For each requirement, classify it as: + +- already implemented +- available natively in Vertesia +- partially covered and composable from native pieces +- requires custom plugin/app code +- requires external integration or project configuration + +Also record three support layers separately: + +- native platform support +- installed project support +- current app support + +## 6. Architecture Heuristics + +Use these defaults unless evidence shows a better fit: + +- deterministic intake, updates, retries, audit-friendly side effects -> workflow +- structured extraction with schema output -> interaction executed by workflow +- exploratory review, explanation, search assistance -> agent +- standard repository operations -> built-in workflow activities or built-in agent tools +- domain-specific routing and child-record creation -> custom plugin logic if native pieces stop short +- scheduling and outbound communication -> verify native support and project configuration before proposing custom code +- list/detail UX with preserved context -> lift state above the route boundary, persist scroll explicitly, and normalize URL-restored filters + +## 7. Debugging Heuristics + +When a create or execute path fails with "app type not found" or "interaction not found": + +1. verify the installed app manifest points to the correct `/api/package` endpoint +2. verify the public tunnel or deployment URL is reachable and returns the expected app package +3. verify the app package endpoint exposes the resource +4. verify the project app-resource listing endpoint sees it +5. verify the project app-resource detail endpoint resolves it +6. compare the id shape used in the failing payload with the id shape returned by the detail endpoint +7. test direct API create/execute before blaming the app logic + +When debugging locally against a cloud project, app-manifest and tunnel issues are often mistaken for type or interaction bugs. + +When back-navigation in a list/detail UI behaves badly: + +1. check whether filters, sort, and search live inside the list page component +2. check whether a shared filter component also restores from the URL on mount +3. dedupe or normalize filter writes if both persisted React state and URL restoration are active +4. persist scroll position in history state and restore it after the list layout is ready +5. if hover-reveal buttons are invisible, verify Tailwind variant classes are literal strings and not dynamically assembled template fragments + +When auth expires mid-debugging: + +1. refresh auth +2. verify the existing local dev server still responds +3. verify the current tunnel host still resolves +4. verify the installed app manifest still points to that live tunnel +5. only create a new dev server or tunnel if the current pair is invalid + +Do not let multiple quick tunnels and drifting local ports accumulate during one debugging session. + +When two workflows act on the same object: + +1. check installed rules, not just repo workflow specs +2. confirm which event each rule matches +3. avoid `create`-time races when a standard intake workflow already exists +4. move custom enrichment to a later `update` condition if the generic workflow should finish first diff --git a/templates/plugin-template/.claude/skills/vertesia-plugin/REFERENCE.md b/templates/plugin-template/.claude/skills/vertesia-plugin/REFERENCE.md index f651fe7d0..79ea6b7d4 100644 --- a/templates/plugin-template/.claude/skills/vertesia-plugin/REFERENCE.md +++ b/templates/plugin-template/.claude/skills/vertesia-plugin/REFERENCE.md @@ -8,7 +8,7 @@ Additional details for the plugin architecture. Referenced from SKILL.md. - [CSS Customization](#css-customization) - [Deployment](#deployment) -For resource creation code examples (tools, skills, interactions, types, templates), use the **write-tool-server-resource** skill. +For resource creation code examples (tools, skills, interactions, types, templates), use the **vertesia-tool-server-resource** skill. --- @@ -103,6 +103,43 @@ pnpm build && pnpm start # Runs on port 3000 (or PORT env var) The Node server (`server-node.ts`) serves static files from `dist/` via `@hono/node-server/serve-static`. +### Tunnel-Based Dev Testing + +When testing a local plugin against a cloud Vertesia project: + +1. start the local HTTPS dev server +2. expose it publicly with a tunnel, for example: + +```bash +npx cloudflared tunnel --url https://localhost:5174 --no-tls-verify +``` + +3. update the installed app manifest so `endpoint` points to: + +```text +https:///api/package +``` + +4. verify: + +- the app manifest now shows the tunnel endpoint +- `GET /api/package?scope=types,interactions,activities` returns the expected app package + +If the project cannot resolve app-defined resources, treat tunnel reachability and manifest endpoint correctness as first-line checks. + +### Auth Expiry Discipline + +When the CLI or project auth expires during tunnel-based testing: + +1. refresh auth first +2. verify whether the current local dev server is still alive on its existing port +3. verify whether the current tunnel host is still reachable +4. verify whether the installed app manifest still points to that live tunnel +5. only create a new tunnel if the current one is actually dead +6. if a new tunnel is created, update the installed app manifest immediately + +Avoid creating a fresh `pnpm dev` process and a fresh quick tunnel by reflex. That leaves multiple ports and stale manifest endpoints in play, which commonly shows up as `Failed to fetch type` or `interaction not found` even though the local app code is fine. + ### Organization Access Restriction Set `VERTESIA_ALLOWED_ORGS` to restrict to specific orgs: diff --git a/templates/plugin-template/.claude/skills/vertesia-plugin/SKILL.md b/templates/plugin-template/.claude/skills/vertesia-plugin/SKILL.md index 2ef11ccbb..4b19a216a 100644 --- a/templates/plugin-template/.claude/skills/vertesia-plugin/SKILL.md +++ b/templates/plugin-template/.claude/skills/vertesia-plugin/SKILL.md @@ -1,6 +1,6 @@ --- name: vertesia-plugin -description: Reference for plugin architecture, dual build system, import hooks, and deployment. Use when understanding plugin structure or build configuration. For creating resources use write-tool-server-resource; for UI use vertesia-ui; for client API use vertesia-api. +description: Reference for plugin architecture, dual build system, import hooks, and deployment. Use when understanding plugin structure or build configuration. For creating resources use vertesia-tool-server-resource; for UI use vertesia-ui; for client API use vertesia-api. --- # Vertesia Plugin Development @@ -113,7 +113,7 @@ These Rollup import transformations only work in `src/tool-server/` code: ## Creating Resources -To create tools, skills, interactions, content types, or templates, use the **write-tool-server-resource** skill. It provides step-by-step scaffolding with full code examples. +To create tools, skills, interactions, content types, or templates, use the **vertesia-tool-server-resource** skill. It provides step-by-step scaffolding with full code examples. Each resource follows the same pattern: create files → export from collection → register in `config.ts`. @@ -121,6 +121,8 @@ Each resource follows the same pattern: create files → export from collection For UI component APIs, routing, layout, styling, and agent conversation patterns, use the **vertesia-ui** skill. +When the task includes UI work, do not stop at "it renders". The UI pass should start with a `@vertesia/ui` component inventory and end with a conformance check for duplicated primitives such as raw tables, native selects, local page headers, and inline styles. + Key entry points: - `src/ui/plugin.tsx` — Library entry for Vertesia host (exports default component receiving `{ slot }`) - `src/ui/main.tsx` — Standalone dev entry (VertesiaShell + AdminApp at `/`, plugin at `/app/`) @@ -133,6 +135,29 @@ Tool endpoints receive JWT tokens via `Authorization: Bearer {token}`. The SDK v For organization access restriction and deployment details, see REFERENCE.md. +## Local Runtime Exposure + +For project-connected testing, the local plugin server is not enough by itself. The installed Vertesia app manifest must point to a reachable package endpoint. + +Use this sequence: + +1. run the local HTTPS dev server +2. expose it with a tunnel such as Cloudflare Tunnel +3. update the installed app manifest `endpoint` to `/api/package` +4. verify the app package from the public URL before debugging project-side failures + +If the project does not see updated types, interactions, or activities, check the manifest endpoint before changing app code. + +When auth expires during this flow: + +1. refresh auth first +2. verify the currently running dev server port still works +3. verify the current public tunnel still resolves +4. verify the installed app manifest still points to that live tunnel +5. only then decide whether a new server or tunnel is needed + +Do not keep spawning new local ports and quick tunnels after an auth failure. The project can easily end up pointing at a dead tunnel even while the local app appears healthy. + ## Key Dependencies | Package | Role | diff --git a/templates/plugin-template/.claude/skills/vertesia-tool-server-resource/REFERENCE.md b/templates/plugin-template/.claude/skills/vertesia-tool-server-resource/REFERENCE.md new file mode 100644 index 000000000..c8491c89a --- /dev/null +++ b/templates/plugin-template/.claude/skills/vertesia-tool-server-resource/REFERENCE.md @@ -0,0 +1,394 @@ +# Write Tool Server Resource — Code Reference + +Full code examples for each resource type. SKILL.md has the workflow and decision-points; this file has the templates you copy from. + +## Table of Contents + +- [Tool](#tool) +- [Skill](#skill) +- [Interaction (template-based)](#interaction-template-based) +- [Interaction (code-based)](#interaction-code-based) +- [Content Type](#content-type) +- [Rendering Template](#rendering-template) +- [Collection registration & icons](#collection-registration--icons) + +--- + +## Tool + +### `schema.ts` + +```typescript +import { JSONSchema } from "@llumiverse/common"; + +export interface MyToolParams { + query: string; + limit?: number; +} + +export const Schema = { + type: "object", + properties: { + query: { type: "string", description: "Search query" }, + limit: { type: "number", description: "Max results" } + }, + required: ["query"] +} satisfies JSONSchema; +``` + +### `.ts` + +```typescript +import { ToolExecutionContext, ToolExecutionPayload } from "@vertesia/tools-sdk"; +import { ToolResultContent } from "@vertesia/common"; +import { type MyToolParams } from "./schema.js"; + +export async function myToolRun( + payload: ToolExecutionPayload, + context: ToolExecutionContext +): Promise { + try { + const { query, limit } = payload.tool_use.tool_input!; + const client = await context.getClient(); + const results = await client.store.objects.find({ where: { name: query }, limit }); + + return { is_error: false, content: JSON.stringify(results) }; + } catch (error) { + return { + is_error: true, + content: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + } +} +``` + +### `index.ts` (tool definition) + +```typescript +import { Tool } from "@vertesia/tools-sdk"; +import { myToolRun } from "./my-impl.js"; +import { MyToolParams, Schema } from "./schema.js"; + +export const MyTool = { + name: "my_tool", + description: "Description of what this tool does", + input_schema: Schema, + run: myToolRun +} satisfies Tool; +``` + +### Collection (`tools//index.ts`) + +```typescript +import { ToolCollection } from "@vertesia/tools-sdk"; +import { MyTool } from "./my-tool/index.js"; +import icon from "./icon.svg.js"; + +export const MyTools = new ToolCollection({ + name: "my-collection", + title: "My Tools", + description: "Description of this collection", + icon, + tools: [MyTool] +}); +``` + +--- + +## Skill + +### `SKILL.md` + +```markdown +--- +name: my-skill +title: My Skill +description: What this skill does and when to use it +keywords: [keyword1, keyword2, keyword3] +tools: [tool-to-enable] +--- + +# Skill Instructions + +Instructions for the AI agent when this skill is active. + +## What to do + +Describe the behavior, output format, and constraints. +``` + +**Frontmatter fields:** + +- `name` (required): snake_case identifier +- `description` (required): what the skill does +- `title`: display name +- `keywords`: trigger auto-activation when matched +- `tools`: related tools to unlock when skill is active +- `language` / `packages`: for code-execution skills +- `widgets`: UI widgets to render + +### Optional `properties.ts` + +```typescript +import { SkillDefinition, ToolUseContext } from "@vertesia/tools-sdk"; + +export default { + isEnabled(_context: ToolUseContext) { + // Return false to hide this skill based on context/config + return true; + } +} satisfies Partial; +``` + +### Collection auto-discovery + +```typescript +// skills//index.ts +import { SkillCollection } from "@vertesia/tools-sdk"; +import skills from "./all?skills"; + +export const MySkills = new SkillCollection({ + name: "my-collection", + title: "My Skills", + description: "Description of this skill collection", + skills // Auto-discovers all subdirs with SKILL.md +}); +``` + +--- + +## Interaction (template-based) + +### `prompt.hbs` + +```handlebars +{{!-- prompt.hbs --}} +--- +role: user +content_type: handlebars +schema: ./prompt_schema.ts +--- +Analyze the following content: {{input}} +Please provide a {{format}} summary. +``` + +### `prompt_schema.ts` + +```typescript +import { JSONSchema } from "@llumiverse/common"; + +export default { + type: "object", + properties: { + input: { type: "string", description: "Content to analyze" }, + format: { type: "string", description: "Output format" } + }, + required: ["input"] +} satisfies JSONSchema; +``` + +### `result_schema.ts` + +```typescript +import { JSONSchema } from "@llumiverse/common"; + +export default { + type: "object", + properties: { + summary: { type: "string", description: "The analysis summary" }, + confidence: { type: "number", description: "Confidence score 0-1" } + }, + required: ["summary"] +} satisfies JSONSchema; +``` + +### `index.ts` (interaction spec) + +```typescript +import { InteractionSpec } from "@vertesia/common"; +import PROMPT from "./prompt.hbs?prompt"; +import result_schema from "./result_schema.js"; + +export default { + name: "analyze_content", + title: "Analyze Content", + description: "Analyzes content and returns a structured summary", + result_schema, + prompts: [PROMPT], + tags: ["analysis", "text"] +} satisfies InteractionSpec; +``` + +--- + +## Interaction (code-based) + +For agents and conversational interactions (no `.hbs` file): + +```typescript +import { PromptRole } from "@llumiverse/common"; +import type { InteractionSpec } from "@vertesia/common"; +import { TemplateType } from "@vertesia/common"; + +export default { + name: "my_assistant", + title: "My Assistant", + description: "A conversational assistant", + tags: ["assistant", "chat"], + agent_runner_options: { + is_agent: true, + }, + prompts: [ + { + role: PromptRole.system, + content: "You are a helpful assistant. Answer questions accurately.", + content_type: TemplateType.text, + }, + { + role: PromptRole.user, + content_type: TemplateType.handlebars, + content: "{{user_prompt}}", + }, + ], +} satisfies InteractionSpec; +``` + +### Collection (`interactions//index.ts`) + +```typescript +import { InteractionCollection } from "@vertesia/tools-sdk"; +import analyzeContent from "./analyze_content/index.js"; +import icon from "./icon.svg.js"; + +export const MyInteractions = new InteractionCollection({ + name: "my-collection", + title: "My Interactions", + description: "Description of this collection", + icon, + interactions: [analyzeContent] +}); +``` + +--- + +## Content Type + +### `.ts` + +```typescript +import { InCodeTypeSpec } from "@vertesia/common"; + +export const MyType = { + name: "my_type", + description: "Description of this content type", + tags: ["category1", "category2"], + object_schema: { + type: "object", + properties: { + title: { type: "string", description: "Title", minLength: 1, maxLength: 200 }, + body: { type: "string", description: "Body content" }, + status: { type: "string", enum: ["draft", "published", "archived"] } + }, + required: ["title"], + additionalProperties: false + }, + table_layout: [ + { field: "properties.title", name: "Title", type: "string" }, + { field: "properties.status", name: "Status", type: "string" }, + { field: "updated_at", name: "Updated", type: "date" } + ], + is_chunkable: true, + strict_mode: true +} satisfies InCodeTypeSpec; +``` + +### Collection (`types//index.ts`) + +```typescript +import { ContentTypesCollection } from "@vertesia/tools-sdk"; +import { MyType } from "./my-type.js"; +import icon from "./icon.svg.js"; + +export const MyTypes = new ContentTypesCollection({ + name: "my-collection", + title: "My Content Types", + description: "Description of this collection", + icon, + types: [MyType] +}); +``` + +--- + +## Rendering Template + +### `TEMPLATE.md` + +```markdown +--- +title: My Report +description: A report template for generating formatted PDFs +tags: [report, pdf] +type: document +--- + +# Report Template + +Instructions for the document generation system. + +## Available Variables + +- `{{title}}` — Report title +- `{{author}}` — Author name +- `{{date}}` — Report date +``` + +**Frontmatter fields:** + +- `description` (required): what this template generates +- `type` (required): `'document'` or `'presentation'` +- `title`: display name +- `tags`: categorization tags + +Asset files (SVG, LaTeX, PNG) in the same directory are auto-discovered and copied to `dist/templates/`. + +### Collection auto-discovery + +```typescript +// templates//index.ts +import { RenderingTemplateCollection } from "@vertesia/tools-sdk"; +import templates from './all?templates'; + +export const MyTemplates = new RenderingTemplateCollection({ + name: "my-collection", + title: "My Templates", + description: "Description of this template collection", + templates // Auto-discovers all subdirs with TEMPLATE.md +}); +``` + +--- + +## Collection registration & icons + +### Adding a collection to its type's index + +```typescript +// src/tool-server/tools/index.ts +import { ExampleTools } from "./examples/index.js"; +import { MyTools } from "./my-collection/index.js"; + +export const tools = [ExampleTools, MyTools]; +``` + +`config.ts` already imports from these per-type index files, so no further wiring is needed once the new collection is in the array. + +### `icon.svg.ts` + +Each collection needs an SVG icon as a default string export: + +```typescript +export default ` + +`; +``` diff --git a/templates/plugin-template/.claude/skills/vertesia-tool-server-resource/SKILL.md b/templates/plugin-template/.claude/skills/vertesia-tool-server-resource/SKILL.md new file mode 100644 index 000000000..242ed6949 --- /dev/null +++ b/templates/plugin-template/.claude/skills/vertesia-tool-server-resource/SKILL.md @@ -0,0 +1,109 @@ +--- +name: vertesia-tool-server-resource +description: Creates tools, skills, interactions, content types, and rendering templates for the Vertesia plugin tool server. Handles file scaffolding, collection registration, and config.ts wiring. Use when adding new tool server resources to this plugin. +--- + +# Vertesia Tool Server Resource + +Step-by-step guide for creating tool server resources. Each resource follows the same workflow: + +1. Create files in the appropriate `src/tool-server///` directory +2. Export from the collection's `index.ts` +3. Register the collection in `src/tool-server//index.ts` (only when adding a new collection) + +For full code templates of every resource type, see `REFERENCE.md`. + +## Conventions + +- All imports use `.js` extensions: `import { x } from "./foo.js"` +- Use `satisfies` for type validation (`{} satisfies Tool`, `{} satisfies InCodeTypeSpec`, …) +- Icons are SVG strings exported as default from `.ts` files +- Import hooks (`?skill`, `?skills`, `?prompt`, `?template`, `?templates`, `?raw`) only work in Rollup-compiled tool-server code, **not** in UI code +- Snake_case for resource names (`my_tool`, `my_type`); PascalCase for TypeScript exports (`MyTool`, `MyType`) +- See `src/tool-server//examples/` for working starter code + +## Resource types + +### Tool + +Three files in `src/tool-server/tools///`: + +| File | Purpose | +|------|---------| +| `schema.ts` | TypeScript interface + JSONSchema (`satisfies JSONSchema`) | +| `.ts` | The `run` function — uses `ToolExecutionPayload

`, returns `ToolResultContent` | +| `index.ts` | `Tool

` definition (`satisfies Tool`) | + +Then export from `tools//index.ts` as a `ToolCollection`. + +→ Code in `REFERENCE.md` § Tool. + +### Skill + +Files in `src/tool-server/skills///`: + +| File | Required | Purpose | +|------|----------|---------| +| `SKILL.md` | yes | YAML frontmatter + instructions for the agent | +| `properties.ts` | no | Runtime gating (`isEnabled`) | +| `*.tsx` | no | Widgets (compiled to `dist/widgets/`) | +| `*.py`, `*.js` | no | Scripts (copied to `dist/scripts/`) | + +Skills are auto-discovered: the collection imports `./all?skills` — no per-skill imports needed. + +→ Code in `REFERENCE.md` § Skill. + +### Interaction + +Two flavors: + +- **Template-based** — `prompt.hbs` + `prompt_schema.ts` + `result_schema.ts` + `index.ts` (`InteractionSpec` importing the prompt via `?prompt`). +- **Code-based** — `index.ts` only, with `prompts: [{ role, content, content_type }, …]` inline. Use this for agents/conversations. + +Then export from `interactions//index.ts` as an `InteractionCollection`. + +→ Code in `REFERENCE.md` § Interaction (template-based) and § Interaction (code-based). + +### Content Type + +One file per type in `src/tool-server/types//.ts` (`InCodeTypeSpec`), then a `ContentTypesCollection` in `types//index.ts`. + +Key fields: `name` (snake_case), `object_schema` (JSON Schema with `additionalProperties: false`), `table_layout` (columns for the UI), `is_chunkable`, `strict_mode`. + +→ Code in `REFERENCE.md` § Content Type. + +### Rendering Template + +Folder per template in `src/tool-server/templates///`: + +- `TEMPLATE.md` with YAML frontmatter (`description`, `type: 'document' | 'presentation'`, optional `title`, `tags`) +- Asset files (SVG, LaTeX, PNG) — auto-discovered, copied to `dist/templates/` + +Templates are auto-discovered: the collection imports `./all?templates`. + +→ Code in `REFERENCE.md` § Rendering Template. + +## Collection registration + +Once a collection is exported from `src/tool-server///index.ts`, add it to the array in `src/tool-server//index.ts`: + +```typescript +// src/tool-server/tools/index.ts +import { ExampleTools } from "./examples/index.js"; +import { MyTools } from "./my-collection/index.js"; + +export const tools = [ExampleTools, MyTools]; +``` + +`config.ts` already wires those per-type index files into the server — no further changes needed there. + +Each collection needs an SVG `icon.svg.ts` (default string export). Code in `REFERENCE.md` § Collection registration & icons. + +## Verification + +After creating a resource: + +1. `pnpm build:server` +2. `pnpm start` +3. Check the admin UI at `http://localhost:3000/` — your resource should appear. +4. Or hit the API: `curl http://localhost:3000/api/tools` (or `/skills`, `/interactions`, `/types`, `/templates`). diff --git a/templates/plugin-template/.claude/skills/vertesia-ui/SKILL.md b/templates/plugin-template/.claude/skills/vertesia-ui/SKILL.md index ca596677f..3e3570457 100644 --- a/templates/plugin-template/.claude/skills/vertesia-ui/SKILL.md +++ b/templates/plugin-template/.claude/skills/vertesia-ui/SKILL.md @@ -1,6 +1,6 @@ --- name: vertesia-ui -description: Reference for building UIs with @vertesia/ui. Covers component API (Input, Button, Card, Modal, Tabs, Table), table views with infinite scroll and filters, layout (Sidebar, FullHeightLayout), routing (NestedRouterProvider, useParams, useNavigate), agent conversation (ModernAgentConversation), and styling. Use when creating or modifying React UI pages or components. +description: Reference for building UIs with @vertesia/ui. Covers component API (Input, Button, VModal, VTabs, Table), list/detail tables with infinite scroll, sortable headers, FilterProvider with URL persistence and inline row filters, layout (Sidebar, FullHeightLayout, GenericPageNavHeader), routing (NestedRouterProvider, useParams, useNavigate), agent conversation (ModernAgentConversation), styling, and security. Use when creating or modifying React UI pages or components. --- # Vertesia UI Development @@ -9,6 +9,36 @@ React UI built with React 19, Tailwind CSS 4, and `@vertesia/ui` components. For the full component API reference, see also `composableai/packages/ui/llms.txt` (shipped with the npm package). +For a reusable list/detail table example with backend search, sort, facets, inline filters, and back-navigation preservation, see `references/generic-table-pattern.md`. + +## Required First Step: Component Inventory + +Before writing or refactoring UI code, explicitly check whether `@vertesia/ui` already provides the surface you need. + +At minimum, search for an existing component in these buckets: + +- `@vertesia/ui/core` +- `@vertesia/ui/layout` +- `@vertesia/ui/features` + +If a suitable component exists, use it. Do not reimplement it with raw HTML just because the local version is faster to type. + +This is especially strict for: + +- tables +- page headers +- filters +- modals +- tabs +- badges +- buttons +- selects +- text inputs +- side panels +- empty/loading/error states + +If you still introduce a custom wrapper, state which existing `@vertesia/ui` component you checked and why it was insufficient. + ## Import Paths ```tsx @@ -43,10 +73,14 @@ import { VertesiaShell, StandaloneApp } from '@vertesia/ui/shell'; -// Variants: default, destructive, outline, secondary, ghost, link -// Sizes: default, sm, lg, icon +// Variants: primary (default), destructive, outline, secondary, ghost, link, unstyled +// Sizes: xs, sm, md (default), lg, xl, icon ``` +**Tooltip + accessible name are built in:** pass `alt="..."` (or `title="..."`) and `Button` auto-wraps with `VTooltip`. Don't wrap a `Button` in manual `VTooltip` (nested-button DOM) or add a separate `aria-label`. Manual `VTooltip` is only for non-Button triggers (span, icon, Badge) or non-default placement/size. + +Use `isDisabled={...}` (documented prop). `size="icon"` is `rounded-full` — for a *square* icon button use `size="sm"`/`"xs"`. Example: ``. + ### VModal ```tsx @@ -99,6 +133,8 @@ import { Table, TBody, THead, Th, Tr, Td } from '@vertesia/ui/core'; `TBody` renders loading skeletons when `isLoading=true`. **Only use `isLoading` for initial empty-state load**, not for "load more" — show a separate `` below the table for appending. +Use this instead of custom `` wrappers unless there is a documented gap. + ### Infinite Scroll (Lazy Loading) ```tsx @@ -213,6 +249,60 @@ const path = useLocation().pathname; // Current path Go to Items ``` +### List / Detail Preservation + +If a list page links to a detail page and users are expected to go back, do not keep the list state inside the list page component. + +Preserve these concerns above the route boundary when they matter: + +- active filters +- search query +- sort +- pagination or loaded results +- row selection +- scroll position + +Use a provider above `NestedRouterProvider` or above the list/detail route split. Do not use a module-level singleton. + +If `FilterProvider` is involved, remember that it restores filters from the URL on mount. If your list state also survives route changes, normalize or dedupe filter writes at the provider boundary so the same filter does not get appended repeatedly on back-navigation. + +For scroll restoration: + +- persist the list scroll position in provider state and `window.history.state.data` +- restore it in `useLayoutEffect` +- wait until the list has rendered before restoring, typically with `requestAnimationFrame` + +Do not treat list/detail navigation as a cosmetic issue. If filters and scroll reset on back, the UX is wrong. + +### Search vs Find For Table Surfaces + +For table/list surfaces that need any combination of: + +- full-text search +- backend sort +- backend facets + +use one backend `search` path consistently. + +Do not mix `find` for the default state with `search` for filtered states if the page exposes sort and facet-driven filtering. That creates different backend behavior depending on UI state and weakens the table contract. + +Use `find` only for simple exact-match fetches that do not require backend sort, full-text behavior, or facets. + +## Completion Check + +Before calling a UI task complete, run a short conformance pass: + +1. any raw `
` where `Table` / `THead` / `TBody` should be used? +2. any native ` + + + + + + + navigate(`/items/${id}`)} + /> + + ); +} +``` + +## Table Pattern + +Use `Table`, `THead`, and `TBody` directly. + +```tsx +
+ + + + + + + + + {items.map((item) => ( + onOpen(item.id)} + > + + + ))} + +
+
+ {item.title} + onFilterValue("title", item.title)} + /> +
+
+``` + +## Inline Filter Button Pattern + +Do not assemble Tailwind hover classes dynamically. + +Bad: + +```tsx +className={`opacity-0 group-hover/${groupName}:opacity-100`} +``` + +Good: + +```tsx +const hoverClass = { + title: "group-hover/title:opacity-100", + owner: "group-hover/owner:opacity-100", +}[groupName]; +``` + +```tsx +function InlineFilterButton({ + tooltip, + hoverClass, + onClick, +}: { + tooltip: string; + hoverClass: string; + onClick: () => void; +}) { + return ( + + ); +} +``` + +## Filter Deduplication Rule + +If both of these are true: + +- the list state survives route changes +- `FilterProvider` restores filters from the URL on mount + +then filter writes must be normalized or deduped. + +Otherwise, going to detail and back can duplicate chips and query params. + +```tsx +function dedupeFilters(filters: Filter[]) { + const seen = new Set(); + const deduped: Filter[] = []; + + for (const filter of filters) { + const normalizedValue = Array.isArray(filter.value) + ? filter.value.map((entry) => typeof entry === "string" ? entry : `${entry.value}|${entry.label || ""}`) + : []; + const key = [ + filter.name, + filter.type, + filter.multiple ? "multi" : "single", + ...normalizedValue, + ].join("::"); + + if (seen.has(key)) continue; + seen.add(key); + deduped.push(filter); + } + + return deduped; +} +``` + +## Scroll Persistence Pattern + +The naïve "save on scroll, restore on mount" approach has several traps that bit us in production. Apply the rules below — the helpers are simple, the gotchas are not. + +### Persist + read helpers + +```tsx +function persistScrollTop(scrollTop: number) { + const state = window.history.state || {}; + window.history.replaceState({ + ...state, + data: { + ...(state.data || {}), + listScrollTop: scrollTop, + }, + }, ""); +} + +function readScrollTop() { + const state = window.history.state as { data?: { listScrollTop?: number } } | null; + return state?.data?.listScrollTop; +} +``` + +### Find the actual scrolling element at runtime + +In nested layouts (e.g. an `AppLayout` with its own `overflow-y-auto` wrapper), the element that actually scrolls is **not always** the inner `ref` you attach to. The inner div may have `overflow-auto` but no overflow yet (during initial render with empty items), so the user ends up scrolling an ancestor. Walk up from the ref: + +```tsx +function findScrollableElement(start: HTMLElement | null): HTMLElement | null { + let current: HTMLElement | null = start; + while (current && current !== document.body) { + const overflowY = window.getComputedStyle(current).overflowY; + const canScroll = + (overflowY === 'auto' || overflowY === 'scroll') && + current.scrollHeight > current.clientHeight; + if (canScroll) return current; + current = current.parentElement; + } + return document.scrollingElement as HTMLElement | null; +} +``` + +Use the result for both attaching the listener AND for restoration. + +### Listener: persist on every event, **never on cleanup** + +```tsx +useEffect(() => { + const el = findScrollableElement(scrollContainerRef.current); + scrollElRef.current = el; + if (!el) return; + const onScroll = () => { + const next = el.scrollTop; + setScrollTop(next); // provider state (in-memory, survives reload) + persistScrollTop(next); // history.state (survives full reload) + }; + el.addEventListener('scroll', onScroll, { passive: true }); + return () => { + el.removeEventListener('scroll', onScroll); + // ⚠️ Do NOT read el.scrollTop here and "flush" it. By the time cleanup + // runs, the route has changed and the DOM is being detached — el.scrollTop + // reads as 0 and would clobber the value the on-scroll listener already + // saved. The listener has already persisted the latest value on every + // scroll; cleanup just removes the listener. + }; +}, [setScrollTop]); +``` + +### Restore: synchronous in `useLayoutEffect`, fall back to rAF only if needed + +A naïve `requestAnimationFrame(() => el.scrollTop = target)` produces a one-frame flash to the top before the rAF fires. Restore synchronously inside `useLayoutEffect` (which runs before paint); only poll rAF if the DOM hasn't laid out enough rows yet for the scroll target to be reachable. + +```tsx +useLayoutEffect(() => { + if (restoreDoneRef.current) return; + if (isLoading || items.length === 0) return; + const target = readScrollTop() ?? scrollTop; + if (target <= 0) { + restoreDoneRef.current = true; + return; + } + + const trySync = (): boolean => { + const el = scrollElRef.current ?? findScrollableElement(scrollContainerRef.current); + scrollElRef.current = el; + if (!el) return false; + const maxScroll = el.scrollHeight - el.clientHeight; + if (maxScroll < target) return false; + el.scrollTop = target; + return true; + }; + + if (trySync()) { + restoreDoneRef.current = true; + return; + } + + // Poll rAF until rows render enough scrollHeight for the target. + let attempts = 0, cancelled = false; + const tryAsync = () => { + if (cancelled) return; + if (trySync() || attempts >= 10) { + const el = scrollElRef.current; + if (el && !restoreDoneRef.current) { + const maxScroll = el.scrollHeight - el.clientHeight; + el.scrollTop = Math.min(target, Math.max(maxScroll, 0)); + } + restoreDoneRef.current = true; + return; + } + attempts++; + requestAnimationFrame(tryAsync); + }; + const frame = requestAnimationFrame(tryAsync); + return () => { cancelled = true; cancelAnimationFrame(frame); }; +}, [isLoading, items.length, scrollTop]); +``` + +### Provider state is the source of truth, history.state is a nice-to-have + +Push-based routers (including `NestedRouterProvider`) create a **new history entry** when you `navigate('/list')` from a detail page, so the original entry's saved `data.listScrollTop` is "behind" you and `readScrollTop()` returns `undefined`. The restore must fall back to provider state (`scrollTop`), which survives across route changes because the provider lives above the route boundary. The history.state path only helps for full-page reloads. + +### Common failure modes (what bit us) + +| Symptom | Cause | Fix | +| --- | --- | --- | +| Scroll resets to top on first back-nav, works on second | Cleanup persisted `el.scrollTop = 0` (DOM detached at cleanup time), clobbering the saved value | Don't persist on cleanup | +| Listener attaches to `` and never fires | `findScrollableElement` ran when items were empty; inner div had no overflow yet, so it walked all the way up | Walk-up logic + provider state covers it | +| Visible flash to top before scroll lands | Restore wrapped in `requestAnimationFrame` with no synchronous attempt | Try synchronously inside `useLayoutEffect` first | +| StrictMode double-mount loses scroll | Cleanup persists 0 between passes; second mount reads 0 | Don't persist on cleanup | +| `fromHistory = undefined` after back-nav | Router pushes new entry instead of going back via browser history | Provider state is the primary source | + +## Checklist + +Before calling a list/detail table done: + +1. does the table use `Table`, `THead`, `TBody` directly? +2. does backend search own rows, sort, and facets? +3. is list state above the route boundary? +4. are URL-restored filters deduped or normalized? +5. is scroll restored on back-navigation? +6. are hover-reveal classes literal/static so Tailwind emits them? diff --git a/templates/plugin-template/.claude/skills/vertesia-ui/references/security.md b/templates/plugin-template/.claude/skills/vertesia-ui/references/security.md new file mode 100644 index 000000000..d1f9f052a --- /dev/null +++ b/templates/plugin-template/.claude/skills/vertesia-ui/references/security.md @@ -0,0 +1,89 @@ +# Security Practices for `@vertesia/ui` Pages + +Quick rules and the patterns they correspond to. Apply these to any page or component that takes user input or makes API calls. + +## XSS Prevention + +React escapes JSX content automatically. Be careful only with `dangerouslySetInnerHTML`. + +```tsx +// NEVER use dangerouslySetInnerHTML with unsanitized input +

// ❌ + +// If you must render HTML, sanitize first +import DOMPurify from 'dompurify'; +
// ✅ +``` + +## URL Validation + +Validate user-provided URLs before rendering as links: + +```tsx +function isValidUrl(url: string): boolean { + try { + const parsed = new URL(url); + return ['http:', 'https:'].includes(parsed.protocol); + } catch { return false; } +} + +{isValidUrl(userUrl) && Link} +``` + +## Error Handling + +Never expose internal API details to users. Log full details to console; show generic messages with `useToast`. + +```tsx +try { + await client.objects.retrieve(objectId); +} catch (error) { + console.error('Object retrieval failed:', error); // full details + toast({ status: 'error', title: 'Unable to load data. Please try again.' }); +} +``` + +## Secrets + +- Never hardcode API keys or tokens — use environment variables. +- Prefix client-side env vars with `VITE_` (they are embedded in the bundle). +- Keep sensitive keys server-side only (tool server code, not UI). +- Never commit `.env` files — use `.env.example` for documentation. + +## Form Security + +Validate inputs before submitting (Zod or similar). Validate file uploads (type, size, extension): + +```tsx +const MAX_SIZE = 10 * 1024 * 1024; // 10MB +const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'application/pdf']; + +if (file.size > MAX_SIZE) throw new Error('File too large'); +if (!ALLOWED_TYPES.includes(file.type)) throw new Error('Invalid file type'); +``` + +## Client-Side Throttling + +Disable buttons after the first click and re-enable in a `finally` block. Apply to **every** button that triggers an async action — form submissions, deletions, API calls. + +```tsx +const [isSubmitting, setIsSubmitting] = useState(false); + +async function handleSubmit() { + if (isSubmitting) return; + setIsSubmitting(true); + try { + await client.someApi.doAction(payload); + toast({ status: 'success', title: 'Item saved' }); + } catch (error) { + console.error('Action failed:', error); + toast({ status: 'error', title: 'Unable to save. Please try again.' }); + } finally { + setIsSubmitting(false); + } +} + + +``` diff --git a/templates/plugin-template/.env.app.template b/templates/plugin-template/.env.app.template index 8f4a4d9e1..ef6cb0bf1 100644 --- a/templates/plugin-template/.env.app.template +++ b/templates/plugin-template/.env.app.template @@ -2,3 +2,9 @@ # Required by `vite dev --mode app` and `vite build --mode app`. # This is public Vite build-time config, not a secret. VITE_APP_NAME={{PROJECT_NAME}} + +# Vertesia endpoints (region: {{REGION}}). +# Override in .env.app.local to point at a different environment. +VITE_STUDIO_URL={{STUDIO_URL}} +VITE_ZENO_URL={{ZENO_URL}} +VITE_STS_URL={{STS_URL}} diff --git a/templates/plugin-template/AGENTS.md b/templates/plugin-template/AGENTS.md new file mode 120000 index 000000000..681311eb9 --- /dev/null +++ b/templates/plugin-template/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/templates/plugin-template/CLAUDE.md b/templates/plugin-template/CLAUDE.md index ade34d18a..56ba895e5 100644 --- a/templates/plugin-template/CLAUDE.md +++ b/templates/plugin-template/CLAUDE.md @@ -2,6 +2,31 @@ Dual-architecture plugin: **Hono tool server** (backend) + **React UI plugin** (frontend), built and deployed as a single unit. +## Skill Auto-Invocation + +IMPORTANT: You MUST invoke the relevant skill using the Skill tool BEFORE starting implementation for the task types below. Skills contain detailed, up-to-date patterns beyond what this file covers. + +| When the task involves... | INVOKE this skill first | +| ------------------------------------------------------------------------------- | ------------------------------- | +| Reviewing requirement docs, discovery notes, or feature/demo asks | `vertesia-gap-assessment` | +| Understanding plugin architecture, dual build system, or `config.ts` | `vertesia-plugin` | +| Adding tools, skills, interactions, content types, or rendering templates | `vertesia-tool-server-resource` | +| Building or modifying React UI pages or components | `vertesia-ui` | +| Calling the Vertesia client API (objects, workflows, interactions, files) | `vertesia-api` | +| Writing or debugging DSL workflows that call remote activities | `vertesia-dsl-workflow` | +| Seeding the store with realistic demo objects | `vertesia-demo-content` | + +### Typical "build a feature" loop + +1. If the requirement is fuzzy or comes from a discovery doc → `vertesia-gap-assessment` first. +2. Need plugin context (build, layout, deployment) → `vertesia-plugin`. +3. Pick the implementation skill: + - Backend resources (tools/skills/interactions/types/templates) → `vertesia-tool-server-resource` + - UI page or component → `vertesia-ui` + - Multi-step orchestration → `vertesia-dsl-workflow` +4. Add `vertesia-api` whenever the implementation reads or writes the platform. +5. Add `vertesia-demo-content` if you need real seed objects to exercise the feature end-to-end. + ## Build & Dev Commands ```bash @@ -16,64 +41,87 @@ pnpm start # Preview production build (build:server + vite previ ## Dual Build System -| Component | Bundler | Entry | tsconfig | Output | -|-----------|---------|-------|----------|--------| -| Tool Server | Rollup | `src/tool-server/server.ts` | `tsconfig.tool-server.json` | `lib/*.js` | -| UI Plugin | Vite | `src/ui/plugin.tsx` | `tsconfig.ui.json` | `dist/lib/plugin.js` | -| UI App | Vite | `src/ui/main.tsx` | `tsconfig.ui.json` | `dist/ui/` | -| Widgets | Rollup | `skills/**/*.tsx` | `tsconfig.widgets.json` | `dist/widgets/` | +| Component | Bundler | Entry | tsconfig | Output | +|-------------|---------|-----------------------------|-----------------------------|-----------------------| +| Tool Server | Rollup | `src/tool-server/server.ts` | `tsconfig.tool-server.json` | `lib/*.js` | +| UI Plugin | Vite | `src/ui/plugin.tsx` | `tsconfig.ui.json` | `dist/lib/plugin.js` | +| UI App | Vite | `src/ui/main.tsx` | `tsconfig.ui.json` | `dist/ui/` | +| Widgets | Rollup | `skills/**/*.tsx` | `tsconfig.widgets.json` | `dist/widgets/` | + +## Key Files + +| File | Purpose | +|-------------------------------|------------------------------------------------------| +| `src/tool-server/config.ts` | Registers all collections — add new resources here | +| `src/tool-server/settings.ts` | Plugin settings JSON Schema | +| `src/ui/plugin.tsx` | Library entry for the Vertesia host app | +| `src/ui/main.tsx` | Standalone dev entry (VertesiaShell + AdminApp) | +| `src/ui/app/App.tsx` | App root (NestedRouterProvider) | +| `src/ui/app/routes.tsx` | Route definitions | +| `src/ui/index.css` | Tailwind CSS 4 entry with shared styles import | + +## UI Directory Structure + +User application code lives under `src/ui/app/`. Place new files according to the layout below — `app/README.md` has the full convention and the "add a feature" recipe. + +```text +src/ui/ +├── main.tsx, plugin.tsx, env.ts, index.css ← bootstrap / wiring (don't add app code here) +├── i18n/ +└── app/ ← user application code + ├── App.tsx, routes.tsx, constants.ts + ├── components/ ← cross-feature shared components (generic primitives) + ├── hooks/ ← cross-feature shared hooks + ├── layouts/ ← plugin chrome (PluginLayout, PluginSidebar, …) + ├── pages/ ← thin route-level wrappers (one file per route) + └── features// + ├── components/, hooks/, types.ts, utils.ts + ├── View.tsx + └── index.ts ← public barrel +``` + +Rules of thumb: + +- A new route → thin component in `app/pages/` that imports its feature. +- Self-contained business logic → `app/features//` with its own components/hooks/types. +- A primitive used by ≥2 features (e.g. a sortable header) → promote to `app/components/`. +- A hook used by ≥2 features → promote to `app/hooks/`. -## Code Style +## Visual Defaults + +Generated apps should look like compact Vertesia Studio product surfaces. Default +to a light operational UI with restrained brand accents unless the user +explicitly asks for a dark, immersive, or heavily branded treatment. + +- Use `@vertesia/ui` components and semantic tokens (`bg-background`, + `bg-card`, `border-border`, `text-muted`, `text-success`, `text-attention`, + `text-destructive`) before hardcoded colors. +- Prefer tables, queues, filters, split panes, detail pages, and process + timelines over hero sections, oversized cards, or presentation-style + dashboards. +- Keep typography dense and restrained. Use `text-xl`/`text-2xl` for page + titles and smaller headings inside panels. +- Do not force black/near-black backgrounds, neon palettes, or dark-first + panels unless requested. If dark mode is useful, implement it with `.dark` + variants and semantic tokens. +- Translate customer brand material into small accents, badges, labels, chart + colors, and optional theme variables rather than full-page theming. + +## Plugin-Specific Conventions - ESM with `.js` import extensions in tool-server code: `import { x } from "./foo.js"` - Type-safe definitions: `{} satisfies Tool`, `{} satisfies InCodeTypeSpec`, `{} satisfies InteractionSpec` -- All tool/skill/interaction/type/template collections must be registered in `src/tool-server/config.ts` -- Import hooks (`?skill`, `?skills`, `?prompt`, `?raw`, `?template`, `?templates`) only work in Rollup-compiled tool-server code, not in UI code +- All collections must be registered in `src/tool-server/config.ts` (or its per-type index files) +- Standalone dev requires HTTPS (Firebase auth): +- Set `VITE_APP_NAME` in `.env.app`; use `.env.app.local` for local overrides - Icons are SVG strings exported as default from `.ts` files -## Key Files +## Cross-Cutting Pitfalls -| File | Purpose | -|------|---------| -| `src/tool-server/config.ts` | Registers all collections — add new resources here | -| `src/tool-server/settings.ts` | Plugin settings JSON Schema | -| `src/ui/plugin.tsx` | Library entry for Vertesia host app | -| `src/ui/main.tsx` | Standalone dev entry (VertesiaShell + AdminApp) | -| `src/ui/routes.tsx` | Route definitions (NestedRouterProvider) | -| `src/ui/index.css` | Tailwind CSS 4 entry with shared styles import | - -## UI Development - -- React 19, Tailwind CSS 4, `@vertesia/ui` component library -- Component API reference: `composableai/packages/ui/llms.txt` (also shipped with npm package) -- Use `@vertesia/ui/core` for components, `@vertesia/ui/router` for navigation, `@vertesia/ui/session` for auth -- Standalone dev requires HTTPS (Firebase auth): https://localhost:5173 -- Set `VITE_APP_NAME` in `.env.app`; use `.env.app.local` for local overrides +Fast-path reminders — these bite often enough to flag here even though the relevant skill covers them: + +- **Import hooks are Rollup-only**: `?skill`, `?skills`, `?prompt`, `?raw`, `?template`, `?templates` fail silently or error in Vite UI code. They work only in tool-server code. +- **Must register in `config.ts`**: a collection that isn't wired into `config.ts` (or its per-type index) won't be served. +- **`Input.onChange` takes the value directly** (`onChange={setValue}`), not a React event — `Textarea` uses standard events. -## Development Practices - -- Extract components from map callbacks when the body has logic or is more than 2-3 lines -- Use `flex flex-col gap-{n}` for vertical spacing (not `space-y-*`) -- Debounce expensive search/filter operations -- Guard form submissions with `isSubmitting` state to prevent double-clicks -- Show generic user-facing error messages; log details to console -- Never hardcode secrets or API keys — use environment variables (`VITE_*` prefix for client-side) -- Validate user inputs before passing to API calls -- Never use `dangerouslySetInnerHTML` without sanitization - -## Common Pitfalls - -- **Import hooks are Rollup-only**: `?skill`, `?prompt`, `?raw` imports fail silently or error in Vite UI code -- **Must register in config.ts**: Creating a collection without adding it to `config.ts` means it won't be served -- **Input onChange API**: `@vertesia/ui` Input passes value directly (`onChange={setValue}`), not a React event — Textarea uses standard events -- **listConversations limitations**: Does not return the `input` field — only `topic` is available for labeling conversations; fall back to date/time -- **getRunDetails** for full data: Use `client.store.workflows.getRunDetails(runId, workflowId)` when you need `input` or history - -## Skills - -| Skill | Use when | -|-------|----------| -| `write-tool-server-resource` | Adding new tools, skills, interactions, content types, or templates to the tool server | -| `vertesia-plugin` | Understanding plugin architecture, build system, or configuration | -| `vertesia-api` | Working with the Vertesia client API (objects, workflows, interactions, auth) | -| `vertesia-ui` | Building UI pages and components (routing, layout, styling, agent conversation) | +For full UI patterns (tables, filters, sort, security) see `vertesia-ui`; for tool-server scaffolding conventions see `vertesia-tool-server-resource`. diff --git a/templates/plugin-template/Procfile.dev b/templates/plugin-template/Procfile.dev deleted file mode 100644 index 3218972b4..000000000 --- a/templates/plugin-template/Procfile.dev +++ /dev/null @@ -1,2 +0,0 @@ -ui: npx vite dev --mode app -server: npm run build:server && node ./lib/server-node.js diff --git a/templates/plugin-template/README.md b/templates/plugin-template/README.md index eb0b79711..bec6e2840 100644 --- a/templates/plugin-template/README.md +++ b/templates/plugin-template/README.md @@ -52,8 +52,8 @@ The template generates this file from the project name, which must match the `na ## Quick Start ```bash -pnpm install -pnpm dev # Vite dev server with API middleware +{{PM}} install +{{PM_RUN}} dev # Vite dev server with API middleware ``` Open -- the UI loads with HMR, and the tool server API is available at `/api` on the same port. @@ -62,14 +62,14 @@ Open -- the UI loads with HMR, and the tool server API | Script | Runs | Description | | ------------------- | ------------------------------ | ----------------------------------------------------------- | -| `pnpm dev` | `vite dev --mode app` | Dev server (HTTPS) with UI HMR + tool server API middleware | -| `pnpm build` | `build:server && build:ui` | Full production build (lint runs as prebuild) | -| `pnpm build:server` | `rollup -c` | Compile tool server to `lib/` | -| `pnpm build:ui` | `build:ui:app && build:ui:lib` | Build both UI targets | -| `pnpm build:ui:app` | `vite build --mode app` | Standalone app to `dist/app/` | -| `pnpm build:ui:lib` | `vite build --mode lib` | Plugin library to `dist/lib/plugin.js` | -| `pnpm start` | `build:server && vite preview` | Preview production build locally | -| `pnpm start:vercel` | `vercel dev` | Test Vercel deployment locally | +| `{{PM_RUN}} dev` | `vite dev --mode app` | Dev server (HTTPS) with UI HMR + tool server API middleware | +| `{{PM_RUN}} build` | `rollup -c && vite build (app + lib)` | Full production build (lint runs as prebuild) | +| `{{PM_RUN}} build:server` | `rollup -c` | Compile tool server to `lib/` | +| `{{PM_RUN}} build:ui` | `vite build (app + lib)` | Build both UI targets | +| `{{PM_RUN}} build:ui:app` | `vite build --mode app` | Standalone app to `dist/app/` | +| `{{PM_RUN}} build:ui:lib` | `vite build --mode lib` | Plugin library to `dist/lib/plugin.js` | +| `{{PM_RUN}} start` | `rollup -c && vite preview` | Preview production build locally | +| `{{PM_RUN}} start:vercel` | `vercel dev` | Test Vercel deployment locally | ## Project Structure @@ -105,11 +105,11 @@ plugin-template/ ## Architecture -### Dev Mode (`pnpm dev`) +### Dev Mode (`{{PM_RUN}} dev`) A single Vite dev server runs at `https://localhost:5173`. The `vite-api-server.ts` plugin mounts the Hono tool server as Connect middleware, so the API is served at `/api` on the same port. Tool server source is loaded via Vite's `ssrLoadModule` with hot reload. Import hooks (`?skill`, `?skills`, `?prompt`, `?raw`, `?template`, `?templates`) are handled by `vertesiaImportPlugin`. HTTPS is required for Firebase authentication. -### Preview Mode (`pnpm start`) +### Preview Mode (`{{PM_RUN}} start`) Builds the tool server first, then runs `vite preview` which loads the compiled server from `lib/` as middleware alongside the built UI. Useful for validating production output locally. @@ -486,7 +486,7 @@ To test your local tool server with Vertesia agents (e.g. debug tool execution, ```bash # 1. Start the dev server -pnpm dev +{{PM_RUN}} dev # 2. In a separate terminal, create a tunnel npx cloudflared tunnel --url https://localhost:5173 --no-tls-verify @@ -513,9 +513,9 @@ This lets you set breakpoints, add logging, and iterate on tools/skills while ru **Import hooks are Rollup-only**: `?skill`, `?skills`, `?prompt`, `?raw`, `?template`, `?templates` only work in tool server code (compiled by Rollup). They are not available in UI code (compiled by Vite). -**HTTPS required for dev**: `pnpm dev` uses HTTPS via `@vitejs/plugin-basic-ssl`. Use `-k` flag with curl to skip certificate verification. +**HTTPS required for dev**: `{{PM_RUN}} dev` uses HTTPS via `@vitejs/plugin-basic-ssl`. Use `-k` flag with curl to skip certificate verification. -**TypeScript verification**: Run `pnpm exec tsc --noEmit` to check for compilation errors without building. +**TypeScript verification**: Run `npx tsc --noEmit` to check for compilation errors without building. ## License diff --git a/templates/plugin-template/eslint.config.js b/templates/plugin-template/eslint.config.js index ae5c519fb..160be94a6 100644 --- a/templates/plugin-template/eslint.config.js +++ b/templates/plugin-template/eslint.config.js @@ -6,6 +6,14 @@ import tseslint from 'typescript-eslint' export default [ { ignores: ['dist', 'node_modules', 'lib', '*.config.js', '*.config.widgets.js', 'lib'] }, + { + files: ['scripts/**/*.{js,mjs}'], + languageOptions: { + ecmaVersion: 2024, + sourceType: 'module', + globals: globals.node, + }, + }, { files: ['**/*.{ts,tsx}'], languageOptions: { @@ -37,11 +45,6 @@ export default [ 'warn', { allowConstantExport: true }, ], - '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-unused-vars': ['warn', { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - }], '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, diff --git a/templates/plugin-template/package.json b/templates/plugin-template/package.json index b07e3c3bb..faff3aef1 100644 --- a/templates/plugin-template/package.json +++ b/templates/plugin-template/package.json @@ -12,16 +12,16 @@ "license": "Apache-2.0", "scripts": { "dev": "vite dev --mode app", - "prebuild": "pnpm run lint && tsc --build --noEmit", - "build": "pnpm run build:server && pnpm run build:ui", + "lint": "eslint .", + "prebuild": "eslint . && tsc --build --noEmit", "build:server": "rollup -c", - "build:ui": "pnpm run build:ui:app && pnpm run build:ui:lib", "build:ui:app": "vite build --mode app", "build:ui:lib": "vite build --mode lib", - "start": "pnpm run preview", + "build:ui": "vite build --mode app && vite build --mode lib", + "build": "rollup -c && vite build --mode app && vite build --mode lib", + "preview": "rollup -c && vite preview", + "start": "rollup -c && vite preview", "start:vercel": "vercel dev", - "lint": "eslint .", - "preview": "pnpm run build:server && vite preview", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [ @@ -83,6 +83,8 @@ "@vertesia/ui": "workspace:*", "dotenv": "^17.2.3", "hono": "^4.10.3", - "lucide-react": "^0.562.0" + "html-parse-stringify": "^3.0.1", + "lucide-react": "^0.562.0", + "use-sync-external-store": "^1.6.0" } } diff --git a/templates/plugin-template/scripts/typecheck.mjs b/templates/plugin-template/scripts/typecheck.mjs new file mode 100644 index 000000000..41cf8efe2 --- /dev/null +++ b/templates/plugin-template/scripts/typecheck.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +/** + * Run tsc --noEmit on each project that actually has inputs. + * + * The widgets project (tsconfig.widgets.json) only globs `*.tsx` files under + * `src/tool-server/skills/`. If a plugin removes all example skills (or never + * ships a widget), `tsc --build` errors with TS18003 ("No inputs were found"). + * + * This script auto-detects widgets and only runs the widgets typecheck when + * at least one .tsx file is present, so the prebuild stays green for both + * new plugins and plugins that keep widgets. + */ +import { execSync } from "node:child_process"; +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +const cwd = process.cwd(); + +function hasFileMatching(dir, predicate) { + if (!existsSync(dir)) return false; + const entries = readdirSync(dir); + for (const name of entries) { + const full = join(dir, name); + const st = statSync(full); + if (st.isDirectory()) { + if (hasFileMatching(full, predicate)) return true; + } else if (predicate(name, full)) { + return true; + } + } + return false; +} + +const projects = [ + "tsconfig.ui.json", + "tsconfig.tool-server.json", + "tsconfig.node.json", +]; + +const widgetsRoot = join(cwd, "src", "tool-server", "skills"); +const hasWidgets = hasFileMatching(widgetsRoot, (name) => name.endsWith(".tsx")); +if (hasWidgets) { + projects.push("tsconfig.widgets.json"); +} else { + console.log("[typecheck] no widget .tsx found under src/tool-server/skills/, skipping tsconfig.widgets.json"); +} + +for (const project of projects) { + if (!existsSync(join(cwd, project))) { + console.log(`[typecheck] ${project} not found, skipping`); + continue; + } + console.log(`[typecheck] tsc -p ${project} --noEmit`); + execSync(`tsc -p ${project} --noEmit`, { stdio: "inherit" }); +} diff --git a/templates/plugin-template/src/tool-server/config.ts b/templates/plugin-template/src/tool-server/config.ts index 6457e3ed1..aa8cf867f 100644 --- a/templates/plugin-template/src/tool-server/config.ts +++ b/templates/plugin-template/src/tool-server/config.ts @@ -1,5 +1,6 @@ import { ToolServerConfig } from "@vertesia/tools-sdk"; import { activities } from "./activities/index.js"; +import { dashboards } from "./dashboards/index.js"; import { interactions } from "./interactions/index.js"; import { mcpProviders } from "./mcp/index.js"; import { skills } from "./skills/index.js"; @@ -21,6 +22,7 @@ export const ServerConfig = { skills, templates, mcpProviders, + dashboards, uiConfig: { isolation: "shadow", src: "/lib/plugin.js", diff --git a/templates/plugin-template/src/tool-server/dashboards/index.ts b/templates/plugin-template/src/tool-server/dashboards/index.ts new file mode 100644 index 000000000..79ad0b3c7 --- /dev/null +++ b/templates/plugin-template/src/tool-server/dashboards/index.ts @@ -0,0 +1,14 @@ +import type { AppDashboardDefinition } from "@vertesia/common"; + +/** + * Dashboards exposed by this plugin. + * + * Each dashboard renders as `app::` in the host platform — read-only + * until a user clones it into a stored dashboard. Use `dataSource: { type: "store_es_dsl", ... }` + * to ship business dashboards over Store/ES data without per-project records. + * + * Add a definition object per dashboard, or import from sibling files. + * + * @see AppDashboardDefinition in @vertesia/common + */ +export const dashboards: AppDashboardDefinition[] = []; diff --git a/templates/plugin-template/src/tool-server/server-node.ts b/templates/plugin-template/src/tool-server/server-node.ts index 153efc317..1710e35bf 100644 --- a/templates/plugin-template/src/tool-server/server-node.ts +++ b/templates/plugin-template/src/tool-server/server-node.ts @@ -17,7 +17,8 @@ console.log(`Starting Tool Server on port ${port}...`); console.log(`API endpoint: http://localhost:${port}/api`); console.log(`Web UI: http://localhost:${port}/`); -server.get('*', serveStatic({ root: './dist' })); +// Hono's handler type carries a route symbol, so duplicate workspace type instances can be incompatible. +server.all('*', serveStatic({ root: './dist' }) as never); serve({ fetch: server.fetch, diff --git a/templates/plugin-template/src/tool-server/server.ts b/templates/plugin-template/src/tool-server/server.ts index 42efc41cd..6fa00549e 100644 --- a/templates/plugin-template/src/tool-server/server.ts +++ b/templates/plugin-template/src/tool-server/server.ts @@ -1,7 +1,8 @@ +import type { Hono } from "hono"; import { createToolServer } from "@vertesia/tools-sdk"; import { ServerConfig } from "./config.js"; // Create server using tools-sdk -const server = createToolServer(ServerConfig); +const server: Hono = createToolServer(ServerConfig); export default server; diff --git a/templates/plugin-template/src/ui/DevSessionProvider.tsx b/templates/plugin-template/src/ui/DevSessionProvider.tsx new file mode 100644 index 000000000..e0bf4abe8 --- /dev/null +++ b/templates/plugin-template/src/ui/DevSessionProvider.tsx @@ -0,0 +1,42 @@ +import type { AuthTokenPayload } from '@vertesia/common' +import { LastSelectedAccountId_KEY, LastSelectedProjectId_KEY, UserSession, UserSessionContext } from '@vertesia/ui/session' +import { useMemo } from 'react' +import type { ReactNode } from 'react' + +function decodeJwtPayload(token: string): AuthTokenPayload { + const [, payload] = token.split('.'); + if (!payload) { + throw new Error('Invalid Vertesia auth token'); + } + const padded = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '='); + return JSON.parse(atob(padded)) as AuthTokenPayload; +} + +interface DevSessionProviderProps { + children: ReactNode; + token: string; +} + +export function DevSessionProvider({ children, token }: DevSessionProviderProps) { + const session = useMemo(() => { + const next = new UserSession(); + next.isLoading = false; + + try { + next.authToken = decodeJwtPayload(token); + next.client.withAuthCallback(() => Promise.resolve(`Bearer ${token}`)); + + localStorage.setItem(LastSelectedAccountId_KEY, next.authToken.account.id); + localStorage.setItem( + `${LastSelectedProjectId_KEY}-${next.authToken.account.id}`, + next.authToken.project?.id ?? '', + ); + } catch (error: unknown) { + next.authError = error instanceof Error ? error : new Error(String(error)); + } + + return next.clone(); + }, [token]); + + return {children}; +} diff --git a/templates/plugin-template/src/ui/PageShell.tsx b/templates/plugin-template/src/ui/PageShell.tsx new file mode 100644 index 000000000..6fb326739 --- /dev/null +++ b/templates/plugin-template/src/ui/PageShell.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from "react"; + +interface PageShellProps { + title: string; + description?: string; + action?: ReactNode; + children: ReactNode; +} + +export function PageShell({ title, description, action, children }: PageShellProps) { + return ( +
+
+
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ {action} +
+ {children} +
+
+ ); +} diff --git a/templates/plugin-template/src/ui/PluginLayout.tsx b/templates/plugin-template/src/ui/PluginLayout.tsx deleted file mode 100644 index c8cf36d98..000000000 --- a/templates/plugin-template/src/ui/PluginLayout.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { AppLayout } from '@vertesia/ui/layout'; -import { NestedNavigationContext, useRouterBasePath } from '@vertesia/ui/router'; -import { PluginSidebar } from './PluginSidebar'; -import { PluginTopNav } from './PluginTopNav'; - -interface PluginLayoutProps { - children: React.ReactNode; -} - -export function PluginLayout({ children }: PluginLayoutProps) { - const sidebarBg = 'bg-sidebar text-sidebar-foreground border-r border-sidebar-border w-full'; - const basePath = useRouterBasePath(); - - return ( - - - - )} - sidebarClassName={sidebarBg} - mainNav={} - > - {children} - - ); -} diff --git a/templates/plugin-template/src/ui/PluginTopNav.tsx b/templates/plugin-template/src/ui/PluginTopNav.tsx deleted file mode 100644 index 3a5a4b287..000000000 --- a/templates/plugin-template/src/ui/PluginTopNav.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Avatar, Button } from '@vertesia/ui/core'; -import { Env } from '@vertesia/ui/env'; -import { useUITranslation } from '@vertesia/ui/i18n'; -import { HamburgerButton } from '@vertesia/ui/layout'; -import { useUserSession } from '@vertesia/ui/session'; -import { LogOut } from 'lucide-react'; - -export function PluginTopNav() { - const { t } = useUITranslation(); - const { user, logout } = useUserSession(); - - return ( -
-
    -
  • - -
  • -
  • - {Env.name} -
  • -
-
    - {user && ( - <> -
  • - -
  • -
  • - -
  • - - )} -
-
- ); -} diff --git a/templates/plugin-template/src/ui/api-base.ts b/templates/plugin-template/src/ui/api-base.ts new file mode 100644 index 000000000..8bfa6a826 --- /dev/null +++ b/templates/plugin-template/src/ui/api-base.ts @@ -0,0 +1,16 @@ +export function resolveAppApiBaseUrl() { + const configured = import.meta.env.VITE_APP_API_BASE_URL ?? import.meta.env.VITE_APP_API_BASE; + if (configured) return configured.replace(/\/+$/, ''); + + const parts = window.location.pathname.split('/').filter(Boolean); + if (parts[0] === 'live' && parts[1]) { + return `/live/${parts[1]}/api`; + } + if (parts[0] === 'tenants' && parts[2] === 'live' && parts[3]) { + return `/${parts.slice(0, 4).join('/')}/api`; + } + if (parts[0] === 'tenants' && parts[2] === 'apps' && parts[4] === 'versions' && parts[5]) { + return `/${parts.slice(0, 6).join('/')}/api`; + } + return '/api'; +} diff --git a/templates/plugin-template/src/ui/app.tsx b/templates/plugin-template/src/ui/app.tsx deleted file mode 100644 index be6d02a32..000000000 --- a/templates/plugin-template/src/ui/app.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { NestedRouterProvider } from "@vertesia/ui/router"; -import { routes } from "./routes"; - -export function App() { - return ( - - ) -} \ No newline at end of file diff --git a/templates/plugin-template/src/ui/app/App.tsx b/templates/plugin-template/src/ui/app/App.tsx new file mode 100644 index 000000000..bb14f5b9b --- /dev/null +++ b/templates/plugin-template/src/ui/app/App.tsx @@ -0,0 +1,14 @@ +import { NestedRouterProvider } from "@vertesia/ui/router"; +import { ContentObjectsListStateProvider } from "./features/content-objects"; +import { ConversationsListStateProvider } from "./features/conversations"; +import { routes } from "./routes"; + +export function App() { + return ( + + + + + + ) +} \ No newline at end of file diff --git a/templates/plugin-template/src/ui/app/README.md b/templates/plugin-template/src/ui/app/README.md index d679dbf2a..5cbdd9ed6 100644 --- a/templates/plugin-template/src/ui/app/README.md +++ b/templates/plugin-template/src/ui/app/README.md @@ -1,6 +1,45 @@ -# Application Components +# App -Put your components and business logic here +User application code lives here. Layout follows the standard React app structure: +```text +app/ +├── App.tsx # Root component (renders NestedRouterProvider) +├── routes.tsx # Route definitions +├── constants.ts # Shared constants (interaction names, etc.) +├── components/ # Shared UI components (used across features/pages) +├── features/ # Business logic grouped by feature +│ └── / +│ ├── components/ # Feature-only components +│ ├── hooks/ # Feature-only hooks +│ ├── types.ts # Feature types +│ ├── utils.ts # Feature helpers +│ ├── View.tsx +│ └── index.ts # Barrel export +├── hooks/ # Cross-feature custom hooks +├── layouts/ # Plugin chrome (PluginLayout, PluginSidebar, …) +└── pages/ # Route-level components — thin wrappers around features +``` +## Adding a feature +1. Create `app/features//`. +2. Build it as a self-contained module with `components/`, `hooks/`, `types.ts`, `utils.ts`, etc. +3. Export the entry view and any public types via `index.ts`. +4. Add a thin route component in `app/pages/Page.tsx` that imports from `features/`. +5. Wire the route in `app/routes.tsx`. + +For UI patterns and component conventions, see the `vertesia-ui` skill. + +## Visual defaults + +Build app screens as compact Vertesia Studio product surfaces. The default is a +light operational UI with restrained brand accents. + +- Use `@vertesia/ui` components and semantic tokens before hardcoded colors. +- Prefer dense tables, queues, filters, split panes, detail pages, and process + timelines over hero sections or oversized dashboard cards. +- Keep page titles around `text-xl`/`text-2xl`; use smaller headings inside + panels. +- Do not force black/near-black backgrounds, neon palettes, or dark-first panels + unless explicitly requested. diff --git a/templates/plugin-template/src/ui/app/components/InlineFilterButton.tsx b/templates/plugin-template/src/ui/app/components/InlineFilterButton.tsx new file mode 100644 index 000000000..c5d8cb7fc --- /dev/null +++ b/templates/plugin-template/src/ui/app/components/InlineFilterButton.tsx @@ -0,0 +1,25 @@ +import { Filter as FilterIcon } from 'lucide-react'; +import { Button } from '@vertesia/ui/core'; + +interface InlineFilterButtonProps { + tooltip: string; + hoverClass: string; + onClick: () => void; +} + +export function InlineFilterButton({ tooltip, hoverClass, onClick }: InlineFilterButtonProps) { + return ( + + ); +} diff --git a/templates/plugin-template/src/ui/app/components/SortableHead.tsx b/templates/plugin-template/src/ui/app/components/SortableHead.tsx new file mode 100644 index 000000000..2b83cf0e9 --- /dev/null +++ b/templates/plugin-template/src/ui/app/components/SortableHead.tsx @@ -0,0 +1,42 @@ +import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react'; + +export type SortDir = 'asc' | 'desc'; + +interface SortableHeadProps { + field: TField; + label: string; + activeField: TField; + direction: SortDir; + onSort: (field: TField) => void; + className?: string; +} + +export function SortableHead({ + field, + label, + activeField, + direction, + onSort, + className, +}: SortableHeadProps) { + const isActive = activeField === field; + const Icon = isActive ? (direction === 'asc' ? ArrowUp : ArrowDown) : ArrowUpDown; + return ( + onSort(field)} + > +
+ {label} +
+ + ); +} diff --git a/templates/plugin-template/src/ui/app/constants.ts b/templates/plugin-template/src/ui/app/constants.ts new file mode 100644 index 000000000..ca3cf3eef --- /dev/null +++ b/templates/plugin-template/src/ui/app/constants.ts @@ -0,0 +1,3 @@ +// Format: app::: +// The literal below is replaced by the generator from the `ASSISTANT_INTERACTION` prompt. +export const ASSISTANT_INTERACTION = "TEMPLATE__ASSISTANT_INTERACTION"; diff --git a/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectDetailView.tsx b/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectDetailView.tsx new file mode 100644 index 000000000..1343b93be --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectDetailView.tsx @@ -0,0 +1,157 @@ +import { Badge, ErrorBox, Spinner, useFetch } from '@vertesia/ui/core'; +import { + AudioPanel, + GenericPageNavHeader, + ImagePanel, + SimplePdfViewer, + VideoPanel, + WEB_SUPPORTED_AUDIO_FORMATS, + WEB_SUPPORTED_IMAGE_FORMATS, + WEB_SUPPORTED_VIDEO_FORMATS, +} from '@vertesia/ui/features'; +import { useUITranslation } from '@vertesia/ui/i18n'; +import { NavLink, useParams } from '@vertesia/ui/router'; +import { useUserSession } from '@vertesia/ui/session'; +import type { ContentObject } from '@vertesia/common'; +import { statusVariant } from './utils'; + +const PDF_MIME_TYPES = new Set(['application/pdf']); + +interface MetadataRowProps { + label: string; + children: React.ReactNode; +} + +function MetadataRow({ label, children }: MetadataRowProps) { + return ( +
+ + {label} + + {children} +
+ ); +} + +function formatDate(value?: string | Date): string { + if (!value) return '—'; + const d = value instanceof Date ? value : new Date(value); + return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString(); +} + +interface PreviewProps { + object: ContentObject; + t: (key: string) => string; +} + +function Preview({ object, t }: PreviewProps) { + const mime = object.content?.type; + if (mime && PDF_MIME_TYPES.has(mime)) { + return ; + } + if (mime && WEB_SUPPORTED_IMAGE_FORMATS.includes(mime)) { + return ; + } + if (mime && WEB_SUPPORTED_VIDEO_FORMATS.includes(mime)) { + return ; + } + if (mime && WEB_SUPPORTED_AUDIO_FORMATS.includes(mime)) { + return ; + } + return ( +
+ {t('objects.detail.previewUnsupported')} +
+ ); +} + +export function ContentObjectDetailView() { + const { t } = useUITranslation(); + const { client } = useUserSession(); + const { id } = useParams() as { id?: string }; + + const { data: object, isLoading, error } = useFetch( + () => (id ? client.store.objects.retrieve(id) : Promise.resolve(undefined)), + [id], + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ {String(error)} +
+ ); + } + + if (!object) { + return ( +
+ {id ?? ''} +
+ ); + } + + const properties = object.properties as Record | undefined; + const fullName = object.name || object.id; + + return ( +
+ + {t('nav.objects')} + , + + + {fullName} + + , + ]} + /> +
+ +
+ +
+
+
+ ); +} diff --git a/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsListStateContext.ts b/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsListStateContext.ts new file mode 100644 index 000000000..14e9b5015 --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsListStateContext.ts @@ -0,0 +1,48 @@ +import { + createContext, + useContext, + type Dispatch, + type MutableRefObject, + type SetStateAction, +} from 'react'; +import type { Filter } from '@vertesia/ui/core'; +import type { ContentObjectItem } from '@vertesia/common'; +import type { SortDir } from '../../components/SortableHead'; +import type { SortField } from './types'; + +export interface ContentObjectsListStateValue { + // Search params + query: string; + setQuery: Dispatch>; + filters: Filter[]; + setFilters: Dispatch>; + sortField: SortField; + setSortField: Dispatch>; + sortDir: SortDir; + setSortDir: Dispatch>; + + // Data + items: ContentObjectItem[]; + isLoading: boolean; + isLoadingMore: boolean; + hasMore: boolean; + loadMore: () => void; + refetch: () => Promise; + + // Scroll position (preserved across list/detail navigation) + scrollTopRef: MutableRefObject; +} + +export const ContentObjectsListStateContext = createContext< + ContentObjectsListStateValue | undefined +>(undefined); + +export function useContentObjectsListState() { + const ctx = useContext(ContentObjectsListStateContext); + if (!ctx) { + throw new Error( + 'useContentObjectsListState must be used inside ContentObjectsListStateProvider', + ); + } + return ctx; +} diff --git a/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsListStateProvider.tsx b/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsListStateProvider.tsx new file mode 100644 index 000000000..4d9eb2bf6 --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsListStateProvider.tsx @@ -0,0 +1,185 @@ +import { + useCallback, + useMemo, + useRef, + useState, + type Dispatch, + type ReactNode, + type SetStateAction, +} from 'react'; +import { type Filter, useDebounce, useFetch, useToast } from '@vertesia/ui/core'; +import { useUITranslation } from '@vertesia/ui/i18n'; +import { useUserSession } from '@vertesia/ui/session'; +import type { ContentObjectItem } from '@vertesia/common'; +import type { SortDir } from '../../components/SortableHead'; +import { + ContentObjectsListStateContext, + type ContentObjectsListStateValue, +} from './ContentObjectsListStateContext'; +import { PAGE_SIZE, SORT_FIELD_MAP, type SortField } from './types'; +import { getSelectValues } from './utils'; + +// FilterProvider re-applies URL filters on mount. When the list state survives +// route changes, that re-application can append duplicate chips. Dedupe writes. +function dedupeFilters(filters: Filter[]) { + const seen = new Set(); + const deduped: Filter[] = []; + for (const filter of filters) { + const normalizedValue = Array.isArray(filter.value) + ? filter.value.map((entry) => + typeof entry === 'string' ? entry : `${entry.value}|${entry.label || ''}`, + ) + : []; + const key = [ + filter.name, + filter.type ?? '', + filter.multiple ? 'multi' : 'single', + ...normalizedValue, + ].join('::'); + if (seen.has(key)) continue; + seen.add(key); + deduped.push(filter); + } + return deduped; +} + +interface ProviderProps { + children: ReactNode; +} + +export function ContentObjectsListStateProvider({ children }: ProviderProps) { + const { t } = useUITranslation(); + const { client } = useUserSession(); + const toast = useToast(); + + const [query, setQuery] = useState(''); + const [filtersState, setFiltersState] = useState([]); + const [sortField, setSortField] = useState('updated'); + const [sortDir, setSortDir] = useState('desc'); + const [moreItems, setMoreItems] = useState([]); + const [hasMore, setHasMore] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const scrollTopRef = useRef(0); + + const debouncedQuery = useDebounce(query, 300); + + const setFilters: Dispatch> = useCallback((value) => { + setFiltersState((current) => { + const next = typeof value === 'function' ? value(current) : value; + return dedupeFilters(next); + }); + }, []); + + const buildQuery = useCallback(() => { + const typeIds = getSelectValues(filtersState, 'type'); + const statusValues = getSelectValues(filtersState, 'status'); + const trimmed = debouncedQuery.trim(); + return { + ...(trimmed ? { full_text: trimmed } : {}), + ...(typeIds.length ? { types: typeIds } : {}), + ...(statusValues.length ? { status: statusValues } : {}), + }; + }, [debouncedQuery, filtersState]); + + const sortPayload = useMemo( + () => [{ field: SORT_FIELD_MAP[sortField], order: sortDir }], + [sortField, sortDir], + ); + + const { data: firstPage, isLoading, refetch } = useFetch( + async () => { + const result = await client.objects.search({ + query: buildQuery(), + limit: PAGE_SIZE, + offset: 0, + sort: sortPayload, + }); + return result.results ?? []; + }, + { + deps: [debouncedQuery, filtersState, sortField, sortDir], + onSuccess: (results) => { + setMoreItems([]); + setHasMore(results.length >= PAGE_SIZE); + }, + onError: (err) => { + console.error('Content object search failed:', err); + toast({ status: 'error', title: t('objects.searchError') }); + }, + }, + ); + + const items = useMemo(() => [...(firstPage ?? []), ...moreItems], [firstPage, moreItems]); + + const loadMore = useCallback(() => { + if (isLoadingMore || !hasMore || isLoading) return; + setIsLoadingMore(true); + const offset = items.length; + client.objects + .search({ + query: buildQuery(), + limit: PAGE_SIZE, + offset, + sort: sortPayload, + }) + .then((result) => { + const results = result.results ?? []; + setMoreItems((prev) => [...prev, ...results]); + setHasMore(results.length >= PAGE_SIZE); + }) + .catch((err) => { + console.error('Load more failed:', err); + toast({ status: 'error', title: t('objects.searchError') }); + }) + .finally(() => setIsLoadingMore(false)); + }, [ + client, + buildQuery, + sortPayload, + items.length, + isLoadingMore, + isLoading, + hasMore, + toast, + t, + ]); + + const value: ContentObjectsListStateValue = useMemo( + () => ({ + query, + setQuery, + filters: filtersState, + setFilters, + sortField, + setSortField, + sortDir, + setSortDir, + items, + isLoading, + isLoadingMore, + hasMore, + loadMore, + refetch, + scrollTopRef, + }), + [ + query, + filtersState, + setFilters, + sortField, + sortDir, + items, + isLoading, + isLoadingMore, + hasMore, + loadMore, + refetch, + ], + ); + + return ( + + {children} + + ); +} diff --git a/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsView.tsx b/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsView.tsx new file mode 100644 index 000000000..49fe2f495 --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsView.tsx @@ -0,0 +1,421 @@ +import { + startTransition, + useCallback, + useDeferredValue, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { RefreshCw } from 'lucide-react'; +import { + Button, + type FilterGroup, + type FilterOption, + FilterBar, + FilterBtn, + FilterClear, + FilterProvider, + Input, + Spinner, + TBody, + THead, + Table, + useIntersectionObserver, +} from '@vertesia/ui/core'; +import { GenericPageNavHeader } from '@vertesia/ui/features'; +import { useUITranslation } from '@vertesia/ui/i18n'; +import { useNavigate } from '@vertesia/ui/router'; +import { useUserSession } from '@vertesia/ui/session'; +import type { ContentObjectTypeItem } from '@vertesia/common'; +import { SortableHead } from '../../components/SortableHead'; +import { ContentObjectRow } from './components/ContentObjectRow'; +import { useContentObjectsListState } from './ContentObjectsListStateContext'; +import { + STATUS_VALUES, + type ContentObjectRowModel, + type FilterableField, + type SortField, +} from './types'; +import { statusVariant } from './utils'; + +const SCROLL_HISTORY_KEY = 'contentObjectsScrollTop'; + +function persistScrollTop(scrollTop: number) { + const state = (window.history.state as { data?: Record } | null) ?? {}; + window.history.replaceState( + { + ...state, + data: { + ...((state.data as Record | undefined) ?? {}), + [SCROLL_HISTORY_KEY]: scrollTop, + }, + }, + '', + ); +} + +function readScrollTop(): number | undefined { + const state = window.history.state as + | { data?: Record } + | null; + const value = state?.data?.[SCROLL_HISTORY_KEY]; + return typeof value === 'number' ? value : undefined; +} + +// Walk up from `start` to find the nearest scrollable ancestor (the element that +// actually scrolls when the user scrolls inside it). Falls back to documentElement +// when no overflow:auto/scroll ancestor exists. +function findScrollableElement(start: HTMLElement | null): HTMLElement | null { + let current: HTMLElement | null = start; + while (current && current !== document.body) { + const overflowY = window.getComputedStyle(current).overflowY; + const canScroll = + (overflowY === 'auto' || overflowY === 'scroll') && + current.scrollHeight > current.clientHeight; + if (canScroll) return current; + current = current.parentElement; + } + return document.scrollingElement as HTMLElement | null; +} + +export function ContentObjectsView() { + const { t } = useUITranslation(); + const { client } = useUserSession(); + const navigate = useNavigate(); + + const { + query, + setQuery, + filters, + setFilters, + sortField, + setSortField, + sortDir, + setSortDir, + items, + isLoading, + isLoadingMore, + loadMore, + refetch, + scrollTopRef, + } = useContentObjectsListState(); + + const [types, setTypes] = useState([]); + const loadMoreRef = useRef(null); + const scrollContainerRef = useRef(null); + const scrollElRef = useRef(null); + const restoreDoneRef = useRef(false); + const deferredItems = useDeferredValue(items); + + useEffect(() => { + client.store.types + .list({ limit: 200 }) + .then(setTypes) + .catch((err) => console.error('Failed to load content types:', err)); + }, [client]); + + useIntersectionObserver(loadMoreRef, loadMore, { + threshold: 0.1, + deps: [loadMore], + }); + + // Persist scroll on every event. The actual scrolling element may be an + // ancestor of `scrollContainerRef` (e.g. AppLayout's outer overflow-y-auto), + // so find it dynamically. + // + // Important: do NOT persist on cleanup. By the time cleanup runs, the route + // has changed and the View's DOM is being detached — `el.scrollTop` reads as + // 0 and would overwrite the saved value. The on-scroll listener already + // persists every value during the user's scroll, so the latest position is + // already saved when navigation happens. + useEffect(() => { + const el = findScrollableElement(scrollContainerRef.current); + scrollElRef.current = el; + if (!el) return; + const onScroll = () => { + const next = el.scrollTop; + scrollTopRef.current = next; + persistScrollTop(next); + }; + el.addEventListener('scroll', onScroll, { passive: true }); + return () => { + el.removeEventListener('scroll', onScroll); + }; + }, [scrollTopRef]); + + // Restore scroll once the list has rendered. useLayoutEffect (not useEffect) + // so the scroll happens before paint. A single rAF is not enough — the table + // can take a few frames to lay out enough rows that scrollHeight reaches the + // target. Poll up to maxAttempts frames waiting for sufficient scrollHeight. + // Restore scroll once the list has rendered. useLayoutEffect runs after DOM + // mutations but BEFORE paint — so doing the scroll synchronously here means + // the user never sees the un-restored top-of-list flash. We only fall back + // to rAF polling if scrollHeight isn't yet tall enough (rare — happens with + // virtualized lists or async row mounting). + useLayoutEffect(() => { + if (restoreDoneRef.current) return; + if (isLoading || items.length === 0) return; + const target = readScrollTop() ?? scrollTopRef.current; + if (target <= 0) { + restoreDoneRef.current = true; + return; + } + + const trySync = (): boolean => { + const el = + scrollElRef.current ?? + findScrollableElement(scrollContainerRef.current); + scrollElRef.current = el; + if (!el) return false; + const maxScroll = el.scrollHeight - el.clientHeight; + if (maxScroll < target) return false; + el.scrollTop = target; + return true; + }; + + if (trySync()) { + restoreDoneRef.current = true; + return; + } + + // scrollHeight too small right now — poll rAF until rows fill it out. + let attempts = 0; + const maxAttempts = 10; + let cancelled = false; + const tryAsync = () => { + if (cancelled) return; + if (trySync() || attempts >= maxAttempts) { + const el = scrollElRef.current; + if (el && !restoreDoneRef.current) { + const maxScroll = el.scrollHeight - el.clientHeight; + el.scrollTop = Math.min(target, Math.max(maxScroll, 0)); + } + restoreDoneRef.current = true; + return; + } + attempts++; + requestAnimationFrame(tryAsync); + }; + const frame = requestAnimationFrame(tryAsync); + return () => { + cancelled = true; + cancelAnimationFrame(frame); + }; + }, [isLoading, items.length, scrollTopRef]); + + const handleSort = useCallback( + (field: SortField) => { + startTransition(() => { + if (sortField === field) { + setSortDir((current) => (current === 'asc' ? 'desc' : 'asc')); + return; + } + setSortField(field); + setSortDir('asc'); + }); + }, + [sortField, setSortField, setSortDir], + ); + + const handleRowOpen = useCallback( + (id: string) => { + startTransition(() => { + navigate(`/objects/${id}`); + }); + }, + [navigate], + ); + + const addFilterValue = useCallback( + (name: FilterableField, value: string, label: string) => { + const placeholder = + name === 'type' ? t('objects.filterType') : t('objects.filterStatus'); + const newOption: FilterOption = { value, label }; + startTransition(() => { + setFilters((prev) => { + const existing = prev.find((f) => f.name === name); + if (!existing) { + return [ + ...prev, + { + name, + placeholder, + type: 'select', + multiple: true, + value: [newOption], + }, + ]; + } + const currentValues = Array.isArray(existing.value) ? existing.value : []; + const alreadyHas = currentValues.some( + (v) => (typeof v === 'string' ? v : v.value) === value, + ); + if (alreadyHas) return prev; + return prev.map((f) => + f === existing + ? { ...f, value: [...(f.value as FilterOption[]), newOption] } + : f, + ); + }); + }); + }, + [t, setFilters], + ); + + const filterGroups: FilterGroup[] = useMemo( + () => [ + { + name: 'type', + placeholder: t('objects.filterType'), + type: 'select', + multiple: true, + options: [...types] + .map((typ) => ({ value: typ.id, label: typ.name })) + .sort((a, b) => a.label.localeCompare(b.label)), + }, + { + name: 'status', + placeholder: t('objects.filterStatus'), + type: 'select', + multiple: true, + options: STATUS_VALUES.map((s) => ({ + value: s, + label: t(`objects.status.${s}`), + })).sort((a, b) => a.label.localeCompare(b.label)), + }, + ], + [types, t], + ); + + const rowModels = useMemo( + () => + deferredItems.map((item) => { + const title = item.name || item.id; + const typeName = item.type?.name ?? '—'; + const typeId = item.type && 'id' in item.type ? item.type.id : undefined; + const statusLabel = item.status ? t(`objects.status.${item.status}`) : '—'; + + return { + id: item.id, + title, + description: item.description, + typeId, + typeName, + typeFilterTooltip: + typeId ? t('objects.filterByValue', { value: typeName }) : undefined, + statusValue: item.status, + statusLabel, + statusFilterTooltip: item.status + ? t('objects.filterByValue', { value: statusLabel }) + : undefined, + statusVariant: statusVariant(item.status), + updatedLabel: item.updated_at ? new Date(item.updated_at).toLocaleString() : '—', + }; + }), + [deferredItems, t], + ); + + const tableRows = useMemo( + () => + rowModels.map((row) => ( + + )), + [rowModels, addFilterValue, handleRowOpen], + ); + + const showEmpty = !isLoading && rowModels.length === 0; + + return ( +
+ +
+ +
+
+ +
+ + + +
+ +
+ +
+ + + + + + + + + + + {tableRows} + +
+ {isLoadingMore && ( +
+ +
+ )} +
+ {showEmpty && ( +
+ {t('objects.empty')} +
+ )} +
+
+
+ ); +} diff --git a/templates/plugin-template/src/ui/app/features/content-objects/components/ContentObjectRow.tsx b/templates/plugin-template/src/ui/app/features/content-objects/components/ContentObjectRow.tsx new file mode 100644 index 000000000..cf10eaa1e --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/content-objects/components/ContentObjectRow.tsx @@ -0,0 +1,58 @@ +import { memo } from 'react'; +import { Badge } from '@vertesia/ui/core'; +import { InlineFilterButton } from '../../../components/InlineFilterButton'; +import type { FilterableField } from '../types'; +import type { ContentObjectRowModel } from '../types'; + +interface ContentObjectRowProps { + row: ContentObjectRowModel; + onAddFilter: (name: FilterableField, value: string, label: string) => void; + onOpen: (id: string) => void; +} + +function ContentObjectRowImpl({ row, onAddFilter, onOpen }: ContentObjectRowProps) { + return ( + onOpen(row.id)} + > + +
+ {row.title} + {row.description && ( + + {row.description} + + )} +
+ + +
+ {row.typeName} + {row.typeId && row.typeFilterTooltip && ( + onAddFilter('type', row.typeId!, row.typeName)} + /> + )} +
+ + +
+ {row.statusLabel} + {row.statusValue && row.statusFilterTooltip && ( + onAddFilter('status', row.statusValue!, row.statusLabel)} + /> + )} +
+ + {row.updatedLabel} + + ); +} + +export const ContentObjectRow = memo(ContentObjectRowImpl); diff --git a/templates/plugin-template/src/ui/app/features/content-objects/index.ts b/templates/plugin-template/src/ui/app/features/content-objects/index.ts new file mode 100644 index 000000000..4fd554616 --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/content-objects/index.ts @@ -0,0 +1,5 @@ +export { ContentObjectsView } from './ContentObjectsView'; +export { ContentObjectDetailView } from './ContentObjectDetailView'; +export { ContentObjectsListStateProvider } from './ContentObjectsListStateProvider'; +export { useContentObjectsListState } from './ContentObjectsListStateContext'; +export type { SortField, FilterableField } from './types'; diff --git a/templates/plugin-template/src/ui/app/features/content-objects/types.ts b/templates/plugin-template/src/ui/app/features/content-objects/types.ts new file mode 100644 index 000000000..6bd9201f8 --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/content-objects/types.ts @@ -0,0 +1,41 @@ +import { ContentObjectStatus } from '@vertesia/common'; + +export type SortField = 'name' | 'type' | 'status' | 'updated'; + +// Elasticsearch text fields can't be sorted directly; use the .keyword sub-field. +// See ElasticsearchIndexManager BASE_INDEX_MAPPING_PROPERTIES. +export const SORT_FIELD_MAP: Record = { + name: 'name.keyword', + type: 'type.name', + status: 'status', + updated: 'updated_at', +}; + +export const PAGE_SIZE = 50; + +export const STATUS_VALUES = Object.values(ContentObjectStatus); + +export type FilterableField = 'type' | 'status'; + +export type BadgeVariant = + | 'default' + | 'secondary' + | 'destructive' + | 'attention' + | 'success' + | 'info' + | 'done'; + +export interface ContentObjectRowModel { + id: string; + title: string; + description?: string; + typeId?: string; + typeName: string; + typeFilterTooltip?: string; + statusValue?: ContentObjectStatus; + statusLabel: string; + statusFilterTooltip?: string; + statusVariant: BadgeVariant; + updatedLabel: string; +} diff --git a/templates/plugin-template/src/ui/app/features/content-objects/utils.ts b/templates/plugin-template/src/ui/app/features/content-objects/utils.ts new file mode 100644 index 000000000..f10ea3c74 --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/content-objects/utils.ts @@ -0,0 +1,28 @@ +import { ContentObjectStatus } from '@vertesia/common'; +import type { Filter } from '@vertesia/ui/core'; +import type { BadgeVariant } from './types'; + +export function statusVariant(status?: ContentObjectStatus): BadgeVariant { + switch (status) { + case ContentObjectStatus.ready: + case ContentObjectStatus.completed: + return 'success'; + case ContentObjectStatus.failed: + return 'destructive'; + case ContentObjectStatus.processing: + case ContentObjectStatus.created: + return 'attention'; + case ContentObjectStatus.archived: + return 'done'; + default: + return 'default'; + } +} + +export function getSelectValues(filters: Filter[], name: string): string[] { + const filter = filters.find((f) => f.name === name); + if (!filter || !Array.isArray(filter.value) || filter.value.length === 0) return []; + return filter.value + .map((v) => (typeof v === 'string' ? v : v.value ?? '')) + .filter((v): v is string => Boolean(v)); +} diff --git a/templates/plugin-template/src/ui/app/features/conversations/ConversationsListStateContext.ts b/templates/plugin-template/src/ui/app/features/conversations/ConversationsListStateContext.ts new file mode 100644 index 000000000..a17c3a3e9 --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/conversations/ConversationsListStateContext.ts @@ -0,0 +1,36 @@ +import { createContext, useContext, type Dispatch, type SetStateAction } from 'react'; +import type { Filter } from '@vertesia/ui/core'; +import type { AgentRunSearchHit } from '@vertesia/common'; +import type { SortDir } from '../../components/SortableHead'; +import type { SortField } from './types'; + +export interface ConversationsListStateValue { + query: string; + setQuery: Dispatch>; + filters: Filter[]; + setFilters: Dispatch>; + sortField: SortField; + setSortField: Dispatch>; + sortDir: SortDir; + setSortDir: Dispatch>; + + hits: AgentRunSearchHit[]; + isLoading: boolean; + refetch: () => Promise; + + scrollTopRef: React.MutableRefObject; +} + +export const ConversationsListStateContext = createContext< + ConversationsListStateValue | undefined +>(undefined); + +export function useConversationsListState() { + const ctx = useContext(ConversationsListStateContext); + if (!ctx) { + throw new Error( + 'useConversationsListState must be used inside ConversationsListStateProvider', + ); + } + return ctx; +} diff --git a/templates/plugin-template/src/ui/app/features/conversations/ConversationsListStateProvider.tsx b/templates/plugin-template/src/ui/app/features/conversations/ConversationsListStateProvider.tsx new file mode 100644 index 000000000..10d55470f --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/conversations/ConversationsListStateProvider.tsx @@ -0,0 +1,163 @@ +import { + useCallback, + useMemo, + useRef, + useState, + type Dispatch, + type ReactNode, + type SetStateAction, +} from 'react'; +import { type Filter, useDebounce, useFetch, useToast } from '@vertesia/ui/core'; +import { useUITranslation } from '@vertesia/ui/i18n'; +import { useUserSession } from '@vertesia/ui/session'; +import type { AgentRunResponse, AgentRunSearchHit, AgentRunStatus } from '@vertesia/common'; +import type { SortDir } from '../../components/SortableHead'; +import { + ConversationsListStateContext, + type ConversationsListStateValue, +} from './ConversationsListStateContext'; +import { PAGE_SIZE, type SortField } from './types'; +import { getSelectValues } from './utils'; + +// Normalize the `list` response to the `search` response shape so the View +// always sees a single type (AgentRunSearchHit). +function toHit(run: AgentRunResponse): AgentRunSearchHit { + const isAgent = run.run_kind === 'agent'; + const toIso = (v: unknown): string => { + if (v instanceof Date) return v.toISOString(); + if (typeof v === 'string') return v; + return ''; + }; + return { + id: run.id, + score: 0, + interaction: isAgent ? run.interaction : undefined, + run_kind: run.run_kind, + run_type: run.run_type, + interaction_name: isAgent ? run.interaction_name : undefined, + status: run.status, + activity_state: run.activity_state, + started_at: toIso(run.started_at), + completed_at: toIso(run.completed_at) || undefined, + started_by: run.started_by, + title: run.title, + topic: isAgent ? run.topic : undefined, + interactive: isAgent ? (run.interactive ?? false) : false, + created_at: toIso(run.created_at), + updated_at: toIso(run.updated_at), + }; +} + +// FilterProvider re-applies URL filters on mount; dedupe writes so back-nav +// doesn't multiply chips. +function dedupeFilters(filters: Filter[]) { + const seen = new Set(); + const deduped: Filter[] = []; + for (const filter of filters) { + const normalizedValue = Array.isArray(filter.value) + ? filter.value.map((entry) => + typeof entry === 'string' ? entry : `${entry.value}|${entry.label || ''}`, + ) + : []; + const key = [ + filter.name, + filter.type ?? '', + filter.multiple ? 'multi' : 'single', + ...normalizedValue, + ].join('::'); + if (seen.has(key)) continue; + seen.add(key); + deduped.push(filter); + } + return deduped; +} + +interface ProviderProps { + children: ReactNode; +} + +export function ConversationsListStateProvider({ children }: ProviderProps) { + const { t } = useUITranslation(); + const { client } = useUserSession(); + const toast = useToast(); + + const [query, setQuery] = useState(''); + const [filtersState, setFiltersState] = useState([]); + const [sortField, setSortField] = useState('started'); + const [sortDir, setSortDir] = useState('desc'); + const scrollTopRef = useRef(0); + + const debouncedQuery = useDebounce(query, 300); + + const setFilters: Dispatch> = useCallback((value) => { + setFiltersState((current) => { + const next = typeof value === 'function' ? value(current) : value; + return dedupeFilters(next); + }); + }, []); + + // Backend re-runs on query / status / single-agent / sort changes. + // - With query: `agents.search` (full-text, sort=relevance — `sort` ignored by backend + // but we still refetch so the user gets a loading indicator). + // - Without query: `agents.list` (supports sort by `started_at` / `updated_at`; for other + // sort fields, the loaded page is sorted client-side downstream). + // Multi-agent filter is client-side (backend `interaction` param is single-valued). + const { data, isLoading, refetch } = useFetch( + async () => { + const trimmed = debouncedQuery.trim(); + const statusValues = getSelectValues(filtersState, 'status') as AgentRunStatus[]; + const agentValues = getSelectValues(filtersState, 'agent'); + if (trimmed) { + const response = await client.agents.search({ + query: trimmed, + ...(statusValues.length ? { status: statusValues } : {}), + ...(agentValues.length === 1 ? { interaction: agentValues[0] } : {}), + limit: PAGE_SIZE, + }); + return response.hits; + } + const backendSort: 'started_at' | 'updated_at' | undefined = + sortField === 'started' ? 'started_at' : undefined; + const response = await client.agents.list({ + ...(statusValues.length === 1 ? { status: statusValues[0] } : {}), + ...(agentValues.length === 1 ? { interaction: agentValues[0] } : {}), + ...(backendSort ? { sort: backendSort, order: sortDir } : {}), + limit: PAGE_SIZE, + }); + return response.items.map(toHit); + }, + { + deps: [debouncedQuery, filtersState, sortField, sortDir], + onError: (err) => { + console.error('Conversations fetch failed:', err); + toast({ status: 'error', title: t('conversations.searchError') }); + }, + }, + ); + + const hits = useMemo(() => data ?? [], [data]); + + const value: ConversationsListStateValue = useMemo( + () => ({ + query, + setQuery, + filters: filtersState, + setFilters, + sortField, + setSortField, + sortDir, + setSortDir, + hits, + isLoading, + refetch, + scrollTopRef, + }), + [query, filtersState, setFilters, sortField, sortDir, hits, isLoading, refetch], + ); + + return ( + + {children} + + ); +} diff --git a/templates/plugin-template/src/ui/app/features/conversations/ConversationsView.tsx b/templates/plugin-template/src/ui/app/features/conversations/ConversationsView.tsx new file mode 100644 index 000000000..7afb4f0ee --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/conversations/ConversationsView.tsx @@ -0,0 +1,353 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; +import { RefreshCw } from 'lucide-react'; +import { + Button, + type FilterGroup, + type FilterOption, + FilterBar, + FilterBtn, + FilterClear, + FilterProvider, + Input, + TBody, + THead, + Table, +} from '@vertesia/ui/core'; +import { GenericPageNavHeader } from '@vertesia/ui/features'; +import { useUITranslation } from '@vertesia/ui/i18n'; +import { useNavigate } from '@vertesia/ui/router'; +import type { AgentRunSearchHit } from '@vertesia/common'; +import { SortableHead } from '../../components/SortableHead'; +import { ConversationRow } from './components/ConversationRow'; +import { useConversationsListState } from './ConversationsListStateContext'; +import { STATUS_VALUES, type FilterableField, type SortField } from './types'; +import { getSelectValues } from './utils'; + +const SCROLL_HISTORY_KEY = 'conversationsScrollTop'; + +function persistScrollTop(scrollTop: number) { + const state = (window.history.state as { data?: Record } | null) ?? {}; + window.history.replaceState( + { + ...state, + data: { + ...((state.data as Record | undefined) ?? {}), + [SCROLL_HISTORY_KEY]: scrollTop, + }, + }, + '', + ); +} + +function readScrollTop(): number | undefined { + const state = window.history.state as { data?: Record } | null; + const value = state?.data?.[SCROLL_HISTORY_KEY]; + return typeof value === 'number' ? value : undefined; +} + +function findScrollableElement(start: HTMLElement | null): HTMLElement | null { + let current: HTMLElement | null = start; + while (current && current !== document.body) { + const overflowY = window.getComputedStyle(current).overflowY; + const canScroll = + (overflowY === 'auto' || overflowY === 'scroll') && + current.scrollHeight > current.clientHeight; + if (canScroll) return current; + current = current.parentElement; + } + return document.scrollingElement as HTMLElement | null; +} + +export function ConversationsView() { + const { t } = useUITranslation(); + const navigate = useNavigate(); + + const { + query, + setQuery, + filters, + setFilters, + sortField, + setSortField, + sortDir, + setSortDir, + hits, + isLoading, + refetch, + scrollTopRef, + } = useConversationsListState(); + + const scrollContainerRef = useRef(null); + const scrollElRef = useRef(null); + const restoreDoneRef = useRef(false); + + // Persist scroll on every event; do NOT persist on cleanup (DOM detached → scrollTop=0). + useEffect(() => { + const el = findScrollableElement(scrollContainerRef.current); + scrollElRef.current = el; + if (!el) return; + const onScroll = () => { + scrollTopRef.current = el.scrollTop; + persistScrollTop(el.scrollTop); + }; + el.addEventListener('scroll', onScroll, { passive: true }); + return () => { + el.removeEventListener('scroll', onScroll); + }; + }, [scrollTopRef]); + + // Restore scroll synchronously inside useLayoutEffect (before paint). + useLayoutEffect(() => { + if (restoreDoneRef.current) return; + if (isLoading || hits.length === 0) return; + const target = readScrollTop() ?? scrollTopRef.current; + if (target <= 0) { + restoreDoneRef.current = true; + return; + } + const trySync = (): boolean => { + const el = + scrollElRef.current ?? + findScrollableElement(scrollContainerRef.current); + scrollElRef.current = el; + if (!el) return false; + const maxScroll = el.scrollHeight - el.clientHeight; + if (maxScroll < target) return false; + el.scrollTop = target; + return true; + }; + if (trySync()) { + restoreDoneRef.current = true; + return; + } + let attempts = 0; + const maxAttempts = 10; + let cancelled = false; + const tryAsync = () => { + if (cancelled) return; + if (trySync() || attempts >= maxAttempts) { + const el = scrollElRef.current; + if (el && !restoreDoneRef.current) { + const maxScroll = el.scrollHeight - el.clientHeight; + el.scrollTop = Math.min(target, Math.max(maxScroll, 0)); + } + restoreDoneRef.current = true; + return; + } + attempts++; + requestAnimationFrame(tryAsync); + }; + const frame = requestAnimationFrame(tryAsync); + return () => { + cancelled = true; + cancelAnimationFrame(frame); + }; + }, [isLoading, hits.length, scrollTopRef]); + + // Build agent-filter options dynamically from the loaded set. + const agentOptions = useMemo(() => { + const seen = new Map(); + for (const hit of hits) { + const code = hit.interaction; + if (!code) continue; + const label = hit.interaction_name || code; + if (!seen.has(code)) seen.set(code, label); + } + return [...seen.entries()] + .map(([value, label]) => ({ value, label })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [hits]); + + // Multi-agent filtering is client-side because the backend `interaction` + // param is single-valued. Backend already handled query + status + the + // single-agent case. Sort is also client-side (backend offers no sort). + const displayedHits = useMemo(() => { + const agents = new Set(getSelectValues(filters, 'agent')); + const filtered = agents.size > 1 + ? hits.filter((hit) => hit.interaction && agents.has(hit.interaction)) + : hits; + + const dirSign = sortDir === 'asc' ? 1 : -1; + const getSortValue = (hit: AgentRunSearchHit): string => { + switch (sortField) { + case 'topic': + return ((hit.topic ?? hit.title ?? '')).toLowerCase(); + case 'agent': + return hit.interaction ?? ''; + case 'status': + return hit.status ?? ''; + case 'started': + default: + return hit.started_at ?? ''; + } + }; + return [...filtered].sort((a, b) => { + const va = getSortValue(a); + const vb = getSortValue(b); + if (va < vb) return -1 * dirSign; + if (va > vb) return 1 * dirSign; + return 0; + }); + }, [hits, filters, sortField, sortDir]); + + const handleSort = useCallback( + (field: SortField) => { + if (sortField === field) { + setSortDir((current) => (current === 'asc' ? 'desc' : 'asc')); + return; + } + setSortField(field); + setSortDir('asc'); + }, + [sortField, setSortField, setSortDir], + ); + + const handleOpen = useCallback( + (id: string) => navigate(`/chat/${id}`), + [navigate], + ); + + const addFilterValue = useCallback( + (name: FilterableField, value: string, label: string) => { + const placeholder = + name === 'status' + ? t('conversations.filterStatus') + : t('conversations.filterAgent'); + const newOption: FilterOption = { value, label }; + setFilters((prev) => { + const existing = prev.find((f) => f.name === name); + if (!existing) { + return [ + ...prev, + { name, placeholder, type: 'select', multiple: true, value: [newOption] }, + ]; + } + const currentValues = Array.isArray(existing.value) ? existing.value : []; + const alreadyHas = currentValues.some( + (v) => (typeof v === 'string' ? v : v.value) === value, + ); + if (alreadyHas) return prev; + return prev.map((f) => + f === existing + ? { ...f, value: [...(f.value as FilterOption[]), newOption] } + : f, + ); + }); + }, + [t, setFilters], + ); + + const filterGroups: FilterGroup[] = useMemo( + () => [ + { + name: 'agent', + placeholder: t('conversations.filterAgent'), + type: 'select', + multiple: true, + options: agentOptions, + }, + { + name: 'status', + placeholder: t('conversations.filterStatus'), + type: 'select', + multiple: true, + options: STATUS_VALUES.map((s) => ({ value: s, label: s })), + }, + ], + [agentOptions, t], + ); + + const showEmpty = !isLoading && displayedHits.length === 0; + + return ( +
+ +
+ +
+
+ +
+ + + +
+ +
+ +
+ + + + + + + + + + + {displayedHits.map((hit) => ( + + ))} + +
+ {showEmpty && ( +
+ {t('conversations.empty')} +
+ )} +
+
+
+ ); +} diff --git a/templates/plugin-template/src/ui/app/features/conversations/components/ConversationRow.tsx b/templates/plugin-template/src/ui/app/features/conversations/components/ConversationRow.tsx new file mode 100644 index 000000000..218a4e714 --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/conversations/components/ConversationRow.tsx @@ -0,0 +1,60 @@ +import { Badge } from '@vertesia/ui/core'; +import type { AgentRunSearchHit } from '@vertesia/common'; +import { InlineFilterButton } from '../../../components/InlineFilterButton'; +import type { FilterableField } from '../types'; +import { statusVariant } from '../utils'; + +interface ConversationRowProps { + hit: AgentRunSearchHit; + t: (key: string, opts?: Record) => string; + onAddFilter: (name: FilterableField, value: string, label: string) => void; + onOpen: (id: string) => void; +} + +export function ConversationRow({ hit, t, onAddFilter, onOpen }: ConversationRowProps) { + const topic = hit.topic || hit.title || t('conversations.untitled'); + const interaction = hit.interaction; + const interactionLabel = hit.interaction_name || interaction; + const started = hit.started_at ? new Date(hit.started_at).toLocaleString() : '—'; + const status = hit.status; + + return ( + onOpen(hit.id)} + > + +
+ {topic} +
+ + +
+ {interactionLabel ?? '—'} + {interaction && ( + + onAddFilter('agent', interaction, interactionLabel ?? interaction) + } + /> + )} +
+ + +
+ {status ?? '—'} + {status && ( + onAddFilter('status', status, status)} + /> + )} +
+ + {started} + + ); +} diff --git a/templates/plugin-template/src/ui/app/features/conversations/index.ts b/templates/plugin-template/src/ui/app/features/conversations/index.ts new file mode 100644 index 000000000..9ff638c71 --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/conversations/index.ts @@ -0,0 +1,3 @@ +export { ConversationsView } from './ConversationsView'; +export { ConversationsListStateProvider } from './ConversationsListStateProvider'; +export { useConversationsListState } from './ConversationsListStateContext'; diff --git a/templates/plugin-template/src/ui/app/features/conversations/types.ts b/templates/plugin-template/src/ui/app/features/conversations/types.ts new file mode 100644 index 000000000..3fd35a01a --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/conversations/types.ts @@ -0,0 +1,31 @@ +import type { AgentRunStatus } from '@vertesia/common'; + +export type SortField = 'topic' | 'agent' | 'status' | 'started'; + +export const SORT_FIELD_MAP: Record = { + topic: 'topic', + agent: 'interaction', + status: 'status', + started: 'started_at', +}; + +export const PAGE_SIZE = 100; + +export const STATUS_VALUES: AgentRunStatus[] = [ + 'created', + 'running', + 'completed', + 'failed', + 'cancelled', +]; + +export type FilterableField = 'status' | 'agent'; + +export type BadgeVariant = + | 'default' + | 'secondary' + | 'destructive' + | 'attention' + | 'success' + | 'info' + | 'done'; diff --git a/templates/plugin-template/src/ui/app/features/conversations/utils.ts b/templates/plugin-template/src/ui/app/features/conversations/utils.ts new file mode 100644 index 000000000..cd15ae071 --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/conversations/utils.ts @@ -0,0 +1,25 @@ +import type { Filter } from '@vertesia/ui/core'; +import type { AgentRunStatus } from '@vertesia/common'; +import type { BadgeVariant } from './types'; + +export function statusVariant(status?: AgentRunStatus): BadgeVariant { + switch (status) { + case 'completed': + return 'success'; + case 'failed': + case 'cancelled': + return 'destructive'; + case 'running': + return 'attention'; + default: + return 'default'; + } +} + +export function getSelectValues(filters: Filter[], name: string): string[] { + const filter = filters.find((f) => f.name === name); + if (!filter || !Array.isArray(filter.value) || filter.value.length === 0) return []; + return filter.value + .map((v) => (typeof v === 'string' ? v : v.value ?? '')) + .filter((v): v is string => Boolean(v)); +} diff --git a/templates/plugin-template/src/ui/app/hooks/.gitkeep b/templates/plugin-template/src/ui/app/hooks/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/templates/plugin-template/src/ui/AppSidebarItem.tsx b/templates/plugin-template/src/ui/app/layouts/AppSidebarItem.tsx similarity index 100% rename from templates/plugin-template/src/ui/AppSidebarItem.tsx rename to templates/plugin-template/src/ui/app/layouts/AppSidebarItem.tsx diff --git a/templates/plugin-template/src/ui/app/layouts/CommandPalette.tsx b/templates/plugin-template/src/ui/app/layouts/CommandPalette.tsx new file mode 100644 index 000000000..8462c467c --- /dev/null +++ b/templates/plugin-template/src/ui/app/layouts/CommandPalette.tsx @@ -0,0 +1,164 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Command, Search } from "lucide-react"; +import { Modal } from "@vertesia/ui/core"; +import { useUITranslation } from "@vertesia/ui/i18n"; +import { useNavigate } from "@vertesia/ui/router"; +import { routes } from "../routes"; +import type { PluginRoute } from "../routes"; + +interface PaletteItem { + path: string; + label: string; + icon?: PluginRoute["icon"]; +} + +function isMacLike(): boolean { + if (typeof navigator === "undefined") return false; + return /Mac|iPhone|iPad|iPod/.test(navigator.platform); +} + +function buildItems(t: (key: string) => string): PaletteItem[] { + return routes + .filter(route => !route.hideFromNav && route.label) + .map(route => ({ + path: route.path, + label: route.label!.includes(".") ? t(route.label!) : route.label!, + icon: route.icon, + })); +} + +export function CommandPaletteTrigger({ onOpen }: { onOpen: () => void }) { + const { t } = useUITranslation(); + const shortcut = isMacLike() ? "⌘K" : "Ctrl+K"; + return ( + + ); +} + +export function CommandPalette() { + const { t } = useUITranslation(); + const navigate = useNavigate(); + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + const inputRef = useRef(null); + + const items = useMemo(() => buildItems(t), [t]); + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return items; + return items.filter(item => item.label.toLowerCase().includes(q)); + }, [items, query]); + + const open = useCallback(() => { + setQuery(""); + setActiveIndex(0); + setIsOpen(true); + }, []); + + const close = useCallback(() => setIsOpen(false), []); + + useEffect(() => { + const handler = (event: KeyboardEvent) => { + const isToggle = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k"; + if (isToggle) { + event.preventDefault(); + setIsOpen(prev => !prev); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + + useEffect(() => { + if (isOpen) { + const id = window.setTimeout(() => inputRef.current?.focus(), 0); + return () => window.clearTimeout(id); + } + }, [isOpen]); + + const handleQueryChange = useCallback((value: string) => { + setQuery(value); + setActiveIndex(0); + }, []); + + const choose = useCallback((item: PaletteItem) => { + navigate(item.path); + close(); + }, [navigate, close]); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex(i => Math.min(i + 1, filtered.length - 1)); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex(i => Math.max(i - 1, 0)); + } else if (event.key === "Enter") { + event.preventDefault(); + const item = filtered[activeIndex]; + if (item) choose(item); + } else if (event.key === "Escape") { + event.preventDefault(); + close(); + } + }; + + return ( + <> + + +
+
+ + handleQueryChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={t("nav.commandPalettePlaceholder")} + className="flex-1 bg-transparent text-sm outline-none" + /> +
+
    + {filtered.length === 0 && ( +
  • + {t("nav.commandPaletteNoResults")} +
  • + )} + {filtered.map((item, index) => { + const Icon = item.icon; + const isActive = index === activeIndex; + return ( +
  • + +
  • + ); + })} +
+
+
+ + ); +} diff --git a/templates/plugin-template/src/ui/OrgGate.tsx b/templates/plugin-template/src/ui/app/layouts/OrgGate.tsx similarity index 100% rename from templates/plugin-template/src/ui/OrgGate.tsx rename to templates/plugin-template/src/ui/app/layouts/OrgGate.tsx diff --git a/templates/plugin-template/src/ui/app/layouts/PersistentAssistant.tsx b/templates/plugin-template/src/ui/app/layouts/PersistentAssistant.tsx new file mode 100644 index 000000000..0b09b21c5 --- /dev/null +++ b/templates/plugin-template/src/ui/app/layouts/PersistentAssistant.tsx @@ -0,0 +1,150 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Bot, X } from "lucide-react"; +import { Button, SidePanel } from "@vertesia/ui/core"; +import { ModernAgentConversation } from "@vertesia/ui/features"; +import { useUITranslation } from "@vertesia/ui/i18n"; +import { useLocation } from "@vertesia/ui/router"; +import { useUserSession } from "@vertesia/ui/session"; +import type { CreateAgentRunPayload } from "@vertesia/common"; +import { ASSISTANT_INTERACTION } from "../constants"; +import { OPEN_ASSISTANT_EVENT } from "./assistantEvents"; +import type { OpenAssistantDetail } from "./assistantEvents"; + +/** + * Route-aware assistant context. Replace this builder for your plugin to + * surface relevant ids (objectId, typeId, selectedObjectIds, etc.) to agent + * runs. The default extracts the first path segment as `scope`. + */ +export interface AssistantContext { + scope: string; + route: string; + [key: string]: unknown; +} + +function defaultContext(pathname: string): AssistantContext { + const segments = pathname.split("/").filter(Boolean); + return { scope: segments[0] || "home", route: pathname }; +} + +interface PersistentAssistantProps { + /** + * Build a route-aware context that gets passed to every agent run started + * from the assistant. Override per plugin to include entity ids. + */ + getContext?: (pathname: string) => AssistantContext; + /** + * Tag every agent run started from the assistant. Use this to scope + * Activity tabs and personalization queries to your plugin. + */ + appTag?: string; + /** Width of the side panel in pixels. */ + panelWidth?: number; + /** Initial assistant interaction id. Defaults to `ASSISTANT_INTERACTION`. */ + interaction?: string; +} + +export function PersistentAssistant({ + getContext = defaultContext, + appTag = "plugin-app", + panelWidth = 560, + interaction = ASSISTANT_INTERACTION, +}: PersistentAssistantProps) { + const { t } = useUITranslation(); + const { client, project, account, user } = useUserSession(); + const location = useLocation(); + const [isOpen, setIsOpen] = useState(false); + const [agentRunId, setAgentRunId] = useState(); + const [suggestedMessage, setSuggestedMessage] = useState(); + + const context = useMemo(() => getContext(location.pathname), [getContext, location.pathname]); + + useEffect(() => { + const open = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail?.agentRunId) { + setAgentRunId(detail.agentRunId); + } + setSuggestedMessage(detail?.initialMessage); + setIsOpen(true); + }; + window.addEventListener(OPEN_ASSISTANT_EVENT, open); + return () => window.removeEventListener(OPEN_ASSISTANT_EVENT, open); + }, []); + + const startWorkflow = useCallback(async (initialMessage?: string) => { + const payload: CreateAgentRunPayload = { + interaction, + interactive: true, + data: { + user_prompt: initialMessage || "", + app: appTag, + context, + project_name: project?.name, + account_name: account?.name, + }, + started_by: user?.sub, + tags: [appTag, "user-assistant", context.scope], + }; + const result = await client.agents.start(payload); + if (result?.id) { + setAgentRunId(result.id); + return { agent_run_id: result.id }; + } + return undefined; + }, [interaction, appTag, context, project?.name, account?.name, user?.sub, client.agents]); + + const handleReset = useCallback(() => setAgentRunId(undefined), []); + + return ( + <> + {!isOpen && ( + + )} + setIsOpen(false)} + title={( +
+ + {t("nav.pluginAssistant")} +
+ )} + panelWidth={panelWidth} + contentClassName="flex-1 flex flex-col overflow-hidden min-h-0" + > +
+ {t("nav.assistantContext", { scope: context.scope, route: context.route })} + {agentRunId && ( + + )} +
+
+ +
+
+ + ); +} diff --git a/templates/plugin-template/src/ui/PluginAccessDenied.tsx b/templates/plugin-template/src/ui/app/layouts/PluginAccessDenied.tsx similarity index 100% rename from templates/plugin-template/src/ui/PluginAccessDenied.tsx rename to templates/plugin-template/src/ui/app/layouts/PluginAccessDenied.tsx diff --git a/templates/plugin-template/src/ui/app/layouts/PluginLayout.tsx b/templates/plugin-template/src/ui/app/layouts/PluginLayout.tsx new file mode 100644 index 000000000..6cc4e55d3 --- /dev/null +++ b/templates/plugin-template/src/ui/app/layouts/PluginLayout.tsx @@ -0,0 +1,31 @@ +import { AppLayout } from "@vertesia/ui/layout"; +import { NestedNavigationContext, useRouterBasePath } from "@vertesia/ui/router"; +import { PersistentAssistant } from "./PersistentAssistant"; +import { PluginSidebar } from "./PluginSidebar"; +import { PluginTopNav } from "./PluginTopNav"; + +interface PluginLayoutProps { + children: React.ReactNode; +} + +export function PluginLayout({ children }: PluginLayoutProps) { + const sidebarBg = "bg-sidebar text-sidebar-foreground border-r border-sidebar-border w-full"; + const basePath = useRouterBasePath(); + + return ( + <> + + + + )} + sidebarClassName={sidebarBg} + mainNav={} + > + {children} + + + + ); +} diff --git a/templates/plugin-template/src/ui/PluginSidebar.tsx b/templates/plugin-template/src/ui/app/layouts/PluginSidebar.tsx similarity index 67% rename from templates/plugin-template/src/ui/PluginSidebar.tsx rename to templates/plugin-template/src/ui/app/layouts/PluginSidebar.tsx index b548a80c1..8003d13b3 100644 --- a/templates/plugin-template/src/ui/PluginSidebar.tsx +++ b/templates/plugin-template/src/ui/app/layouts/PluginSidebar.tsx @@ -1,15 +1,33 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { ModeToggle } from '@vertesia/ui/core'; import { useUITranslation } from '@vertesia/ui/i18n'; import { SidebarSection, useSidebarToggle } from '@vertesia/ui/layout'; import { useLocation, useRouterBasePath } from '@vertesia/ui/router'; import { useUserSession } from '@vertesia/ui/session'; -import { HomeIcon, MessageSquare, PlusCircle } from 'lucide-react'; -import type { AgentRunResponse, WorkflowRun } from '@vertesia/common'; +import { Database, HomeIcon, MessageSquare, MessagesSquare, PlusCircle } from 'lucide-react'; +import type { WorkflowRun } from '@vertesia/common'; import { AppSidebarItem } from './AppSidebarItem'; -import { ASSISTANT_INTERACTION } from './constants'; +import { ASSISTANT_INTERACTION } from '../constants'; -function toWorkflowRun(run: AgentRunResponse): WorkflowRun { +const SIDEBAR_RECENT_LIMIT = 3; + +type RecentAgentRun = { + id: string; + run_kind?: string; + workflow_id?: string; + status?: WorkflowRun['status']; + started_at?: string | number | Date | null; + completed_at?: string | number | Date | null; + topic?: string; + title?: string; + data?: Record; + interaction_name?: string; + visibility?: WorkflowRun['visibility']; + activity_state?: WorkflowRun['activity_state']; + interactive?: boolean; +}; + +function toWorkflowRun(run: RecentAgentRun): WorkflowRun { const isAgentRun = run.run_kind === 'agent'; return { @@ -29,49 +47,14 @@ function toWorkflowRun(run: AgentRunResponse): WorkflowRun { function getConversationLabel(conv: WorkflowRun, t: (key: string) => string): string { if (conv.topic) return conv.topic; - // input is not populated by listConversations, but check anyway for forward compat const prompt = conv.input?.data?.user_prompt; if (typeof prompt === 'string' && prompt.trim()) return prompt.trim(); - // Fall back to a formatted date/time if (conv.started_at) { return new Date(conv.started_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } return t('nav.conversation'); } -function getDateLabel(date: Date, t: (key: string) => string): string { - const now = new Date(); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const yesterday = new Date(today); - yesterday.setDate(yesterday.getDate() - 1); - const target = new Date(date.getFullYear(), date.getMonth(), date.getDate()); - - if (target.getTime() === today.getTime()) return t('nav.today'); - if (target.getTime() === yesterday.getTime()) return t('nav.yesterday'); - return date.toLocaleDateString(); -} - -interface GroupedConversations { - dateLabel: string; - conversations: WorkflowRun[]; -} - -function groupByDate(conversations: WorkflowRun[], t: (key: string) => string): GroupedConversations[] { - const groups: GroupedConversations[] = []; - let currentLabel = ''; - for (const conv of conversations) { - const date = conv.started_at ? new Date(conv.started_at) : new Date(); - const label = getDateLabel(date, t); - if (label !== currentLabel) { - currentLabel = label; - groups.push({ dateLabel: label, conversations: [conv] }); - } else { - groups[groups.length - 1].conversations.push(conv); - } - } - return groups; -} - export function PluginSidebar() { const { t } = useUITranslation(); const path = useLocation().pathname; @@ -83,14 +66,12 @@ export function PluginSidebar() { useEffect(() => { client.agents.list({ interaction: ASSISTANT_INTERACTION, - limit: 20, + limit: SIDEBAR_RECENT_LIMIT, sort: 'started_at', order: 'desc', }).then(response => setConversations(response.items.map(toWorkflowRun))); }, [client]); - const grouped = useMemo(() => groupByDate(conversations, t), [conversations, t]); - return (
@@ -104,6 +85,22 @@ export function PluginSidebar() { > {t('nav.home')} + + {t('nav.objects')} + + + {t('nav.conversations')} + - {grouped.map(group => ( - - {group.conversations.map(conv => { + {conversations.length > 0 && ( + + {conversations.map(conv => { const convPath = `${basePath}/chat/${conv.run_id}`; return ( - ))} + )}
diff --git a/templates/plugin-template/src/ui/app/layouts/PluginTopNav.tsx b/templates/plugin-template/src/ui/app/layouts/PluginTopNav.tsx new file mode 100644 index 000000000..05da4b693 --- /dev/null +++ b/templates/plugin-template/src/ui/app/layouts/PluginTopNav.tsx @@ -0,0 +1,78 @@ +import type { ReactNode } from "react"; +import { Avatar, Button } from "@vertesia/ui/core"; +import { Env } from "@vertesia/ui/env"; +import { useUITranslation } from "@vertesia/ui/i18n"; +import { HamburgerButton } from "@vertesia/ui/layout"; +import { useUserSession } from "@vertesia/ui/session"; +import { Bot, LogOut } from "lucide-react"; +import { CommandPalette } from "./CommandPalette"; +import { openAssistant } from "./assistantEvents"; + +interface PluginTopNavProps { + /** + * Optional primary action rendered between the command palette and the + * assistant launcher. Use for the page-agnostic top action (e.g. a + * global Upload or Create button). + */ + primaryAction?: ReactNode; + /** + * Optional notifications slot rendered before the avatar. + */ + notifications?: ReactNode; + /** Hide the cmd-K palette trigger. */ + hideCommandPalette?: boolean; + /** Hide the persistent assistant launcher. */ + hideAssistantLauncher?: boolean; +} + +export function PluginTopNav({ + primaryAction, + notifications, + hideCommandPalette = false, + hideAssistantLauncher = false, +}: PluginTopNavProps) { + const { t } = useUITranslation(); + const { user, logout } = useUserSession(); + + return ( +
+
+
+ +
+ {Env.name} +
+
+ {!hideCommandPalette && } +
+
+ {primaryAction} + {!hideAssistantLauncher && ( + + )} + {notifications} + {user && ( + <> + + + + )} +
+
+ ); +} diff --git a/templates/plugin-template/src/ui/app/layouts/assistantEvents.ts b/templates/plugin-template/src/ui/app/layouts/assistantEvents.ts new file mode 100644 index 000000000..9cb760659 --- /dev/null +++ b/templates/plugin-template/src/ui/app/layouts/assistantEvents.ts @@ -0,0 +1,13 @@ +export const OPEN_ASSISTANT_EVENT = "plugin:open-assistant"; + +export interface OpenAssistantDetail { + initialMessage?: string; + agentRunId?: string; +} + +export function openAssistant(initialMessage?: string, agentRunId?: string): void { + const event = new CustomEvent(OPEN_ASSISTANT_EVENT, { + detail: { initialMessage, agentRunId }, + }); + window.dispatchEvent(event); +} diff --git a/templates/plugin-template/src/ui/pages.tsx b/templates/plugin-template/src/ui/app/pages/ChatPage.tsx similarity index 54% rename from templates/plugin-template/src/ui/pages.tsx rename to templates/plugin-template/src/ui/app/pages/ChatPage.tsx index a0ec4386c..5c4a6db08 100644 --- a/templates/plugin-template/src/ui/pages.tsx +++ b/templates/plugin-template/src/ui/app/pages/ChatPage.tsx @@ -1,31 +1,10 @@ -import { useCallback } from "react"; -import { Bot } from "lucide-react"; -import { ModernAgentConversation } from "@vertesia/ui/features"; -import { Button } from "@vertesia/ui/core"; -import { useNavigate, useParams } from "@vertesia/ui/router"; -import { useUserSession } from "@vertesia/ui/session"; -import { useUITranslation } from "@vertesia/ui/i18n"; import type { CreateAgentRunPayload } from "@vertesia/common"; -import { ASSISTANT_INTERACTION } from "./constants"; - -export function HomePage() { - const { user } = useUserSession(); - const { t } = useUITranslation(); - const navigate = useNavigate(); - - return ( -
-

{t('nav.welcome', { name: user?.name || user?.email })}

-

- {t('nav.templateDescription')} -

- -
- ); -} +import { GenericPageNavHeader, ModernAgentConversation } from "@vertesia/ui/features"; +import { useUITranslation } from "@vertesia/ui/i18n"; +import { NavLink, useNavigate, useParams } from "@vertesia/ui/router"; +import { useUserSession } from "@vertesia/ui/session"; +import { useCallback } from "react"; +import { ASSISTANT_INTERACTION } from "../constants"; export function ChatPage() { const { t } = useUITranslation(); @@ -38,7 +17,7 @@ export function ChatPage() { const payload: CreateAgentRunPayload = { interaction: ASSISTANT_INTERACTION, interactive: true, - data: { user_prompt: initialMessage || '' }, + data: { user_prompt: initialMessage || "" }, }; const result = await store.agents.start(payload); if (result) { @@ -48,14 +27,27 @@ export function ChatPage() { return undefined; }, [store, navigate]); - const handleReset = useCallback(() => navigate('/chat'), [navigate]); + const handleReset = useCallback(() => navigate("/chat"), [navigate]); return (
+ {agentRunId && ( + + {t('nav.conversations')} + , + + {t('nav.conversation')} + , + ]} + /> + )} ; +} diff --git a/templates/plugin-template/src/ui/app/pages/ContentObjectsPage.tsx b/templates/plugin-template/src/ui/app/pages/ContentObjectsPage.tsx new file mode 100644 index 000000000..42976ddd6 --- /dev/null +++ b/templates/plugin-template/src/ui/app/pages/ContentObjectsPage.tsx @@ -0,0 +1,5 @@ +import { ContentObjectsView } from '../features/content-objects'; + +export function ContentObjectsPage() { + return ; +} diff --git a/templates/plugin-template/src/ui/app/pages/ConversationsPage.tsx b/templates/plugin-template/src/ui/app/pages/ConversationsPage.tsx new file mode 100644 index 000000000..3b9571a18 --- /dev/null +++ b/templates/plugin-template/src/ui/app/pages/ConversationsPage.tsx @@ -0,0 +1,5 @@ +import { ConversationsView } from '../features/conversations'; + +export function ConversationsPage() { + return ; +} diff --git a/templates/plugin-template/src/ui/app/pages/HomePage.tsx b/templates/plugin-template/src/ui/app/pages/HomePage.tsx new file mode 100644 index 000000000..85ac728d6 --- /dev/null +++ b/templates/plugin-template/src/ui/app/pages/HomePage.tsx @@ -0,0 +1,24 @@ +import { Bot } from "lucide-react"; +import { Button } from "@vertesia/ui/core"; +import { useNavigate } from "@vertesia/ui/router"; +import { useUserSession } from "@vertesia/ui/session"; +import { useUITranslation } from "@vertesia/ui/i18n"; + +export function HomePage() { + const { user } = useUserSession(); + const { t } = useUITranslation(); + const navigate = useNavigate(); + + return ( +
+

{t('nav.welcome', { name: user?.name || user?.email })}

+

+ {t('nav.templateDescription')} +

+ +
+ ); +} diff --git a/templates/plugin-template/src/ui/app/routes.tsx b/templates/plugin-template/src/ui/app/routes.tsx new file mode 100644 index 000000000..d1aca107f --- /dev/null +++ b/templates/plugin-template/src/ui/app/routes.tsx @@ -0,0 +1,56 @@ +import type { LucideIcon } from "lucide-react"; +import { Database, HomeIcon, MessagesSquare, PlusCircle } from "lucide-react"; +import type { Route } from "@vertesia/ui/router"; +import { ChatPage } from "./pages/ChatPage"; +import { ContentObjectDetailPage } from "./pages/ContentObjectDetailPage"; +import { ContentObjectsPage } from "./pages/ContentObjectsPage"; +import { ConversationsPage } from "./pages/ConversationsPage"; +import { HomePage } from "./pages/HomePage"; + +export type PluginRoute = Route & { + label?: string; + icon?: LucideIcon; + hideFromNav?: boolean; +}; + +export const routes: PluginRoute[] = [ + { + path: '/', + label: 'nav.home', + icon: HomeIcon, + Component: () => , + }, + { + path: '/objects', + label: 'nav.objects', + icon: Database, + Component: () => , + }, + { + path: '/objects/:id', + hideFromNav: true, + Component: () => , + }, + { + path: '/conversations', + label: 'nav.conversations', + icon: MessagesSquare, + Component: () => , + }, + { + path: '/chat', + label: 'nav.newChat', + icon: PlusCircle, + Component: () => , + }, + { + path: '/chat/:agentRunId', + hideFromNav: true, + Component: () => , + }, + { + path: '*', + hideFromNav: true, + Component: () =>
Not found
, + } +]; diff --git a/templates/plugin-template/src/ui/constants.ts b/templates/plugin-template/src/ui/constants.ts deleted file mode 100644 index 46cac4531..000000000 --- a/templates/plugin-template/src/ui/constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Format: app::: -export const ASSISTANT_INTERACTION = "sys:GeneralAgent"; diff --git a/templates/plugin-template/src/ui/env.ts b/templates/plugin-template/src/ui/env.ts index 41b52a108..6db02c53e 100644 --- a/templates/plugin-template/src/ui/env.ts +++ b/templates/plugin-template/src/ui/env.ts @@ -1,18 +1,26 @@ import { Env } from "@vertesia/ui/env"; const CONFIG__PLUGIN_TITLE = "Ui Plugin Template"; +const isDev = import.meta.env.DEV; + +const CONFIG__STUDIO_URL = "https://api.vertesia.io"; +const CONFIG__ZENO_URL = "https://api.vertesia.io"; +const CONFIG__STS_URL = "https://sts.vertesia.io"; document.title = CONFIG__PLUGIN_TITLE; -Env.init({ +const envConfig: Parameters[0] & { devAuthToken?: string } = { name: CONFIG__PLUGIN_TITLE, version: "1.0.0", isLocalDev: true, isDocker: true, type: "development", + devAuthToken: isDev ? import.meta.env.VITE_VERTESIA_AUTH_TOKEN : undefined, endpoints: { - studio: "https://api.us1.vertesia.io", - zeno: "https://api.us1.vertesia.io", - sts: "https://sts.us1.vertesia.io", + studio: import.meta.env.VITE_VERTESIA_STUDIO_URL ?? CONFIG__STUDIO_URL, + zeno: import.meta.env.VITE_VERTESIA_ZENO_URL ?? CONFIG__ZENO_URL, + sts: import.meta.env.VITE_VERTESIA_STS_URL ?? CONFIG__STS_URL, } -}); +}; + +Env.init(envConfig); diff --git a/templates/plugin-template/src/ui/i18n/locales/en.json b/templates/plugin-template/src/ui/i18n/locales/en.json index fcb6feb0c..b03fcef88 100644 --- a/templates/plugin-template/src/ui/i18n/locales/en.json +++ b/templates/plugin-template/src/ui/i18n/locales/en.json @@ -6,14 +6,63 @@ "access.selectAccount": "Select Account", "access.selectProject": "Select Project", "access.switchPrompt": "Switch to a different account or project to access this app.", + "conversations.col.agent": "Agent", + "conversations.col.started": "Started", + "conversations.col.status": "Status", + "conversations.col.topic": "Topic", + "conversations.empty": "No conversations yet.", + "conversations.filterAgent": "Agent", + "conversations.filterByValue": "Filter by {{value}}", + "conversations.filterStatus": "Status", + "conversations.refresh": "Refresh", + "conversations.searchError": "Failed to search conversations.", + "conversations.searchPlaceholder": "Search conversations...", + "conversations.title": "Conversations", + "conversations.untitled": "Untitled conversation", + "nav.askAi": "Ask AI", + "nav.assistantContext": "Scope: {{scope}} • Route: {{route}}", + "nav.assistantInitialPrompt": "How can I help you today?", + "nav.assistantPlaceholder": "Ask the assistant...", + "nav.commandPaletteNoResults": "No matching commands.", + "nav.commandPalettePlaceholder": "Search commands...", "nav.conversation": "Conversation", + "nav.conversations": "All Conversations", "nav.home": "Home", + "nav.newAssistantSession": "Start a new assistant session", "nav.newChat": "New Chat", + "nav.newSession": "New session", + "nav.objects": "Content", + "nav.openAssistant": "Open assistant", + "nav.recent": "Recent", "nav.pluginAssistant": "Plugin Assistant", "nav.signOut": "Sign out", "nav.templateDescription": "This is the plugin template. Use it as a starting point to build your own plugin UI.", "nav.today": "Today", "nav.tryAgentChat": "Try the Agent Chat", "nav.welcome": "Welcome, {{name}}!", - "nav.yesterday": "Yesterday" + "nav.yesterday": "Yesterday", + "objects.col.name": "Name", + "objects.col.status": "Status", + "objects.col.type": "Type", + "objects.col.updated": "Updated", + "objects.detail.created": "Created", + "objects.detail.loadError": "Failed to load this content object.", + "objects.detail.mimeType": "MIME type", + "objects.detail.notFound": "Content object not found.", + "objects.detail.previewUnsupported": "Preview not available for this content type.", + "objects.detail.properties": "Properties", + "objects.empty": "No content objects found.", + "objects.filterByValue": "Filter by {{value}}", + "objects.filterStatus": "Status", + "objects.filterType": "Type", + "objects.refresh": "Refresh", + "objects.searchError": "Failed to search content objects.", + "objects.searchPlaceholder": "Search content objects...", + "objects.status.archived": "Archived", + "objects.status.completed": "Completed", + "objects.status.created": "Created", + "objects.status.failed": "Failed", + "objects.status.processing": "Processing", + "objects.status.ready": "Ready", + "objects.title": "Content" } diff --git a/templates/plugin-template/src/ui/i18n/locales/fr.json b/templates/plugin-template/src/ui/i18n/locales/fr.json index a41b25da0..b897e7dea 100644 --- a/templates/plugin-template/src/ui/i18n/locales/fr.json +++ b/templates/plugin-template/src/ui/i18n/locales/fr.json @@ -6,14 +6,63 @@ "access.selectAccount": "Sélectionner un compte", "access.selectProject": "Sélectionner un projet", "access.switchPrompt": "Changez de compte ou de projet pour accéder à cette application.", + "conversations.col.agent": "Agent", + "conversations.col.started": "Démarrée", + "conversations.col.status": "Statut", + "conversations.col.topic": "Sujet", + "conversations.empty": "Aucune conversation pour le moment.", + "conversations.filterAgent": "Agent", + "conversations.filterByValue": "Filtrer par {{value}}", + "conversations.filterStatus": "Statut", + "conversations.refresh": "Rafraîchir", + "conversations.searchError": "Échec de la recherche de conversations.", + "conversations.searchPlaceholder": "Rechercher des conversations...", + "conversations.title": "Conversations", + "conversations.untitled": "Conversation sans titre", + "nav.askAi": "Demander à l'IA", + "nav.assistantContext": "Contexte : {{scope}} • Route : {{route}}", + "nav.assistantInitialPrompt": "Comment puis-je vous aider aujourd'hui ?", + "nav.assistantPlaceholder": "Posez une question à l'assistant...", + "nav.commandPaletteNoResults": "Aucune commande correspondante.", + "nav.commandPalettePlaceholder": "Rechercher des commandes...", "nav.conversation": "Conversation", + "nav.conversations": "Toutes les conversations", "nav.home": "Accueil", + "nav.newAssistantSession": "Démarrer une nouvelle session d'assistant", "nav.newChat": "Nouvelle conversation", + "nav.newSession": "Nouvelle session", + "nav.objects": "Contenu", + "nav.openAssistant": "Ouvrir l'assistant", + "nav.recent": "Récentes", "nav.pluginAssistant": "Assistant du plugin", "nav.signOut": "Se déconnecter", "nav.templateDescription": "Ceci est le modèle de plugin. Utilisez-le comme point de départ pour créer votre propre interface de plugin.", "nav.today": "Aujourd'hui", "nav.tryAgentChat": "Essayer le chat agent", "nav.welcome": "Bienvenue, {{name}} !", - "nav.yesterday": "Hier" + "nav.yesterday": "Hier", + "objects.col.name": "Nom", + "objects.col.status": "Statut", + "objects.col.type": "Type", + "objects.col.updated": "Mis à jour", + "objects.detail.created": "Créé", + "objects.detail.loadError": "Échec du chargement de cet objet de contenu.", + "objects.detail.mimeType": "Type MIME", + "objects.detail.notFound": "Objet de contenu introuvable.", + "objects.detail.previewUnsupported": "Aperçu non disponible pour ce type de contenu.", + "objects.detail.properties": "Propriétés", + "objects.empty": "Aucun objet de contenu trouvé.", + "objects.filterByValue": "Filtrer par {{value}}", + "objects.filterStatus": "Statut", + "objects.filterType": "Type", + "objects.refresh": "Rafraîchir", + "objects.searchError": "Échec de la recherche d'objets de contenu.", + "objects.searchPlaceholder": "Rechercher des objets de contenu...", + "objects.status.archived": "Archivé", + "objects.status.completed": "Terminé", + "objects.status.created": "Créé", + "objects.status.failed": "Échec", + "objects.status.processing": "En cours", + "objects.status.ready": "Prêt", + "objects.title": "Contenu" } diff --git a/templates/plugin-template/src/ui/index.css b/templates/plugin-template/src/ui/index.css index b93b80213..faff5f74e 100644 --- a/templates/plugin-template/src/ui/index.css +++ b/templates/plugin-template/src/ui/index.css @@ -11,23 +11,25 @@ /* * Theme Customization * - * Override the default Vertesia theme colors below. Values use oklch(lightness chroma hue). - * See https://oklch.com for a color picker. - * These apply globally — plugin pages and the admin UI will both use the custom theme. - * Uncomment and adjust to match your brand. + * Slate-blue default palette — calm, neutral, light + dark. + * Edit the oklch values below to brand your plugin (https://oklch.com). + * For a dark-first plugin, also set class="dark" on in index.html. + * + * Looking for ready-made palettes? See "Theme presets" in the template README. */ -/* :root { - --primary: oklch(55% 0.200 145); - --primary-background: oklch(97% 0.020 145); - --secondary: oklch(35% 0.050 280); - --secondary-background: oklch(95% 0.010 280); +:root { + --primary: oklch(58% 0.180 265); + --primary-background: oklch(97% 0.020 265); + --secondary: oklch(40% 0.060 280); + --secondary-background: oklch(96% 0.010 280); + --ring: oklch(58% 0.180 265 / 0.45); } .dark { - --primary: oklch(72% 0.190 145); - --primary-background: oklch(25% 0.030 145); - --secondary: oklch(80% 0.040 280); - --secondary-background: oklch(22% 0.015 280); -} */ - + --primary: oklch(72% 0.170 265); + --primary-background: oklch(22% 0.040 265); + --secondary: oklch(78% 0.040 280); + --secondary-background: oklch(20% 0.020 280); + --ring: oklch(72% 0.170 265 / 0.55); +} diff --git a/templates/plugin-template/src/ui/main.tsx b/templates/plugin-template/src/ui/main.tsx index 4ebd9ff9d..3e200f5ce 100644 --- a/templates/plugin-template/src/ui/main.tsx +++ b/templates/plugin-template/src/ui/main.tsx @@ -7,12 +7,12 @@ import './index.css' // initialize dev environment import { AdminApp } from '@vertesia/tools-admin-ui' import { RouterProvider, type Route } from '@vertesia/ui/router' -import { App } from './app' +import { App } from './app/App' import { setUsePluginAssets } from './assets' import "./env" -import { OrgGate } from './OrgGate' -import { PluginAccessDenied } from './PluginAccessDenied' -import { PluginLayout } from './PluginLayout' +import { OrgGate } from './app/layouts/OrgGate' +import { PluginAccessDenied } from './app/layouts/PluginAccessDenied' +import { PluginLayout } from './app/layouts/PluginLayout' setUsePluginAssets(false); diff --git a/templates/plugin-template/src/ui/plugin.tsx b/templates/plugin-template/src/ui/plugin.tsx index 8369a7eb0..5b4b129fb 100644 --- a/templates/plugin-template/src/ui/plugin.tsx +++ b/templates/plugin-template/src/ui/plugin.tsx @@ -1,7 +1,7 @@ import { PortalContainerProvider } from "@vertesia/ui/core"; import "./i18n"; // register plugin-specific translations -import { App } from "./app"; -import { PluginLayout } from "./PluginLayout"; +import { App } from "./app/App"; +import { PluginLayout } from "./app/layouts/PluginLayout"; /** * Export the plugin component. diff --git a/templates/plugin-template/src/ui/routes.tsx b/templates/plugin-template/src/ui/routes.tsx deleted file mode 100644 index 51748cfa6..000000000 --- a/templates/plugin-template/src/ui/routes.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { ChatPage, HomePage } from "./pages"; - -export const routes = [ - { - path: '/', - Component: HomePage, - }, - { - path: '/chat', - Component: ChatPage, - }, - { - path: '/chat/:agentRunId', - Component: ChatPage, - }, - { - path: '*', - Component: () =>
Not found
, - } -]; diff --git a/templates/plugin-template/src/ui/vite-env.d.ts b/templates/plugin-template/src/ui/vite-env.d.ts index 11f02fe2a..5084bfd42 100644 --- a/templates/plugin-template/src/ui/vite-env.d.ts +++ b/templates/plugin-template/src/ui/vite-env.d.ts @@ -1 +1,12 @@ /// + +interface ImportMetaEnv { + readonly VITE_APP_NAME: string; + readonly VITE_VERTESIA_STUDIO_URL?: string; + readonly VITE_VERTESIA_ZENO_URL?: string; + readonly VITE_VERTESIA_STS_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/templates/plugin-template/template.config.json b/templates/plugin-template/template.config.json index e8a877c3a..217ac73bd 100644 --- a/templates/plugin-template/template.config.json +++ b/templates/plugin-template/template.config.json @@ -31,6 +31,24 @@ "value": true } ] + }, + { + "type": "select", + "name": "REGION", + "message": "Vertesia region", + "initial": 0, + "choices": [ + { + "title": "Global (us1)", + "description": "Default region — api.vertesia.io / sts.vertesia.io", + "value": "us1" + }, + { + "title": "Europe (eu1)", + "description": "EU region — api.eu1.vertesia.io / sts.eu1.vertesia.io", + "value": "eu1" + } + ] } ], "derived": { @@ -45,15 +63,41 @@ "SERVER_TITLE": { "from": "PROJECT_NAME", "transform": "titleCase" + }, + "STUDIO_URL": { + "from": "REGION", + "transform": "map", + "map": { + "us1": "https://api.vertesia.io", + "eu1": "https://api.eu1.vertesia.io" + } + }, + "ZENO_URL": { + "from": "REGION", + "transform": "map", + "map": { + "us1": "https://api.vertesia.io", + "eu1": "https://api.eu1.vertesia.io" + } + }, + "STS_URL": { + "from": "REGION", + "transform": "map", + "map": { + "us1": "https://sts.vertesia.io", + "eu1": "https://sts.eu1.vertesia.io" + } } }, "files": [ "vite.config.ts", "index.html", "src/tool-server/config.ts", + "src/ui/constants.ts", "src/ui/env.ts", "src/ui/plugin.tsx", - ".env.app.template" + ".env.app.template", + "README.md" ], "renameFiles": { ".env.app.template": ".env.app" diff --git a/templates/plugin-template/vercel.json b/templates/plugin-template/vercel.json index 93ca6ab61..805e9f75c 100644 --- a/templates/plugin-template/vercel.json +++ b/templates/plugin-template/vercel.json @@ -1,6 +1,11 @@ { "buildCommand": "npm run build", "outputDirectory": "dist", + "build": { + "env": { + "ENABLE_EXPERIMENTAL_COREPACK": "1" + } + }, "redirects": [ { "source": "/", diff --git a/templates/plugin-template/vite.config.ts b/templates/plugin-template/vite.config.ts index ec212188d..d2f3d225b 100644 --- a/templates/plugin-template/vite.config.ts +++ b/templates/plugin-template/vite.config.ts @@ -89,8 +89,9 @@ function defineLibConfig({ command }: ConfigEnv): UserConfig { * @returns */ function defineAppConfig({ command }: ConfigEnv): UserConfig { - // Vercel dev proxies to the framework dev server over HTTP — HTTPS would break that. - const useHttps = !process.env.VERCEL; + // DEV_MODE is used by appgen/sandbox previews. Vercel also proxies to the + // framework dev server over HTTP, so both modes disable HTTPS. + const useHttps = process.env.DEV_MODE !== '1' && process.env.VERCEL !== '1'; const base = command === 'build' ? '/app/' : '/'; return { @@ -98,7 +99,7 @@ function defineAppConfig({ command }: ConfigEnv): UserConfig { plugins: [ tailwindcss(), react(), - // HTTPS is required for Firebase auth but must be disabled under vercel dev + // HTTPS is required for Firebase auth but must be disabled under appgen/Vercel dev ...(useHttps ? [basicSsl()] : []), // serve lib/plugin.js content in dev mode serveStatic([ @@ -113,8 +114,12 @@ function defineAppConfig({ command }: ConfigEnv): UserConfig { build: { outDir: 'dist/app', // App build goes to dist/app/ }, + optimizeDeps: process.env.DEV_MODE === '1' + ? { include: ['html-parse-stringify', 'use-sync-external-store/shim'] } + : undefined, // for authentication with Firebase server: { + hmr: process.env.APPGEN_DISABLE_HMR === '1' ? false : undefined, proxy: { '/__/auth': { target: 'https://dengenlabs.firebaseapp.com', diff --git a/templates/tool-server-template/.env.example b/templates/tool-server-template/.env.example deleted file mode 100644 index e2116b00d..000000000 --- a/templates/tool-server-template/.env.example +++ /dev/null @@ -1,14 +0,0 @@ -# Environment Variables Template -# Copy this file to .env and fill in your values - -# GitHub App Integration (optional) -# GITHUB_APP_ID=your-app-id -# GITHUB_APP_PRIVATE_KEY_FILE=path/to/private-key.pem - -# Server Configuration -# PORT=5174 -# NODE_ENV=development - -# API Keys (if needed) -# API_KEY=your-api-key -# SECRET=your-secret diff --git a/templates/tool-server-template/.gitignore b/templates/tool-server-template/.gitignore deleted file mode 100644 index f34fb3299..000000000 --- a/templates/tool-server-template/.gitignore +++ /dev/null @@ -1,44 +0,0 @@ -# Dependencies -node_modules/ -.pnp -.pnp.js - -# Build outputs -lib/ -dist/ -*.tsbuildinfo - -# Environment variables -.env -.env.local -.env.*.local -*.pem - -# IDE -.idea/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db - -# Logs -logs/ -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* - -# Vercel -.vercel - -# Testing -coverage/ -.nyc_output/ - -# Temporary files -*.tmp -.cache/ diff --git a/templates/tool-server-template/.vscode/launch.json b/templates/tool-server-template/.vscode/launch.json deleted file mode 100644 index 4a416de39..000000000 --- a/templates/tool-server-template/.vscode/launch.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "type": "node", - "request": "launch", - "name": "Debug Tool Server", - "preLaunchTask": "build", - "program": "${workspaceFolder}/lib/server-node.js", - "cwd": "${workspaceFolder}", - "env": { - "PORT": "5174" - }, - "sourceMaps": true, - "outFiles": [ - "${workspaceFolder}/lib/**/*.js" - ], - "skipFiles": [ - "/**" - ], - "resolveSourceMapLocations": [ - "${workspaceFolder}/**", - "**/node_modules/@vertesia/**", - "!**/node_modules/**" - ], - "console": "integratedTerminal", - "internalConsoleOptions": "neverOpen" - } - ] -} \ No newline at end of file diff --git a/templates/tool-server-template/.vscode/tasks.json b/templates/tool-server-template/.vscode/tasks.json deleted file mode 100644 index d7ee4dce7..000000000 --- a/templates/tool-server-template/.vscode/tasks.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": "build", - "type": "shell", - "command": "pnpm run build", - "problemMatcher": ["$tsc"], - "group": { - "kind": "build", - "isDefault": true - }, - "presentation": { - "reveal": "silent", - "panel": "shared" - } - } - ] -} diff --git a/templates/tool-server-template/LICENSE b/templates/tool-server-template/LICENSE deleted file mode 100644 index 2642274ec..000000000 --- a/templates/tool-server-template/LICENSE +++ /dev/null @@ -1,13 +0,0 @@ -Copyright 2026 Vertesia - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. \ No newline at end of file diff --git a/templates/tool-server-template/README.md b/templates/tool-server-template/README.md deleted file mode 100644 index f8b84e69d..000000000 --- a/templates/tool-server-template/README.md +++ /dev/null @@ -1,611 +0,0 @@ -# Tool Server - -A template for building custom tool servers that expose LLM tools, skills, interactions, and MCP providers. Built with [Hono](https://hono.dev/) for flexible deployment to Vercel Functions or Node.js HTTP servers. - -## Features - -- 🛠️ **Tools**: Executable functions that can be invoked via API (e.g., calculator, API integrations) -- 🎯 **Skills**: AI capabilities defined as markdown prompts with optional helper scripts -- 🔄 **Interactions**: Multi-step agent workflows with templated prompts -- 🔌 **MCP Providers**: Model Context Protocol integrations (optional) -- 📄 **Auto-generated HTML**: Browse and explore resources with automatically generated pages -- 🚀 **Flexible Deployment**: Deploy to Vercel Functions, Cloud Run, Railway, or any Node.js host -- 📦 **Browser Bundles**: Standalone browser-ready bundles for client-side usage -- 🔧 **Simple Build**: Single Rollup config handles TypeScript, raw imports, and bundling - -## Project Structure - -``` -tool-server-template/ -├── src/ -│ ├── tools/ # Tool collections -│ │ └── calculator/ # Example: calculator tool -│ │ ├── manifest.ts -│ │ ├── calculator.ts -│ │ ├── icon.svg.ts -│ │ └── index.ts -│ ├── skills/ # Skill collections -│ │ └── code-review/ # Example: code review skill -│ │ └── SKILL.md -│ ├── interactions/ # Interaction collections -│ │ └── summarize/ # Example: text summarization -│ │ └── text_summarizer/ -│ │ ├── prompt.jst -│ │ └── index.ts -│ ├── server.ts # Hono server entry point -│ └── build-site.ts # Static HTML generator -├── api/ -│ └── index.js # Vercel adapter -├── lib/ # Compiled code (TypeScript → JavaScript) -│ ├── server.js -│ ├── server-node.js -│ ├── tools/ -│ └── ... -├── dist/ # Static HTML pages (public files) -│ ├── index.html -│ ├── tools/ -│ ├── skills/ -│ └── interactions/ -├── public/ # Static assets -├── package.json -├── rollup.config.js # Unified build configuration (TypeScript + bundles) -├── vercel.json # Vercel deployment config -└── tsconfig.json -``` - -## Getting Started - -### Prerequisites - -- Node.js 18+ -- npm or pnpm - -### Installation - -```bash -# Install dependencies -npm install -# or -pnpm install -``` - -### Development - -Start the development server with automatic rebuild and restart: - -```bash -npm run dev -``` - -This will: -1. **Initial build** - Compiles TypeScript and generates HTML pages -2. **Rollup watch mode** - Rebuilds TypeScript on file changes -3. **Node.js with --watch** - Restarts server when lib/ changes - -The server will be available at: -- API: http://localhost:3000/api -- Web UI: http://localhost:3000 - -To use a different port: -```bash -PORT=8080 npm run dev -``` - -**Manual control (advanced):** -```bash -# Terminal 1: Build on changes -npm run build:watch - -# Terminal 2: Server with auto-restart -npm run start:watch -``` - -### Building - -Build the project for production: - -```bash -npm run build -``` - -This will: -1. **Rollup**: Compile TypeScript to JavaScript in `lib/` (ESM with preserveModules) -2. **Copy assets**: Copy skill assets (.md, .py files) to `lib/` -3. **Generate HTML**: Create static HTML pages in `dist/` -4. **Rollup**: Create browser bundles in `dist/libs/` - -The build uses a single **rollup.config.js** that handles: -- TypeScript compilation with `@rollup/plugin-typescript` → `lib/` -- `?raw` imports for template files (via custom rawPlugin) -- Browser bundles with tree-shaking and minification → `dist/libs/` - -**Output structure:** -- `lib/` = Compiled code (what you run) -- `dist/` = Static HTML + browser bundles (what you serve) - -## Creating Resources - -### 1. Creating a Tool - -Tools are executable functions that can be invoked via API. - -**Structure:** -``` -src/tools/my-tool/ -├── manifest.ts # Tool metadata and schema -├── my-tool.ts # Implementation -├── icon.svg.ts # SVG icon -└── index.ts # Collection export -``` - -**Example: manifest.ts** -```typescript -import { ToolDefinition } from "@vertesia/tools-sdk"; - -export default { - name: "my-tool", - description: "Description of what this tool does", - input_schema: { - type: "object", - properties: { - param1: { - type: "string", - description: "First parameter" - } - }, - required: ["param1"] - } -} satisfies ToolDefinition; -``` - -**Example: my-tool.ts** -```typescript -import { Tool, ToolExecutionContext } from "@vertesia/tools-sdk"; -import manifest from "./manifest.js"; - -interface MyToolParams { - param1: string; -} - -async function execute( - params: MyToolParams, - context: ToolExecutionContext -): Promise { - // Tool implementation - return `Processed: ${params.param1}`; -} - -export const MyTool = { - ...manifest, - run: execute -} satisfies Tool; -``` - -**Example: index.ts** -```typescript -import { ToolCollection } from "@vertesia/tools-sdk"; -import { MyTool } from "./my-tool.js"; -import icon from "./icon.svg.js"; - -export const MyTools = new ToolCollection({ - name: "my-tool", - title: "My Tools", - description: "Description of the tool collection", - icon, - tools: [MyTool] -}); -``` - -**Register the collection:** - -Add to `src/tools/index.ts`: -```typescript -import { MyTools } from "./my-tool/index.js"; - -export const tools = [ - MyTools, - // ... other collections -]; -``` - -### 2. Creating a Skill - -Skills are AI capabilities defined as markdown prompts. - -**Structure:** -``` -src/skills/my-skill/ -├── SKILL.md # Skill definition -└── helper.py # Optional helper script -``` - -**Example: SKILL.md** -```markdown ---- -name: my-skill -title: My Skill -keywords: keyword1, keyword2, keyword3 -tools: tool1, tool2 -packages: package1==1.0.0 ---- - -# My Skill - -You are an AI assistant with expertise in [domain]. - -## Instructions - -1. First instruction -2. Second instruction -3. Third instruction - -## Guidelines - -- Guideline 1 -- Guideline 2 -``` - -**Register the collection:** - -Create `src/skills/my-skill/index.ts`: -```typescript -import { SkillCollection, loadSkillsFromDirectory } from "@vertesia/tools-sdk"; - -export const MySkills = new SkillCollection({ - name: "my-skill", - title: "My Skills", - description: "Description of the skill collection", - skills: loadSkillsFromDirectory(new URL(".", import.meta.url).pathname) -}); -``` - -Add to `src/skills/index.ts`: -```typescript -import { MySkills } from "./my-skill/index.js"; - -export const skills = [ - MySkills, - // ... other collections -]; -``` - -### 3. Creating an Interaction - -Interactions are multi-step workflows with templated prompts. - -**Structure:** -``` -src/interactions/my-interaction/ -└── my_workflow/ - ├── prompt.jst # JavaScript template string - └── index.ts # Interaction spec -``` - -**Example: prompt.jst** -```javascript -return ` -# Task: ${taskName} - -## Parameters -- **Input**: ${input} -- **Options**: ${JSON.stringify(options)} - -## Instructions - -Please process the following according to the parameters above. - -${additionalInstructions || 'No additional instructions.'} -`; -``` - -**Example: index.ts** -```typescript -import { PromptRole } from "@llumiverse/common"; -import { InteractionSpec, TemplateType } from "@vertesia/common"; -import PROMPT_CONTENT from "./prompt.jst?raw"; - -export default { - name: "my_workflow", - title: "My Workflow", - description: "Description of what this interaction does", - result_schema: { - type: "object", - properties: { - result: { - type: "string", - description: "The workflow result" - } - }, - required: ["result"] - }, - prompts: [{ - role: PromptRole.user, - content: PROMPT_CONTENT, - content_type: TemplateType.jst, - schema: { - type: "object", - properties: { - taskName: { type: "string" }, - input: { type: "string" }, - options: { type: "object" }, - additionalInstructions: { type: "string" } - }, - required: ["taskName", "input"] - } - }], - tags: ["tag1", "tag2"] -} satisfies InteractionSpec; -``` - -**Register the collection:** - -Create `src/interactions/my-interaction/index.ts`: -```typescript -import { InteractionCollection } from "@vertesia/tools-sdk"; -import myWorkflow from "./my_workflow/index.js"; -import icon from "./icon.svg.js"; - -export const MyInteractions = new InteractionCollection({ - name: "my-interaction", - title: "My Interactions", - description: "Description of the interaction collection", - icon, - interactions: [myWorkflow] -}); -``` - -Add to `src/interactions/index.ts`: -```typescript -import { MyInteractions } from "./my-interaction/index.js"; - -export async function loadInteractions() { - return [ - MyInteractions, - // ... other collections - ]; -} -``` - -## API Reference - -### Endpoints - -#### `GET /api` -Returns descriptions of all available tools, skills, and interactions. - -**Response:** -```json -{ - "tools": [...], - "skills": [...], - "interactions": [...] -} -``` - -#### `POST /api` -Executes a tool with the provided payload. - -**Request Body:** -```json -{ - "tool_name": "calculator", - "tool_input": { - "expression": "2 + 2" - }, - "context": { - "serverUrl": "http://localhost:5174", - "storeUrl": "http://store.example.com", - "apikey": "your-api-key" - }, - "vars": {} -} -``` - -**Response:** -```json -{ - "is_error": false, - "content": "Result: 2 + 2 = 4" -} -``` - -## Deployment - -The template supports **two deployment modes**: - -### 1. Vercel Functions (Serverless) - -Best for: Auto-scaling, zero-config deployment - -```bash -# Install Vercel CLI -npm i -g vercel - -# Deploy -vercel -``` - -The `api/index.js` adapter automatically converts your Hono server to Vercel Functions format. - -### 2. Node.js HTTP Server - -Best for: Cloud Run, Railway, Fly.io, Docker, VPS - -The template includes `src/server-node.ts` which creates a standalone HTTP server. - -**Deploy to Cloud Run:** -```bash -gcloud run deploy tool-server \ - --source . \ - --platform managed \ - --region us-central1 -``` - -**Deploy to Railway:** -1. Connect your repo -2. Railway auto-detects Node.js -3. Uses `npm start` automatically - -**Deploy to Docker:** -```dockerfile -FROM node:24-alpine -WORKDIR /app -COPY package*.json ./ -RUN npm install --production -COPY . . -RUN npm run build -EXPOSE 3000 -CMD ["npm", "start"] -``` - -**Deploy to VPS:** -```bash -# On your server -git clone -cd tool-server-template -npm install -npm run build -npm start - -# Or use PM2 for process management -npm i -g pm2 -pm2 start lib/server-node.js --name tool-server -``` - -### Other Platforms - -Hono's flexibility allows deployment to: -- Cloudflare Workers -- Deno Deploy -- AWS Lambda (with adapter) -- Bun -- Azure Functions - -See [Hono documentation](https://hono.dev/getting-started/basic) for platform-specific guides. - -## Configuration - -### Customization - -Edit `src/server.ts` to customize: - -```typescript -const server = createToolServer({ - title: 'Your Server Name', - description: 'Your server description', - prefix: '/api', - tools, - interactions, - skills, - mcpProviders: [] // Add MCP providers here -}); -``` - -### Environment Variables - -For GitHub integration or other sensitive config, use environment variables: - -1. Create `.env` file: -```bash -GITHUB_APP_ID=your-app-id -GITHUB_APP_PRIVATE_KEY_FILE=path/to/key.pem -``` - -2. Access in code: -```typescript -const githubAppId = process.env.GITHUB_APP_ID; -``` - -## Browser Bundles - -After building, browser-ready bundles are available at: -``` -dist/libs/tool-server-{collection-name}.js -``` - -Use them in the browser: -```html - -``` - -## Debugging - -This template includes full VSCode debugging support with breakpoints, watch expressions, and call stack inspection. - -**Quick start:** -1. Press **F5** in VSCode -2. Select "Debug Server" -3. Set breakpoints in your TypeScript files -4. Make API requests to trigger breakpoints - -For complete debugging workflows, configurations, and troubleshooting, see **[.vscode/README.md](.vscode/README.md)**. - -## Development Tips - -1. **Watch Mode**: `npm run dev` automatically rebuilds and restarts on file changes -2. **Type Safety**: Use `satisfies` to ensure type correctness while preserving inference -3. **Raw Imports**: Use `import content from './file.jst?raw'` for large template strings -4. **Debugging**: See [.vscode/README.md](.vscode/README.md) for VSCode debugging setup -5. **Testing Tools**: Use `POST /api` with curl or Postman to test tools - -### Testing with curl - -**Test a tool:** -```bash -curl -H "Authorization: Bearer {{VERTESIA_JWT}}" \ - -H "Content-Type: application/json" \ - -X POST "http://localhost:3000/api/tools/calculator" \ - -d '{ - "tool_use": { - "id": "run1", - "tool_name": "calculator", - "tool_input": {"expression": "10 * 5"} - } - }' -``` - -**Get interaction details:** -```bash -curl -H "Authorization: Bearer {{VERTESIA_JWT}}" \ - "http://localhost:3000/api/interactions/summarize/text_summarizer" -``` - -**Get skill details:** -```bash -curl -H "Authorization: Bearer {{VERTESIA_JWT}}" \ - "http://localhost:3000/api/skills/code-review/skill_code-review" -``` - -Replace `{{VERTESIA_JWT}}` with a valid Vertesia JWT token. - -## Troubleshooting - -### Build fails with module errors -- Ensure all imports use `.js` extensions (ESM requirement) -- Check that `tsconfig.json` has `"module": "ES2022"` -- Verify `@rollup/plugin-typescript` is installed - -### Dev server not starting -- Make sure `concurrently` is installed -- Check that dependencies are installed (`npm install`) -- If still having issues, try `npm run build` manually first - -### Tool execution errors -- Check tool implementation returns a string -- Verify input_schema matches the parameters -- Check console for detailed error messages - -## License - -MIT - -## Contributing - -Contributions are welcome! Please feel free to submit issues or pull requests. - ---- - -Built with ❤️ using [Hono](https://hono.dev/) and [@vertesia/tools-sdk](https://github.com/vertesia/tools-sdk) diff --git a/templates/tool-server-template/TESTING.md b/templates/tool-server-template/TESTING.md deleted file mode 100644 index 85ac4ae28..000000000 --- a/templates/tool-server-template/TESTING.md +++ /dev/null @@ -1,480 +0,0 @@ -# Testing Guide - -This guide shows you how to test your tool server locally before deploying. - -## Prerequisites - -Install dependencies: -```bash -pnpm install -# or -npm install -``` - -Note: The `dev` command automatically builds on first run, so you can skip the manual build step unless you specifically need production builds. - -## Testing Methods - -The template uses **Node.js HTTP server** for both development and production - ensuring you test the exact same code that runs in production (Cloud Run, Railway, etc.). - -### Development Mode (with auto-rebuild) - -This mode watches for file changes and automatically rebuilds and restarts the server: - -```bash -pnpm dev -# or -npm run dev -``` - -**What happens:** -- Terminal 1: Rollup watch mode rebuilds TypeScript on file changes -- Terminal 2: Node.js --watch restarts server when lib/ changes -- Both run via `concurrently` in a single command - -**Server runs on:** http://localhost:3000 (default) - -**Test the API:** -```bash -# List all tools/skills/interactions -curl http://localhost:3000/api - -# Execute calculator tool -curl -X POST http://localhost:3000/api \ - -H "Content-Type: application/json" \ - -d '{ - "tool_name": "calculator", - "tool_input": {"expression": "10 * 5 + 2^3"}, - "context": {"serverUrl": "http://localhost:3000"} - }' -``` - -**Browse the UI:** -- Main page: http://localhost:3000/ -- Calculator tool: http://localhost:3000/tools/calculator -- Code review skill: http://localhost:3000/skills/code-review -- Summarize interaction: http://localhost:3000/interactions/summarize - -**Custom port:** -```bash -PORT=8080 pnpm dev -``` - -### Production Mode - -This is identical to how it runs in Cloud Run, Railway, etc. - -**Step 1: Build** -```bash -pnpm build -# or -npm run build -``` - -**Step 2: Start the server** -```bash -pnpm start -# or -npm start -``` - -Server will start on **http://localhost:3000** (default) - -### Manual Control (Advanced) - -If you want separate control of build and server processes: - -```bash -# Terminal 1: Rollup watch mode -pnpm run build:watch - -# Terminal 2: Node with auto-restart -pnpm run start:watch -``` - -## Testing Individual Resources - -### 1. Testing the Calculator Tool - -Replace `{{VERTESIA_JWT}}` with a valid Vertesia JWT token in all examples below. - -**Basic calculation:** -```bash -curl -H "Authorization: Bearer {{VERTESIA_JWT}}" \ - -H "Content-Type: application/json" \ - -X POST "http://localhost:3000/api/tools/calculator" \ - -d '{ - "tool_use": { - "id": "run1", - "tool_name": "calculator", - "tool_input": {"expression": "2 + 2"} - } - }' -``` - -Expected response: -```json -{ - "is_error": false, - "content": "Result: 2 + 2 = 4" -} -``` - -**Complex expression:** -```bash -curl -H "Authorization: Bearer {{VERTESIA_JWT}}" \ - -H "Content-Type: application/json" \ - -X POST "http://localhost:3000/api/tools/calculator" \ - -d '{ - "tool_use": { - "id": "run2", - "tool_name": "calculator", - "tool_input": {"expression": "(10 + 5) * 2 - 3^2"} - } - }' -``` - -**Error handling:** -```bash -curl -H "Authorization: Bearer {{VERTESIA_JWT}}" \ - -H "Content-Type: application/json" \ - -X POST "http://localhost:3000/api/tools/calculator" \ - -d '{ - "tool_use": { - "id": "run3", - "tool_name": "calculator", - "tool_input": {"expression": "invalid"} - } - }' -``` - -Expected response: -```json -{ - "is_error": true, - "content": "Calculation error: Failed to evaluate expression: ..." -} -``` - -### 2. Testing Skills - -Skills are prompt templates, so you can't "execute" them via the API. Instead, retrieve their details: - -**Get skill details:** -```bash -curl -H "Authorization: Bearer {{VERTESIA_JWT}}" \ - "http://localhost:3000/api/skills/code-review/skill_code-review" -``` - -**List all skills:** -```bash -curl -H "Authorization: Bearer {{VERTESIA_JWT}}" \ - "http://localhost:3000/api" | jq '.skills' -``` - -**View skill in browser:** -- http://localhost:3000/skills/code-review - -### 3. Testing Interactions - -Interactions define workflows with templated prompts. - -**Get interaction details:** -```bash -curl -H "Authorization: Bearer {{VERTESIA_JWT}}" \ - "http://localhost:3000/api/interactions/summarize/text_summarizer" -``` - -**List all interactions:** -```bash -curl -H "Authorization: Bearer {{VERTESIA_JWT}}" \ - "http://localhost:3000/api" | jq '.interactions' -``` - -**Browse interaction page:** -- http://localhost:3000/interactions/summarize - -## Testing Vercel Deployment Locally - -If you want to test the Vercel deployment setup locally: - -```bash -# Install Vercel CLI -npm i -g vercel - -# Run Vercel dev server -pnpm start:vercel -# or -vercel dev -``` - -This simulates the Vercel serverless function environment. - -## Common Test Scenarios - -### Test 1: Server Health Check -```bash -# Should return 200 with list of tools/skills/interactions -curl -i http://localhost:3000/api -``` - -### Test 2: Invalid Tool Name -```bash -curl -H "Authorization: Bearer {{VERTESIA_JWT}}" \ - -H "Content-Type: application/json" \ - -X POST "http://localhost:3000/api/tools/nonexistent" \ - -d '{ - "tool_use": { - "id": "test1", - "tool_name": "nonexistent", - "tool_input": {} - } - }' -``` - -Should return an error response. - -### Test 3: Missing Required Parameters -```bash -curl -H "Authorization: Bearer {{VERTESIA_JWT}}" \ - -H "Content-Type: application/json" \ - -X POST "http://localhost:3000/api/tools/calculator" \ - -d '{ - "tool_use": { - "id": "test2", - "tool_name": "calculator", - "tool_input": {} - } - }' -``` - -Should return validation error about missing `expression`. - -### Test 4: HTML Pages Generated -```bash -# Check main index page exists -curl -s http://localhost:3000/ | grep -q "Tool Server Template" -echo $? # Should output 0 (success) - -# Check calculator tool page -curl -s http://localhost:3000/tools/calculator | grep -q "Calculator Tools" -echo $? # Should output 0 -``` - -### Test 5: Browser Bundles Created -```bash -# Check that browser bundle exists -ls -lh dist/libs/tool-server-calculator.js - -# Should show the bundled file with size -``` - -## Debugging Tips - -### Enable Detailed Logging - -Add to your `.env` file: -```bash -DEBUG=* -NODE_ENV=development -``` - -### Check Build Output - -After building, verify structure: -```bash -tree lib/ -L 3 -``` - -Expected structure: -``` -lib/ -├── server.js -├── server-node.js -├── build-site.js -├── index.html -├── tools/ -│ └── calculator/ -│ ├── index.html -│ ├── index.js -│ └── ... -├── skills/ -│ └── code-review/ -│ ├── index.html -│ └── SKILL.md -├── interactions/ -│ └── summarize/ -│ └── ... -└── libs/ - └── tool-server-calculator.js -``` - -### Check TypeScript Compilation - -Verify no TypeScript errors: -```bash -pnpm exec tsc --noEmit -``` - -### Watch Rollup Build Process - -See what Rollup is doing: -```bash -pnpm run build:watch -# Watch the console for build errors -``` - -### Test with Different Ports - -```bash -# Test port binding -PORT=8080 pnpm start -PORT=9000 pnpm start -``` - -### Debug Raw Imports - -If `?raw` imports aren't working, check: -```bash -# Verify the rawPlugin is in rollup.config.js -grep -A 10 "rawPlugin" rollup.config.js - -# Check the imported file exists -ls -la src/interactions/*/prompts/*.jst -``` - -## Performance Testing - -### Simple Load Test with Apache Bench - -```bash -# Install apache bench (usually pre-installed on Mac/Linux) -# On Mac: already available -# On Ubuntu: apt-get install apache2-utils - -# Test GET /api endpoint -ab -n 1000 -c 10 http://localhost:3000/api - -# Test POST /api endpoint (save request to file first) -cat > post_data.json < { - const dir = path.join(libToolCollectionsDir, name); - return ( - fs.statSync(dir).isDirectory() && - fs.existsSync(path.join(dir, 'index.js')) - ); - }) - : []; - -// Create a bundle configuration for each tool collection -const browserBundles = entries.map((name) => ({ - input: path.join(libToolCollectionsDir, name, 'index.js'), - output: { - file: path.join(outputDir, `tool-server-${name}.js`), - format: 'es', - sourcemap: true, - inlineDynamicImports: true - }, - plugins: [ - nodeResolve({ - browser: true, - preferBuiltins: false - }), - json(), - commonjs(), - terser({ - compress: { - drop_console: false - } - }) - ] -})); - -export default browserBundles; diff --git a/templates/tool-server-template/rollup.config.js b/templates/tool-server-template/rollup.config.js deleted file mode 100644 index 2b75fb0af..000000000 --- a/templates/tool-server-template/rollup.config.js +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Rollup Configuration for Server Build - * - * This configuration handles: - * 1. TypeScript compilation (src → lib) with preserveModules - * 2. Import transformations via @vertesia/build-tools - * - Raw file imports (?raw) for template files - * - Skill imports (?skill) for markdown skill definitions - * - * Browser bundles are in rollup.config.browser.js - */ -import json from '@rollup/plugin-json'; -import typescript from '@rollup/plugin-typescript'; -import { vertesiaImportPlugin, skillTransformer, rawTransformer, skillCollectionTransformer, promptTransformer } from '@vertesia/build-tools'; - -// ============================================================================ -// Exit Plugin - Forces process exit after build completes -// This prevents TypeScript plugin from keeping the process alive -// ============================================================================ -function exitPlugin() { - return { - name: 'force-exit', - closeBundle() { - console.log('Tool server build Done'); - // Force exit after bundle completes to prevent hanging - setImmediate(() => process.exit(0)); - } - }; -} - -// ============================================================================ -// Server Build Configuration (TypeScript → JavaScript) -// ============================================================================ -const serverBuild = { - input: { - server: './src/server.ts', - 'server-node': './src/server-node.ts', - 'build-site': './src/build-site.ts' - }, - output: { - dir: 'lib', - format: 'es', - sourcemap: true, - preserveModules: true, - preserveModulesRoot: 'src', - entryFileNames: '[name].js' - }, - external: (id) => { - // Keep relative imports as part of the bundle - if (id.startsWith('.') || id.startsWith('/')) { - return false; - } - // Externalize all node modules and absolute imports - return true; - }, - plugins: [ - vertesiaImportPlugin({ - transformers: [ - skillTransformer, // Handles .md?skill imports - skillCollectionTransformer, // Handles .?skills imports - promptTransformer, // Handles ?prompt imports - rawTransformer // Handles ?raw imports - ], - assetsDir: './dist', - widgetConfig: { - minify: false, - tsconfig: './tsconfig.widgets.json' - } - - }), - typescript({ - tsconfig: './tsconfig.json', - declaration: true, - declarationDir: 'lib', - sourceMap: true - }), - json(), - exitPlugin() - ] -}; - -// ============================================================================ -// Export server build configuration only -// ============================================================================ -export default serverBuild; diff --git a/templates/tool-server-template/rollup.config.widgets.js b/templates/tool-server-template/rollup.config.widgets.js deleted file mode 100644 index 98315235a..000000000 --- a/templates/tool-server-template/rollup.config.widgets.js +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Rollup Configuration for Widget Bundles - * - * This configuration: - * 1. Finds all .tsx files in src/skills/ directories (next to SKILL.md files) - * 2. Bundles each widget with all dependencies included - * 3. Outputs browser-ready ES modules flat to dist/widgets/ - * - * Output: dist/widgets/{widget-name}.js - */ -import commonjs from '@rollup/plugin-commonjs'; -import { nodeResolve } from '@rollup/plugin-node-resolve'; -import terser from '@rollup/plugin-terser'; -import typescript from '@rollup/plugin-typescript'; -import { globSync } from 'fs'; -import path from 'path'; - -const outputDir = './dist/widgets'; - -/** - * Find all .tsx files in skill directories using glob. - * Widgets are located next to SKILL.md files in skill directories. - * - * Structure: - * src/skills/ - * examples/ - * user-select/ - * SKILL.md ← Skill definition - * user-select.tsx ← Widget entry point - */ -function findWidgetEntryPoints() { - // Use glob to find all .tsx files in skill directories - const files = globSync('src/skills/**/*.tsx'); - - const widgets = files.map(file => ({ - name: path.basename(file, '.tsx'), - path: file - })); - - // Check for duplicate widget names - const nameMap = new Map(); - for (const widget of widgets) { - if (nameMap.has(widget.name)) { - const existing = nameMap.get(widget.name); - throw new Error( - `Duplicate widget name "${widget.name}" found:\n` + - ` - ${existing}\n` + - ` - ${widget.path}\n` + - `Widget names must be unique across all skills.` - ); - } - nameMap.set(widget.name, widget.path); - } - - return widgets; -} - -// Find all widget entry points -const widgets = findWidgetEntryPoints(); - -console.log(`Found ${widgets.length} widget(s):`, widgets.map(w => w.name).join(', ')); - -// Create a bundle configuration for each widget -const widgetBundles = widgets.map(({ name, path: widgetPath }) => ({ - input: widgetPath, - output: { - dir: outputDir, - entryFileNames: `${name}.js`, - format: 'es', - sourcemap: true, - inlineDynamicImports: true - }, - external: [ - // Externalize React dependencies - they should be provided by the host application - 'react', - 'react-dom', - 'react/jsx-runtime', - 'react/jsx-dev-runtime', - 'react-dom/client' - ], - plugins: [ - typescript({ - tsconfig: './tsconfig.widgets.json', - declaration: false, - sourceMap: true - }), - nodeResolve({ - browser: true, - preferBuiltins: false, - extensions: ['.tsx', '.ts', '.jsx', '.js'] - }), - commonjs(), - terser({ - compress: { - drop_console: false - } - }) - ] -})); - -// Rollup requires at least one config, so export empty config if no widgets found -export default widgetBundles.length > 0 ? widgetBundles : { - input: 'src/widgets/index.ts', // Dummy input - output: { - file: '/dev/null', // No output - format: 'es' - }, - plugins: [] -}; \ No newline at end of file diff --git a/templates/tool-server-template/src/build-site.ts b/templates/tool-server-template/src/build-site.ts deleted file mode 100644 index bba7f22e2..000000000 --- a/templates/tool-server-template/src/build-site.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { - indexPage, - interactionCollectionPage, - skillCollectionPage, - toolCollectionPage -} from "@vertesia/tools-sdk"; -import { copyFileSync, mkdirSync, writeFileSync } from "node:fs"; -import { glob } from "node:fs/promises"; -import { basename } from "node:path"; -import { ServerConfig } from "./config.js"; -import { skills } from "./skills/index.js"; -import { tools } from "./tools/index.js"; - -/** - * Generates static HTML pages for all tool collections, skills, and interactions - * This runs during the build process to create browsable documentation - */ -async function build(outDir: string) { - console.log(`Building static site to ${outDir}...`); - - // Ensure output directory exists - mkdirSync(outDir, { recursive: true }); - - // Load interactions - const interactions = ServerConfig.interactions; - - // Create main index page - console.log('Creating index page...'); - writeFile( - `${outDir}/index.html`, - indexPage(ServerConfig) - ); - - // Create pages for each tool collection - console.log(`Creating ${tools.length} tool collection pages...`); - for (const coll of tools) { - const dir = `${outDir}/tools/${coll.name}`; - mkdirSync(dir, { recursive: true }); - writeFile( - `${dir}/index.html`, - toolCollectionPage(coll) - ); - } - - // Create pages for each skill collection - console.log(`Creating ${skills.length} skill collection pages...`); - for (const coll of skills) { - const dir = `${outDir}/skills/${coll.name}`; - mkdirSync(dir, { recursive: true }); - writeFile( - `${dir}/index.html`, - skillCollectionPage(coll) - ); - } - - // Create pages for each interaction collection - console.log(`Creating ${interactions.length} interaction collection pages...`); - for (const coll of interactions) { - const dir = `${outDir}/interactions/${coll.name}`; - mkdirSync(dir, { recursive: true }); - writeFile( - `${dir}/index.html`, - interactionCollectionPage(coll) - ); - } - - console.log('✓ Static site build complete!'); -} - -/** - * Find and copy all scripts (.js, .py) from skill directories to dist/scripts (flat) - * Uses glob to find: src/skills/*-slash-*-slash-*.{py,js} - */ -async function copyScriptsFromSkills(outputDir: string): Promise { - // Ensure output directory exists - mkdirSync(outputDir, { recursive: true }); - - // Find all .py and .js files in skill directories - const scriptFiles = glob('src/skills/*/*/*.{py,js}'); - - // Check for duplicate script names - const nameMap = new Map(); - const filesToCopy: { file: string; name: string }[] = []; - - for await (const file of scriptFiles) { - const name = basename(file); - if (nameMap.has(name)) { - const existing = nameMap.get(name)!; - throw new Error( - `Duplicate script name "${name}" found:\n` + - ` - ${existing}\n` + - ` - ${file}\n` + - `Script names must be unique across all skills.` - ); - } - nameMap.set(name, file); - filesToCopy.push({ file, name }); - } - - // Copy all scripts - for (const { file, name } of filesToCopy) { - const destPath = `${outputDir}/${name}`; - copyFileSync(file, destPath); - } - - return filesToCopy.length; -} - -function writeFile(file: string, content: string) { - writeFileSync(file, content.trim() + '\n', "utf8"); -} - -// Run the build -const outDir = process.argv[2] || './dist'; -build(outDir) - .then(() => { - process.exit(0); - }) - .catch(error => { - console.error('Build failed:', error); - process.exit(1); - }); diff --git a/templates/tool-server-template/src/config.ts b/templates/tool-server-template/src/config.ts deleted file mode 100644 index bb5d1c1d1..000000000 --- a/templates/tool-server-template/src/config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ToolServerConfig } from "@vertesia/tools-sdk"; -import { interactions } from "./interactions/index.js"; -import { mcpProviders } from "./mcp/index.js"; -import { skills } from "./skills/index.js"; -import { tools } from "./tools/index.js"; - -const CONFIG__SERVER_TITLE = "Tool Server Template"; -export const ServerConfig = { - hideUILinks: true, - disableHtml: true, - title: CONFIG__SERVER_TITLE, - prefix: '/api', - tools, - interactions, - skills, - mcpProviders -} satisfies ToolServerConfig; diff --git a/templates/tool-server-template/src/imports.d.ts b/templates/tool-server-template/src/imports.d.ts deleted file mode 100644 index 251767391..000000000 --- a/templates/tool-server-template/src/imports.d.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * TypeScript declarations for special import types - */ - -// Skill imports - markdown files with ?skill suffix -declare module '*.md?skill' { - import type { SkillDefinition } from '@vertesia/build-tools'; - const skill: SkillDefinition; - export default skill; -} - -// Skill imports - markdown files with ?skill suffix -declare module '*/SKILL.md' { - import type { SkillDefinition } from '@vertesia/build-tools'; - const skill: SkillDefinition; - export default skill; -} - -// Skill collection imports - any file with ?skills suffix -declare module '*?skills' { - import type { SkillDefinition } from '@vertesia/build-tools'; - const skills: SkillDefinition[]; - export default skills; -} - -// Raw imports - any file with ?raw suffix -declare module '*?raw' { - const content: string; - export default content; -} - -// Prompt imports - template files with ?prompt suffix -declare module '*?prompt' { - import type { PromptDefinition } from '@vertesia/build-tools'; - const prompt: PromptDefinition; - export default prompt; -} diff --git a/templates/tool-server-template/src/interactions/examples/icon.svg.ts b/templates/tool-server-template/src/interactions/examples/icon.svg.ts deleted file mode 100644 index 73809688f..000000000 --- a/templates/tool-server-template/src/interactions/examples/icon.svg.ts +++ /dev/null @@ -1,7 +0,0 @@ -export default ` - - - - - -`; diff --git a/templates/tool-server-template/src/interactions/examples/index.ts b/templates/tool-server-template/src/interactions/examples/index.ts deleted file mode 100644 index 0ee7f8a55..000000000 --- a/templates/tool-server-template/src/interactions/examples/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { InteractionCollection } from "@vertesia/tools-sdk"; -import whatColor from "./what_color/index.js"; -import icon from "./icon.svg.js"; - -export const ExampleInteractions = new InteractionCollection({ - name: "examples", - title: "Example Interactions", - description: "A collection of interaction examples", - icon, - interactions: [whatColor] -}); diff --git a/templates/tool-server-template/src/interactions/examples/what_color/index.ts b/templates/tool-server-template/src/interactions/examples/what_color/index.ts deleted file mode 100644 index c2e7972ff..000000000 --- a/templates/tool-server-template/src/interactions/examples/what_color/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { InteractionSpec } from "@vertesia/common"; -import PROMPT from "./prompt.hbs?prompt"; -import result_schema from "./result_schema"; - -export default { - name: "what_color", - title: "What Color", - description: "Identifies the color of a specified object.", - result_schema, - prompts: [PROMPT], - tags: ["text", "summarization", "nlp", "content"] -} satisfies InteractionSpec; diff --git a/templates/tool-server-template/src/interactions/examples/what_color/prompt.hbs b/templates/tool-server-template/src/interactions/examples/what_color/prompt.hbs deleted file mode 100644 index 6192e23c8..000000000 --- a/templates/tool-server-template/src/interactions/examples/what_color/prompt.hbs +++ /dev/null @@ -1,6 +0,0 @@ ---- -role: user -content_type: handlebars -schema: ./prompt_schema.ts ---- -What color is {{object}}? diff --git a/templates/tool-server-template/src/interactions/examples/what_color/prompt_schema.ts b/templates/tool-server-template/src/interactions/examples/what_color/prompt_schema.ts deleted file mode 100644 index f50eac210..000000000 --- a/templates/tool-server-template/src/interactions/examples/what_color/prompt_schema.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { JSONSchema } from "@llumiverse/common"; - -export default { - type: "object", - properties: { - object: { - type: "string", - description: "The object to identify the color of" - }, - }, - required: ["object"] -} satisfies JSONSchema; diff --git a/templates/tool-server-template/src/interactions/examples/what_color/result_schema.ts b/templates/tool-server-template/src/interactions/examples/what_color/result_schema.ts deleted file mode 100644 index 11fd37d49..000000000 --- a/templates/tool-server-template/src/interactions/examples/what_color/result_schema.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { JSONSchema } from "@llumiverse/common"; - -export default { - type: "object", - properties: { - color: { - type: "string", - description: "The identified color of the object" - }, - object: { - type: "string", - description: "The object whose color was identified" - } - }, - required: ["color", "object"] -} satisfies JSONSchema; diff --git a/templates/tool-server-template/src/interactions/index.ts b/templates/tool-server-template/src/interactions/index.ts deleted file mode 100644 index 8677d352a..000000000 --- a/templates/tool-server-template/src/interactions/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ExampleInteractions } from "./examples/index.js"; - -export const interactions = [ - ExampleInteractions -]; diff --git a/templates/tool-server-template/src/mcp/MCPProvider.ts b/templates/tool-server-template/src/mcp/MCPProvider.ts deleted file mode 100644 index 1420c0a6b..000000000 --- a/templates/tool-server-template/src/mcp/MCPProvider.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { MCPConnectionDetails } from "@vertesia/tools-sdk"; -import { AuthSession } from "@vertesia/tools-sdk"; - - -export abstract class MCPProvider { - name: string; - description?: string; - constructor(name: string, description?: string) { - this.name = name; - this.description = description; - } - - /** - * Generate an authorization token to access the returned mcp server URL. the mcp server URL is returned in the generation payload since - * it may change depending on the session and config vars. - * @param session - * @param config - */ - abstract createMCPConnection(session: AuthSession, config: Record): Promise; - -} - - diff --git a/templates/tool-server-template/src/mcp/index.ts b/templates/tool-server-template/src/mcp/index.ts deleted file mode 100644 index e3da0a0b8..000000000 --- a/templates/tool-server-template/src/mcp/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { MCPProvider } from "./MCPProvider.js"; - -export const mcpProviders: MCPProvider[] = [ -] diff --git a/templates/tool-server-template/src/server-node.ts b/templates/tool-server-template/src/server-node.ts deleted file mode 100644 index 406135c7e..000000000 --- a/templates/tool-server-template/src/server-node.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Node.js HTTP Server Entry Point - * - * This file starts a standalone Node.js HTTP server using @hono/node-server. - * Use this for: - * - Local development and testing - * - Deploying to Cloud Run, Railway, Fly.io, etc. - * - Running in Docker containers - */ -import { serve } from '@hono/node-server'; -import { serveStatic } from '@hono/node-server/serve-static'; -import server from './server.js'; -import type { Context, Next } from 'hono'; - -const port = parseInt(process.env.PORT || '3000', 10); - -console.log(`Starting Tool Server on port ${port}...`); -console.log(`API endpoint: http://localhost:${port}/api`); -console.log(`Web UI: http://localhost:${port}/`); - -// Add static file serving for widgets, scripts, and other assets -const staticFile = serveStatic({ root: './dist' }); -server.all("*", async (c: Context, next: Next) => { - // Serve static resources from dist/ - return staticFile(c, next); -}); - -serve({ - fetch: server.fetch, - port -}, (info) => { - console.log(`✓ Server is running at http://localhost:${info.port}`); -}); diff --git a/templates/tool-server-template/src/server.ts b/templates/tool-server-template/src/server.ts deleted file mode 100644 index 42efc41cd..000000000 --- a/templates/tool-server-template/src/server.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createToolServer } from "@vertesia/tools-sdk"; -import { ServerConfig } from "./config.js"; - -// Create server using tools-sdk -const server = createToolServer(ServerConfig); - -export default server; diff --git a/templates/tool-server-template/src/skills/examples/index.ts b/templates/tool-server-template/src/skills/examples/index.ts deleted file mode 100644 index 53ca8568f..000000000 --- a/templates/tool-server-template/src/skills/examples/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { SkillCollection } from "@vertesia/tools-sdk"; -import skills from "./all?skills"; - -export const ExampleSkills = new SkillCollection({ - name: "examples", - title: "Example Skills", - description: "Example skills demonstrating various functionalities", - skills -}); diff --git a/templates/tool-server-template/src/skills/examples/user-select/SKILL.md b/templates/tool-server-template/src/skills/examples/user-select/SKILL.md deleted file mode 100644 index 203945d78..000000000 --- a/templates/tool-server-template/src/skills/examples/user-select/SKILL.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -name: user-select -title: User Selection -description: Present users with choices and collect their selections for interactive decision-making -keywords: [ select, choose, option, pick, decision, choice ] ---- - -# User Selection - -You are a user selection generator. Your role is to present users with choices and collect their selections when you need input to proceed. - -## Output Format - -When you need the user to select from options, output a code block with the `user-select` language identifier: - -```user-select -{ - "options": [ - {"text": "Option 1 display text", "value": "option1"}, - {"text": "Option 2 display text", "value": "option2"}, - {"text": "Option 3 display text", "value": "option3"} - ], - "multiple": false -} -``` - -## Field Specifications - -- **options** (required): Array of option objects, each containing: - - **text** (required): The display text shown to the user - - **value** (required): The value returned when this option is selected -- **multiple** (optional): Boolean, defaults to false. Set to true to allow multiple selections - -## Guidelines - -1. **Option Design** - - Keep option text clear and concise (1-8 words) - - Use meaningful values that represent the choice - - Provide 2-10 options for best usability - - Ensure text and values are unique within the options array - -2. **Single vs Multiple Selection** - - Use single selection (multiple: false or omit) for mutually exclusive choices - - Use multiple selection (multiple: true) when users can choose several options - - Single selection is the default behavior - -3. **When to Use** - - When you need user input to determine next steps - - To let users choose between different approaches or solutions - - To configure settings or preferences - - To select from a predefined list of items or actions - - When binary yes/no is insufficient and you need more nuanced choices - -## Examples - -### Simple Single Choice -```user-select -{ - "options": [ - {"text": "Continue with deployment", "value": "deploy"}, - {"text": "Review changes first", "value": "review"}, - {"text": "Cancel operation", "value": "cancel"} - ] -} -``` - -### Multiple Selection -```user-select -{ - "options": [ - {"text": "Generate unit tests", "value": "unit-tests"}, - {"text": "Generate integration tests", "value": "integration-tests"}, - {"text": "Add documentation", "value": "docs"}, - {"text": "Update README", "value": "readme"} - ], - "multiple": true -} -``` - -### Environment Selection -```user-select -{ - "options": [ - {"text": "Development environment", "value": "dev"}, - {"text": "Staging environment", "value": "staging"}, - {"text": "Production environment", "value": "prod"} - ] -} -``` - -### Feature Selection -```user-select -{ - "options": [ - {"text": "Dark mode support", "value": "dark-mode"}, - {"text": "Offline functionality", "value": "offline"}, - {"text": "Push notifications", "value": "notifications"}, - {"text": "Analytics integration", "value": "analytics"} - ], - "multiple": true -} -``` - -## Usage Tips - -- Always provide clear, descriptive text for each option -- Use values that make sense programmatically (kebab-case or snake_case recommended) -- Consider the context when deciding single vs multiple selection -- After the user makes a selection, acknowledge their choice and proceed accordingly -- Don't overuse - only present selections when user input is genuinely needed diff --git a/templates/tool-server-template/src/skills/examples/user-select/user-select.tsx b/templates/tool-server-template/src/skills/examples/user-select/user-select.tsx deleted file mode 100644 index 0f92dabd3..000000000 --- a/templates/tool-server-template/src/skills/examples/user-select/user-select.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import React from 'react'; - -const { useState } = React; - -/** - * Option structure for user selection - */ -interface SelectOption { - text: string; - value: string; -} - -/** - * User selection data structure - */ -interface UserSelectData { - options: SelectOption[]; - multiple?: boolean; -} - -/** - * Props for the UserSelectWidget component - */ -interface UserSelectWidgetProps { - /** - * The selection data as a JSON string or parsed object - */ - code: string | UserSelectData; -} - -/** - * Interactive user selection widget component - * - * This widget renders selection options for users to choose from. - * It supports both single and multiple selection modes. - */ -export default function UserSelectWidget(props: UserSelectWidgetProps) { - const [selected, setSelected] = useState>(new Set()); - const [submitted, setSubmitted] = useState(false); - - // Parse the data - handle both string and object inputs - let data: UserSelectData; - try { - if (typeof props.code === 'string') { - data = JSON.parse(props.code); - } else { - data = props.code; - } - } catch (error) { - return ( -
-

Error: Invalid selection data

-

Failed to parse selection JSON

-
- ); - } - - // Validate data structure - if (!data.options || !Array.isArray(data.options) || data.options.length === 0) { - return ( -
-

Error: Invalid selection structure

-

- Selection must have at least one option -

-
- ); - } - - // Validate each option has text and value - const invalidOption = data.options.find(opt => !opt.text || !opt.value); - if (invalidOption) { - return ( -
-

Error: Invalid option format

-

- Each option must have 'text' and 'value' fields -

-
- ); - } - - const isMultiple = data.multiple === true; - - const handleOptionClick = (value: string) => { - if (submitted) return; - - const newSelected = new Set(selected); - - if (isMultiple) { - // Multi-select: toggle the option - if (newSelected.has(value)) { - newSelected.delete(value); - } else { - newSelected.add(value); - } - } else { - // Single select: replace the selection - newSelected.clear(); - newSelected.add(value); - } - - setSelected(newSelected); - }; - - const handleSubmit = () => { - if (selected.size === 0) return; - setSubmitted(true); - - // Format the selected values for display - const selectedOptions = data.options.filter(opt => selected.has(opt.value)); - console.log('User selected:', isMultiple ? Array.from(selected) : Array.from(selected)[0]); - console.log('Selected options:', selectedOptions); - }; - - const handleReset = () => { - setSelected(new Set()); - setSubmitted(false); - }; - - return ( -
- {/* Header */} -
-

- {isMultiple ? 'Select Options' : 'Select an Option'} -

- {isMultiple && !submitted && ( -

- ℹ️ You can select multiple options -

- )} -
- - {/* Options */} -
- {data.options.map((option, index) => { - const isSelected = selected.has(option.value); - - return ( -
handleOptionClick(option.value)} - className={` - relative border rounded-lg p-3 transition-all - ${submitted - ? 'cursor-default' - : 'cursor-pointer hover:border-mixer-10 hover:bg-mixer-2' - } - ${isSelected && !submitted - ? 'border-info bg-info/10' - : 'border-mixer-5' - } - ${submitted && isSelected - ? 'border-success bg-success/10' - : '' - } - `} - > -
- {/* Selection indicator */} - {!submitted && ( -
- {isSelected && ( - - )} -
- )} - {submitted && isSelected && ( -
- -
- )} - - {option.text} - -
-
- ); - })} -
- - {/* Actions */} -
- {!submitted ? ( - <> - - {selected.size > 0 && ( - - )} - - ) : ( - <> -
- ✓ Selection confirmed -
- - - )} -
- - {/* Selected values display */} - {submitted && ( -
-

Selected value{isMultiple && selected.size > 1 ? 's' : ''}:

- - {isMultiple - ? JSON.stringify(Array.from(selected)) - : Array.from(selected)[0] - } - -
- )} -
- ); -} diff --git a/templates/tool-server-template/src/skills/index.ts b/templates/tool-server-template/src/skills/index.ts deleted file mode 100644 index cf59b98b7..000000000 --- a/templates/tool-server-template/src/skills/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ExampleSkills } from "./examples"; - -export const skills = [ - ExampleSkills -]; diff --git a/templates/tool-server-template/src/tools/examples/calculator/calculator.ts b/templates/tool-server-template/src/tools/examples/calculator/calculator.ts deleted file mode 100644 index bc3acd6cb..000000000 --- a/templates/tool-server-template/src/tools/examples/calculator/calculator.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Tool, ToolExecutionContext, ToolExecutionPayload } from "@vertesia/tools-sdk"; -import { type CalculatorParams } from "./schema.js"; -import { ToolResultContent } from "@vertesia/common"; - - -/** - * Safely evaluates a mathematical expression - * Supports: +, -, *, /, ^, parentheses, and decimal numbers - */ -function evaluateExpression(expr: string): number { - // Remove whitespace - expr = expr.replace(/\s+/g, ''); - - // Replace ^ with ** for exponentiation - expr = expr.replace(/\^/g, '**'); - - // Validate expression - only allow numbers, operators, parentheses, and decimal points - if (!/^[0-9+\-*/.()^]+$/.test(expr)) { - throw new Error('Invalid expression. Only numbers and operators (+, -, *, /, ^) are allowed.'); - } - - // Use Function constructor for safe evaluation (better than eval) - try { - const result = new Function(`'use strict'; return (${expr})`)(); - if (typeof result !== 'number' || !isFinite(result)) { - throw new Error('Result is not a valid number'); - } - return result; - } catch (error) { - throw new Error(`Failed to evaluate expression: ${error instanceof Error ? error.message : 'Unknown error'}`); - } -} - -export async function calculate( - payload: ToolExecutionPayload, - _context: ToolExecutionContext -): Promise { - try { - const { expression } = payload.tool_use.tool_input!; - const result = evaluateExpression(expression); - - return { - is_error: false, - content: `Result: ${expression} = ${result}` - } satisfies ToolResultContent; - } catch (error) { - return { - is_error: true, - content: `Calculation error: ${error instanceof Error ? error.message : 'Unknown error'}` - } satisfies ToolResultContent; - } -} diff --git a/templates/tool-server-template/src/tools/examples/calculator/index.ts b/templates/tool-server-template/src/tools/examples/calculator/index.ts deleted file mode 100644 index fe238a99f..000000000 --- a/templates/tool-server-template/src/tools/examples/calculator/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Tool } from "@vertesia/tools-sdk"; -import { calculate } from "./calculator.js"; -import { CalculatorParams, Schema } from "./schema.js"; - -export const CalculatorTool = { - name: "calculator", - description: "Performs basic mathematical calculations. Supports addition (+), subtraction (-), multiplication (*), division (/), and exponentiation (^).", - input_schema: Schema, - run: calculate -} satisfies Tool; diff --git a/templates/tool-server-template/src/tools/examples/calculator/schema.ts b/templates/tool-server-template/src/tools/examples/calculator/schema.ts deleted file mode 100644 index d7daaabe2..000000000 --- a/templates/tool-server-template/src/tools/examples/calculator/schema.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { JSONSchema } from "@llumiverse/common"; - -export interface CalculatorParams { - expression: string; -} - -export const Schema = { - type: "object", - properties: { - expression: { - type: "string", - description: "A mathematical expression to evaluate (e.g., '2 + 2', '10 * 5 - 3', '2^8')" - } - }, - required: ["expression"] -} satisfies JSONSchema; diff --git a/templates/tool-server-template/src/tools/examples/icon.svg.ts b/templates/tool-server-template/src/tools/examples/icon.svg.ts deleted file mode 100644 index 7b19d5989..000000000 --- a/templates/tool-server-template/src/tools/examples/icon.svg.ts +++ /dev/null @@ -1,7 +0,0 @@ -export default ` - - - - - -`; diff --git a/templates/tool-server-template/src/tools/examples/index.ts b/templates/tool-server-template/src/tools/examples/index.ts deleted file mode 100644 index f413f9e45..000000000 --- a/templates/tool-server-template/src/tools/examples/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ToolCollection } from "@vertesia/tools-sdk"; -import { CalculatorTool } from "./calculator/index.js"; -import icon from "./icon.svg.js"; - -export const ExampleTools = new ToolCollection({ - name: "examples", - title: "Example Tools", - description: "A collection of example tools", - icon, - tools: [CalculatorTool] -}); diff --git a/templates/tool-server-template/src/tools/index.ts b/templates/tool-server-template/src/tools/index.ts deleted file mode 100644 index 269d15021..000000000 --- a/templates/tool-server-template/src/tools/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ExampleTools } from "./examples/index.js"; - -export const tools = [ - ExampleTools -]; diff --git a/templates/tool-server-template/template.config.json b/templates/tool-server-template/template.config.json deleted file mode 100644 index f364aff97..000000000 --- a/templates/tool-server-template/template.config.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "version": "1.0", - "description": "A template for building custom tool servers with tools, skills, and interactions", - "prompts": [ - { - "type": "text", - "name": "PROJECT_NAME", - "message": "Project name", - "initial": "my-tool-server" - }, - { - "type": "text", - "name": "PROJECT_DESCRIPTION", - "message": "Project description", - "initial": "A custom tool server with tools, skills, and interactions" - } - ], - "derived": { - "SERVER_TITLE": { - "from": "PROJECT_NAME", - "transform": "titleCase" - } - }, - "files": [ - "src/config.ts" - ], - "removeAfterInstall": [ - "template.config.json" - ] -} \ No newline at end of file diff --git a/templates/tool-server-template/tsconfig.json b/templates/tool-server-template/tsconfig.json deleted file mode 100644 index cde4abe94..000000000 --- a/templates/tool-server-template/tsconfig.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2024", - "module": "ES2022", - "lib": [ - "ES2024" - ], - "moduleResolution": "bundler", - "resolveJsonModule": true, - "allowJs": true, - "checkJs": false, - "outDir": "./lib", - "rootDir": "./src", - "removeComments": true, - "sourceMap": true, - "declaration": true, - "declarationMap": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "strict": true, - "skipLibCheck": true, - "allowSyntheticDefaultImports": true, - "types": [ - "node" - ] - }, - "include": [ - "src/**/*" - ], - "exclude": [ - "node_modules", - "lib", - "dist", - "src/**/*.tsx" - ] -} \ No newline at end of file diff --git a/templates/tool-server-template/tsconfig.widgets.json b/templates/tool-server-template/tsconfig.widgets.json deleted file mode 100644 index f7ec500bd..000000000 --- a/templates/tool-server-template/tsconfig.widgets.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2024", - "module": "ES2022", - "lib": [ - "ES2024", - "DOM", - "DOM.Iterable" - ], - "jsx": "react-jsx", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "allowJs": true, - "rootDir": "src", - "outDir": "./dist/widgets", - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "strict": true, - "skipLibCheck": true, - "allowSyntheticDefaultImports": true, - "isolatedModules": true - }, - "include": [ - "src/skills/**/*.tsx" - ], - "exclude": [ - "node_modules", - "lib", - "dist" - ] -} \ No newline at end of file diff --git a/templates/tool-server-template/vercel.json b/templates/tool-server-template/vercel.json deleted file mode 100644 index 575b51682..000000000 --- a/templates/tool-server-template/vercel.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "buildCommand": "npm run build", - "outputDirectory": "dist", - "rewrites": [ - { - "source": "/api/(.*)", - "destination": "/api" - } - ], - "headers": [ - { - "source": "/api/(.*)", - "headers": [ - { - "key": "Access-Control-Allow-Origin", - "value": "*" - }, - { - "key": "Access-Control-Allow-Methods", - "value": "GET, POST, PUT, DELETE, OPTIONS" - }, - { - "key": "Access-Control-Allow-Headers", - "value": "Content-Type, Authorization" - } - ] - } - ] -} diff --git a/templates/ui-plugin-template/.env.local.template b/templates/ui-plugin-template/.env.local.template deleted file mode 100644 index 8ed666a60..000000000 --- a/templates/ui-plugin-template/.env.local.template +++ /dev/null @@ -1 +0,0 @@ -VITE_APP_NAME={{APP_ID}} diff --git a/templates/ui-plugin-template/.gitignore b/templates/ui-plugin-template/.gitignore deleted file mode 100644 index 6e30fd631..000000000 --- a/templates/ui-plugin-template/.gitignore +++ /dev/null @@ -1,23 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -lib -*.local - -# Editor directories and files -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/templates/ui-plugin-template/LICENSE b/templates/ui-plugin-template/LICENSE deleted file mode 100644 index 2642274ec..000000000 --- a/templates/ui-plugin-template/LICENSE +++ /dev/null @@ -1,13 +0,0 @@ -Copyright 2026 Vertesia - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. \ No newline at end of file diff --git a/templates/ui-plugin-template/README.md b/templates/ui-plugin-template/README.md deleted file mode 100644 index 909a3f894..000000000 --- a/templates/ui-plugin-template/README.md +++ /dev/null @@ -1,162 +0,0 @@ -# Vertesia Custom App Sample - -A sample project demonstrating how to build a custom app/plugin for the Vertesia platform using React, TypeScript, and Vite. - -## Overview - -This project serves as a template for building Vertesia plugins that can be integrated into the Vertesia platform. It includes: - -- React 19 with TypeScript for type-safe component development -- Tailwind CSS for styling -- Vite for fast development and optimized builds -- Dual build modes: standalone app and plugin library - -## Project Structure - -```txt -src/ -├── app.tsx # Main app component with router -├── plugin.tsx # Plugin entry point for Vertesia integration -├── routes.tsx # Application route definitions -├── pages.tsx # Page components -└── main.tsx # Dev mode entry point -``` - -## Prerequisites - -- An application manifest [created and installed](/apps/overview) in your Vertesia project - -## Getting Started - -### Installation - -```bash -pnpm install -``` - -Next, set the app Id in the `VITE_APP_NAME` variable in the `.env.local` file. - -### Development - -Run the app in development mode with hot module replacement: - -```bash -pnpm dev -``` - -The app will be available at `https://localhost:5173`. - -### Building - -Build both standalone app and plugin library: - -```bash -pnpm build -``` - -Or build individually: - -```bash -# Build standalone app -pnpm build:app - -# Build plugin library -pnpm build:lib -``` - -The plugin library will be output to the `dist/lib/` directory. - -## Deployment - -Since this is a standard web application, you can deploy it to any static hosting provider (Vercel, Netlify, Cloudflare Pages, AWS S3, etc.). - -### Deploying to Vercel - -Vercel is a practical deployment option with a generous free tier. You can very simply deploy your standalone app using the Vercel CLI. - -#### Setup - -Install the Vercel CLI globally: - -```bash -npm i -g vercel -``` - -#### Deployment Steps - -1. **Login to Vercel**: - - ```bash - vercel login - ``` - -2. **Deploy to preview**: - - ```bash - vercel - ``` - - This will create a preview deployment and provide you with a URL to test your app. - -3. **Deploy to production**: - - ```bash - vercel --prod - ``` - -For more information, visit the [Vercel CLI documentation](https://vercel.com/docs/cli). - -#### Update App Manifest with Deployment URL - -After deploying to Vercel, update your app manifest to point to the deployed URL using the vertesia CLI: - -```bash -vertesia apps update --manifest '{ - "name": "my-app", - "title": "My App", - "description": "A sample app", - "publisher": "your-org", - "private": true, - "status": "beta", - "ui": { - "src": "https://your-app.vercel.app/lib/plugin.js", - "isolation": "shadow" - } -}' -``` - -Replace `appId` by the actual ID and `https://your-app.vercel.app` with your actual Vercel deployment URL. - -## Key Features - -### Dual Build Modes - -- **App Mode**: Builds a standalone application for development and testing -- **Library Mode**: Builds a plugin that can be integrated into the Vertesia platform - -### External Dependencies - -When building as a plugin, React and Vertesia dependencies are externalized to prevent duplication: - -- `react` / `react-dom` -- `@vertesia/common` -- `@vertesia/ui` - -## Tech Stack - -- **React 19** - UI framework -- **TypeScript** - Type safety -- **Vite** - Build tool and dev server -- **Tailwind CSS 4** - Styling -- **@vertesia/ui** - Vertesia UI components -- **@vertesia/plugin-builder** - Plugin build utilities - -## Development Notes - -- The dev server uses HTTPS (via `@vitejs/plugin-basic-ssl`) -- CSS can be inlined in the plugin bundle or kept separate (configured in [vite.config.ts](vite.config.ts)) -- For debugging Vertesia UI sources, set `VERTESIA_UI_PATH` in [vite.config.ts](vite.config.ts) - -## License - -See package.json for license information. diff --git a/templates/ui-plugin-template/eslint.config.js b/templates/ui-plugin-template/eslint.config.js deleted file mode 100644 index d21df2c80..000000000 --- a/templates/ui-plugin-template/eslint.config.js +++ /dev/null @@ -1,29 +0,0 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' -import { defineConfig } from 'eslint/config'; - -export default defineConfig( - { ignores: ['dist'] }, - { - extends: [js.configs.recommended, ...tseslint.configs.recommended], - files: ['**/*.{ts,tsx}'], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - }, - plugins: { - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, - }, - rules: { - ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], - }, - }, -) diff --git a/templates/ui-plugin-template/index.html b/templates/ui-plugin-template/index.html deleted file mode 100644 index a3c0dc753..000000000 --- a/templates/ui-plugin-template/index.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - {{PLUGIN_TITLE}} - - - - -
- - - - \ No newline at end of file diff --git a/templates/ui-plugin-template/package.json b/templates/ui-plugin-template/package.json deleted file mode 100644 index 8cec4c771..000000000 --- a/templates/ui-plugin-template/package.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "name": "ui-plugin-template", - "version": "1.0.0", - "description": "A template for building custom UI plugins for Vertesia", - "type": "module", - "files": [ - "dist" - ], - "author": "Vertesia", - "license": "Apache-2.0", - "scripts": { - "dev": "vite dev", - "build:app": "vite build --mode app", - "build:lib": "vite build --mode lib", - "build": "vite build --mode app && vite build --mode lib", - "lint": "eslint .", - "preview": "vite preview" - }, - "peerDependencies": { - "react": "^19.0.0", - "react-dom": "^19.0.0" - }, - "devDependencies": { - "@eslint/js": "^10.0.1", - "@tailwindcss/forms": "^0.5.10", - "@tailwindcss/vite": "^4.1.18", - "@types/node": "^24.1.0", - "@types/react": "^19.1.6", - "@types/react-dom": "^19.1.5", - "@vertesia/plugin-builder": "workspace:*", - "@vitejs/plugin-basic-ssl": "^2.1.0", - "@vitejs/plugin-react": "^5.1.2", - "eslint": "^10.0.2", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.3.0", - "react": "^19.1.2", - "react-dom": "^19.1.2", - "tailwindcss": "^4.1.18", - "typescript": "^6.0.2", - "typescript-eslint": "^8.58.1", - "vite": "^7.3.0", - "vite-plugin-serve-static": "^1.2.0" - }, - "dependencies": { - "@vertesia/common": "workspace:*", - "@vertesia/ui": "workspace:*" - } -} \ No newline at end of file diff --git a/templates/ui-plugin-template/src/app.tsx b/templates/ui-plugin-template/src/app.tsx deleted file mode 100644 index be6d02a32..000000000 --- a/templates/ui-plugin-template/src/app.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { NestedRouterProvider } from "@vertesia/ui/router"; -import { routes } from "./routes"; - -export function App() { - return ( - - ) -} \ No newline at end of file diff --git a/templates/ui-plugin-template/src/assets.ts b/templates/ui-plugin-template/src/assets.ts deleted file mode 100644 index 01fdc7806..000000000 --- a/templates/ui-plugin-template/src/assets.ts +++ /dev/null @@ -1,26 +0,0 @@ - -let _usePluginAssets = true; - -export function setUsePluginAssets(usePluginAssets: boolean) { - _usePluginAssets = usePluginAssets -} - -/** - * Correctly resolve the URL to an asset so that it works in dev mode but also in prod as a standalone app or plugin. - * Assets must be put inside /public/assets folder so the given `path` will be resolved as /assets/path in the right context - * @param path - */ -export function useAsset(path: string) { - if (path.startsWith('/')) { - path = path.substring(1); - } else if (path.startsWith('./')) { - path = path.substring(2); - } - if (_usePluginAssets) { - // the plugin.js file is in lib/ directory and we need to serve from assets/ directory - path = `../assets/${path}`; - return new URL(path, import.meta.url).href; - } else { - return `/assets/${path}`; - } -} \ No newline at end of file diff --git a/templates/ui-plugin-template/src/env.ts b/templates/ui-plugin-template/src/env.ts deleted file mode 100644 index da1d16498..000000000 --- a/templates/ui-plugin-template/src/env.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Env } from "@vertesia/ui/env"; - -const CONFIG__PLUGIN_TITLE = "Ui Plugin Template"; - -Env.init({ - name: CONFIG__PLUGIN_TITLE, - version: "1.0.0", - isLocalDev: true, - isDocker: true, - type: "development", - endpoints: { - studio: "https://api.us1.vertesia.io", - zeno: "https://api.us1.vertesia.io", - sts: "https://sts.us1.vertesia.io", - } -}); diff --git a/templates/ui-plugin-template/src/index.css b/templates/ui-plugin-template/src/index.css deleted file mode 100644 index 4014d8a3c..000000000 --- a/templates/ui-plugin-template/src/index.css +++ /dev/null @@ -1,21 +0,0 @@ -@import "tailwindcss"; - -/** - * The login form use this for inputs - */ -@plugin '@tailwindcss/forms'; - -/** - * Scan typescript sources - */ -@source "./"; - -/** - * Scan the index.html for tailwind CSS - */ -@source "../index.html"; - -/** - * Scan @vertesia/ui dependency for tailwind classes - */ -@source "../node_modules/@vertesia/ui/src"; \ No newline at end of file diff --git a/templates/ui-plugin-template/src/main.tsx b/templates/ui-plugin-template/src/main.tsx deleted file mode 100644 index fb08c9e88..000000000 --- a/templates/ui-plugin-template/src/main.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { I18nProvider } from '@vertesia/ui/i18n' -import { StandaloneApp, VertesiaShell } from '@vertesia/ui/shell' -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -// initialize dev environment -import { RouterProvider } from '@vertesia/ui/router' -import { App } from './app' -import "./env" -import { setUsePluginAssets } from './assets' - -setUsePluginAssets(false); - -createRoot(document.getElementById('root')!).render( - - - - {/* <---- define VITE_APP_NAME en var in .env.local */} - - - - - , -) diff --git a/templates/ui-plugin-template/src/pages.tsx b/templates/ui-plugin-template/src/pages.tsx deleted file mode 100644 index c22f9f4ed..000000000 --- a/templates/ui-plugin-template/src/pages.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { useNavigate } from "@vertesia/ui/router"; -import { useUserSession } from "@vertesia/ui/session"; -import type { ReactNode } from "react"; - -export function HomePage() { - const { user } = useUserSession(); - return ( -
-

Hello {user?.email}!

- Go to next page -
- ) -} - -export function NextPage() { - return ( -
-

Hello again!

- Go to previous page -
- ) -} - -function NavButton({ href, children }: { href: string, children: ReactNode }) { - const navigate = useNavigate(); - return ( - - ) -} \ No newline at end of file diff --git a/templates/ui-plugin-template/src/plugin.tsx b/templates/ui-plugin-template/src/plugin.tsx deleted file mode 100644 index 923e37c29..000000000 --- a/templates/ui-plugin-template/src/plugin.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { PortalContainerProvider } from "@vertesia/ui/core"; -import { App } from "./app"; - -/** - * Export the plugin component. - */ -export default function TEMPLATE__PluginComponentName({ slot }: { slot: string }) { - // Render the plugin component based on the slot. - // Slot "page" is used in the App Portal - // Slot "content" is used in the Composite App - if (slot === "page" || slot === "content") { - return ( - - - - ); - } else { - console.warn('No component found for slot', slot); - return null; - } -} diff --git a/templates/ui-plugin-template/src/routes.tsx b/templates/ui-plugin-template/src/routes.tsx deleted file mode 100644 index 41cedfef0..000000000 --- a/templates/ui-plugin-template/src/routes.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { HomePage, NextPage } from "./pages"; - -export const routes = [ - { - path: '/', - Component: HomePage, - }, - { - path: '/next', - Component: NextPage, - }, - { - path: '*', - Component: () =>
Not found
, - } - -]; \ No newline at end of file diff --git a/templates/ui-plugin-template/src/vite-env.d.ts b/templates/ui-plugin-template/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2a..000000000 --- a/templates/ui-plugin-template/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/templates/ui-plugin-template/template.config.json b/templates/ui-plugin-template/template.config.json deleted file mode 100644 index 9ff0fdaf3..000000000 --- a/templates/ui-plugin-template/template.config.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "version": "1.0", - "description": "A template for building custom UI plugins for Vertesia", - "prompts": [ - { - "type": "text", - "name": "PROJECT_NAME", - "message": "Project name (use kebab case)", - "initial": "my-plugin" - }, - { - "type": "text", - "name": "PROJECT_DESCRIPTION", - "message": "Project description", - "initial": "A custom UI plugin for Vertesia" - }, - { - "type": "select", - "name": "inlineCss", - "message": "CSS isolation strategy", - "initial": 0, - "choices": [ - { - "title": "Shadow DOM", - "description": "Shadow DOM will be used to fully isolate the plugin.", - "value": false - }, - { - "title": "CSS-only isolation", - "description": "Injects Tailwind utilities into host DOM; not fully isolated. Lighter but may generate conflicts.", - "value": true - } - ] - } - ], - "derived": { - "PLUGIN_TITLE": { - "from": "PROJECT_NAME", - "transform": "titleCase" - }, - "PluginComponentName": { - "from": "PROJECT_NAME", - "transform": "pascalCase" - } - }, - "files": [ - "vite.config.ts", - "index.html", - "src/env.ts", - "src/plugin.tsx", - ".env.local.template" - ], - "renameFiles": { - ".env.local.template": ".env.local" - }, - "removeAfterInstall": [ - "template.config.json" - ] -} \ No newline at end of file diff --git a/templates/ui-plugin-template/tsconfig.app.json b/templates/ui-plugin-template/tsconfig.app.json deleted file mode 100644 index ab7277657..000000000 --- a/templates/ui-plugin-template/tsconfig.app.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2024", - "useDefineForClassFields": true, - "lib": [ - "ES2024", - "DOM", - "DOM.Iterable" - ], - "module": "ESNext", - "skipLibCheck": true, - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - "baseUrl": ".", - "paths": { - "@vertesia/common": [ - "./node_modules/@vertesia/common/src/index.ts" - ], - "@vertesia/client": [ - "./node_modules/@vertesia/client/src/index.ts", - ], - "@vertesia/ui/*": [ - "./node_modules/@vertesia/ui/src/*" - ] - }, - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": false, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": [ - "src" - ], - "references": [ - { - "path": "./node_modules/@vertesia/common/tsconfig.dist.json" - }, - { - "path": "./node_modules/@vertesia/ui/tsconfig.dist.json" - } - ] -} \ No newline at end of file diff --git a/templates/ui-plugin-template/tsconfig.json b/templates/ui-plugin-template/tsconfig.json deleted file mode 100644 index 1ffef600d..000000000 --- a/templates/ui-plugin-template/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] -} diff --git a/templates/ui-plugin-template/tsconfig.node.json b/templates/ui-plugin-template/tsconfig.node.json deleted file mode 100644 index 9ba9773b8..000000000 --- a/templates/ui-plugin-template/tsconfig.node.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "ES2024", - "lib": [ - "ES2024" - ], - "module": "ESNext", - "skipLibCheck": true, - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": false, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": [ - "vite.config.ts" - ] -} \ No newline at end of file diff --git a/templates/ui-plugin-template/vite.config.ts b/templates/ui-plugin-template/vite.config.ts deleted file mode 100644 index 3c36a4642..000000000 --- a/templates/ui-plugin-template/vite.config.ts +++ /dev/null @@ -1,128 +0,0 @@ -import tailwindcss from '@tailwindcss/vite'; -import { vertesiaPluginBuilder } from '@vertesia/plugin-builder'; -import basicSsl from '@vitejs/plugin-basic-ssl'; -import react from '@vitejs/plugin-react'; -import { defineConfig, type ConfigEnv, type UserConfig } from 'vite'; -import serveStatic from "vite-plugin-serve-static"; - - -/** - * List of dependencies that must be bundled in the plugin bundle - */ -const INTERNALS: (string | RegExp)[] = [ -]; - -function isExternal(id: string) { - // If it matches INTERNALS → bundle it - if (INTERNALS.some(pattern => - pattern instanceof RegExp ? pattern.test(id) : id === pattern - )) { - return false; - } - - // Otherwise → treat all bare imports (node_modules deps) as external - return !id.startsWith('.') && !id.startsWith('/') && !id.startsWith('@/') && !id.startsWith('virtual:'); -} - - -/** - * if you want to debug vertesia ui sources define a relative path to the vertesia ui package root - */ -const VERTESIA_UI_PATH = "" - -/** - * Set to true to extract the css utility layer and inject it in the plugin js file. - * If you use shadow dom isolation for the plugin you must set this to false. - */ -const CONFIG__inlineCss = false; - -/** - * Vite configuration to build the plugin as a library or as a standalone application or to run the application in dev mode. - * Use `vite build --mode lib` to build a library (plugin) - * Use `vite build` or `vite build --mode app`to build a standalone application - * Use `vite dev` to run the application in dev mode. - */ -export default defineConfig((env) => { - if (env.mode === 'lib') { - return defineLibConfig(env); - } else { - return defineAppConfig(); - } -}) - -/** - * Vite configuration to build a library (plugin). - * @param env - Vite configuration environment - * @returns - */ -function defineLibConfig({ command }: ConfigEnv): UserConfig { - const isBuildMode = command === 'build'; - if (!isBuildMode) { - throw new Error("Library config is only available in 'build' mode. Please use 'lib' mode for library builds."); - } - return { - plugins: [ - tailwindcss(), - react(), - vertesiaPluginBuilder({ inlineCss: CONFIG__inlineCss }), - ], - build: { - outDir: 'dist/lib', // the plugin will be generated in the `dist/lib` directory - lib: { - entry: './src/plugin.tsx', // Main entry point of your library - formats: ['es'], // Build ESM versions - fileName: "plugin", - }, - minify: true, - sourcemap: true, - rollupOptions: { - external: isExternal, - } - } - } -} - -/** - * Vite configuration to run the application in dev mode - * or to build a standalone application. - * @returns - */ -function defineAppConfig(): UserConfig { - - return { - plugins: [ - tailwindcss(), - react(), - // we need to use https for the firebase authentication to work - basicSsl(), - // serve lib/plugin.js content in dev mode - serveStatic([ - { - pattern: new RegExp("/plugin.(js|css)"), - resolve: (groups: string[]) => `./dist/lib/plugin.${groups[1]}` - }, - ]), - ], - // for authentication with Firebase - server: { - proxy: { - '/__/auth': { - target: 'https://dengenlabs.firebaseapp.com', - changeOrigin: true, - } - } - }, - resolve: { - // For debug support in vertesia ui sources - link to the vertesia/ui location - alias: VERTESIA_UI_PATH ? { - "@vertesia/ui": resolve(`${VERTESIA_UI_PATH}/src`) - } : undefined, - // Deduplicate React to prevent multiple instances - dedupe: ['react', 'react-dom'] - } - } -} - -function resolve(path: string) { - return new URL(path, import.meta.url).pathname -} diff --git a/templates/worker-template/.docker/config.json b/templates/worker-template/.docker/config.json deleted file mode 100644 index 586adf071..000000000 --- a/templates/worker-template/.docker/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "credHelpers": { - "us-docker.pkg.dev": "vertesia" - } -} \ No newline at end of file diff --git a/templates/worker-template/.dockerignore b/templates/worker-template/.dockerignore deleted file mode 100644 index e197af264..000000000 --- a/templates/worker-template/.dockerignore +++ /dev/null @@ -1,7 +0,0 @@ -.env -.DS_Store -.git -.gitignore -*.md -lib/ -node_modules/ diff --git a/templates/worker-template/.env.template b/templates/worker-template/.env.template deleted file mode 100644 index 6805df3e3..000000000 --- a/templates/worker-template/.env.template +++ /dev/null @@ -1,16 +0,0 @@ -# NAME: The name of the app. -NAME={{SERVICE_NAME}} -# VERSION: The version of the app. -VERSION=1.0.0 - -# ENVIRONMENT: The name of the environment that the app is running in. -# Please use the following format: desktop-org-[username|appname] -ENVIRONMENT=desktop-{{SERVICE_NAME}} - -# IS_LOCAL_DEV: A boolean value that indicates whether the app is running in a local development. -IS_LOCAL_DEV=true - -# TEMPORAL_ADDRESS: The address of the Temporal server. -TEMPORAL_ADDRESS=localhost:7233 -# TEMPORAL_TASK_QUEUE: The task queue to use for the worker. -TEMPORAL_TASK_QUEUE={{SERVICE_NAME}} diff --git a/templates/worker-template/.gitignore b/templates/worker-template/.gitignore deleted file mode 100644 index b48a9caf1..000000000 --- a/templates/worker-template/.gitignore +++ /dev/null @@ -1,25 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -lib -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/templates/worker-template/.npmrc b/templates/worker-template/.npmrc deleted file mode 100644 index 28931b3da..000000000 --- a/templates/worker-template/.npmrc +++ /dev/null @@ -1 +0,0 @@ -@dglabs:registry=https://us-central1-npm.pkg.dev/dengenlabs/npm/ diff --git a/templates/worker-template/Dockerfile b/templates/worker-template/Dockerfile deleted file mode 100644 index dbb85701c..000000000 --- a/templates/worker-template/Dockerfile +++ /dev/null @@ -1,28 +0,0 @@ -FROM node:24-slim AS base -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable -COPY . /app -WORKDIR /app - -FROM base AS prod-deps -RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile - -FROM base AS build -RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile -RUN pnpm run build - -FROM base -RUN apt-get update -y && \ - apt-get install -y openssl ca-certificates - -COPY --from=prod-deps /app/node_modules /app/node_modules -COPY --from=build /app/lib /app/lib -COPY --from=build /app/bin /app/bin - -COPY --from=datadog/serverless-init:1 /datadog-init /app/datadog-init - -ENV SERVICE_NAME={{SERVICE_NAME}} - -ENTRYPOINT ["/app/datadog-init"] -CMD [ "pnpm", "start" ] diff --git a/templates/worker-template/README.md b/templates/worker-template/README.md deleted file mode 100644 index ca1ac1fe6..000000000 --- a/templates/worker-template/README.md +++ /dev/null @@ -1,218 +0,0 @@ -# {{SERVICE_NAME}} - -{{PROJECT_DESCRIPTION}} - -## Overview - -This is a Vertesia custom worker built on Temporal workflows. It provides a starting point for building workflow-based automation that integrates with the Vertesia platform. - -## Architecture - -### Core Components - -- **Workflows** (`src/workflows.ts`): Workflow definitions that orchestrate activities - - `exampleWorkflow`: Processes content objects with support for dry-run mode - - `inspectObjectsWorkflow`: Retrieves metadata from content objects without modifications -- **Activities** (`src/activities.ts`): Core processing activities that perform actual work - - `processObjectActivity`: Retrieves and processes a content object - - `getObjectMetadataActivity`: Fetches object metadata for inspection -- **Types**: Activity parameters and results are defined with proper TypeScript interfaces -- **Main Entry** (`src/main.ts`): Worker runner that loads workflow bundle and activities -- **Debug Replayer** (`src/debug-replayer.ts`): Temporal debug tooling for workflow replay and testing -- **Test Utils** (`src/test/utils.ts`): Shared testing utilities and helpers - -### Processing Flow - -1. Workflow receives a payload with `objectIds` to process -2. Each object is processed through activities -3. Activities use the Vertesia client to interact with the platform -4. Results are aggregated and returned - -## Development - -### Prerequisites - -- Node.js 22+ -- pnpm package manager -- Vertesia CLI (`@vertesia/cli`) - -### Setup - -Connect to your Vertesia project (this sets up npm registry authentication): - -```bash -vertesia worker connect -``` - -Install dependencies: - -```bash -pnpm install -``` - -### Build - -```bash -pnpm run build -``` - -### Testing - -Uses Vitest with Temporal testing utilities. Test files use `.test.ts` extension. - -```bash -pnpm test -``` - -Run tests in watch mode: - -```bash -pnpm test -- --watch -``` - -## Configuration - -### Package Configuration - -Worker configuration is defined in `package.json` under the `vertesia` section: - -```json -{ - "vertesia": { - "image": { - "repository": "us-docker.pkg.dev/dengenlabs/us.gcr.io", - "organization": "{{WORKER_ORG}}", - "name": "{{WORKER_NAME}}" - } - } -} -``` - -## Run with a Local Temporal Server - -Running with a local Temporal server is useful for integration testing before deployment. - -### 1. Install the Temporal CLI - -```bash -# macOS -brew install temporal - -# Other platforms: https://docs.temporal.io/cli -``` - -### 2. Start the Dev Server - -```bash -temporal server start-dev -``` - -### 3. Start the Worker - -In another terminal: - -```bash -pnpm run start -``` - -### 4. Start a Workflow - -Using the Temporal CLI: - -```bash -temporal workflow start --name exampleWorkflow -t {{SERVICE_NAME}} --input-file input.json -``` - -Where `input.json` contains the workflow parameters: - -```json -{ - "objectIds": ["object-id-1", "object-id-2"], - "event": "workflow_execution_request", - "auth_token": "your-auth-token", - "account_id": "your-account-id", - "project_id": "your-project-id", - "config": { - "store_url": "https://zeno-server.api.vertesia.io", - "studio_url": "https://studio-server.api.vertesia.io" - }, - "vars": { - "dryRun": true - } -} -``` - -## Deployment - -### Build Docker Image - -```bash -vertesia worker build -``` - -### Create a Release - -```bash -vertesia worker release X.Y.Z -``` - -### Publish and Deploy - -```bash -vertesia worker publish X.Y.Z -``` - -**Note:** Deployments are per environment based on your current CLI profile. - -## Running in Production - -Once deployed, the workflow can be started using the API, CLI, SDK, or Workflow rules. - -### Using the SDK - -```javascript -const run = await client.workflows.execute("exampleWorkflow", { - task_queue: "workers/{{WORKER_ORG}}/{{WORKER_NAME}}", - objectIds: ["object-id-1"], - vars: { - dryRun: false, - }, -}); -``` - -### Using the CLI - -```bash -vertesia workflows execute exampleWorkflow \ - -o \ - --queue "workers/{{WORKER_ORG}}/{{WORKER_NAME}}" \ - -f workflow_vars.json -``` - -### Using curl - -```bash -curl --location 'https://api.vertesia.io/api/v1/workflows/execute/exampleWorkflow' \ - --header 'Authorization: Bearer ' \ - --header 'Content-Type: application/json' \ - --data '{ - "vars": { "dryRun": false }, - "task_queue": "workers/{{WORKER_ORG}}/{{WORKER_NAME}}", - "objectIds": ["object-id-1"] - }' -``` - -## Workflow Variables - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `dryRun` | boolean | `false` | If true, simulates processing without making changes | - -## Dependencies - -Built with: - -- **Temporal**: Workflow orchestration -- **Vertesia SDK**: Platform integration (`@vertesia/client`, `@vertesia/workflow`) -- **TypeScript**: Type-safe development -- **Vitest**: Testing framework diff --git a/templates/worker-template/bin/bundle-workflows.mjs b/templates/worker-template/bin/bundle-workflows.mjs deleted file mode 100755 index 02ef3afa4..000000000 --- a/templates/worker-template/bin/bundle-workflows.mjs +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env node - -import { bundleWorkflowCode } from '@temporalio/worker'; -import { writeFile } from 'node:fs/promises'; -import path from 'node:path'; - -async function bundle(wsPath, bundlePath) { - const { code } = await bundleWorkflowCode({ - workflowsPath: path.resolve(wsPath), - webpackConfigHook: (config) => { - // Fix for Temporal's VM sandbox environment: - // 1. publicPath: '' - disable auto-detection (no document.currentScript) - // 2. chunkLoading: 'import' - use import() instead of JSONP (no 'self' global) - // 3. globalObject: 'globalThis' - use globalThis instead of self/window - config.output = { - ...config.output, - publicPath: '', - chunkLoading: 'import', - globalObject: 'globalThis', - }; - return config; - }, - }); - const codePath = path.resolve(bundlePath); - await writeFile(codePath, code); - console.log(`Bundle written to ${codePath}`); -} - -const wsPath = process.argv[2]; -const bundlePath = process.argv[3]; -if (!wsPath || !bundlePath) { - console.error('Usage: build-workflows '); - process.exit(1); -} - -bundle(wsPath, bundlePath).catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/templates/worker-template/eslint.config.js b/templates/worker-template/eslint.config.js deleted file mode 100644 index d18eb0da2..000000000 --- a/templates/worker-template/eslint.config.js +++ /dev/null @@ -1,34 +0,0 @@ -import eslint from "@eslint/js"; -import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; -import { defineConfig } from "eslint/config"; -import tseslint from "typescript-eslint"; - -export default defineConfig( - eslint.configs.recommended, - ...tseslint.configs.recommendedTypeChecked, - eslintPluginPrettierRecommended, - { - languageOptions: { - parserOptions: { - project: "./tsconfig.eslint.json", - tsconfigRootDir: import.meta.dirname, - }, - }, - }, - { - rules: { - "@typescript-eslint/no-floating-promises": "error", - "@typescript-eslint/no-unused-vars": [ - "error", - { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - }, - ], - '@typescript-eslint/no-unused-expressions': ['error', { - allowShortCircuit: true, - allowTernary: true, - }], - }, - }, -); diff --git a/templates/worker-template/package.json b/templates/worker-template/package.json deleted file mode 100644 index f44380de9..000000000 --- a/templates/worker-template/package.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "name": "@{{WORKER_ORG}}/{{WORKER_NAME}}", - "version": "1.1.0", - "description": "{{PROJECT_DESCRIPTION}}", - "type": "module", - "main": "lib/main.js", - "types": "lib/main.d.ts", - "scripts": { - "clean": "rimraf ./lib tsconfig.tsbuildinfo", - "lint": "pnpm exec eslint 'src/**/*' --fix", - "format": "pnpm exec prettier --write \"**/*.{ts,js,json,md}\"", - "prebuild": "pnpm run lint", - "build": "pnpm run clean && tsc --build && node ./bin/bundle-workflows.mjs lib/workflows.js lib/workflows-bundle.js", - "typecheck:test": "tsc --project tsconfig.test.json", - "pretest": "pnpm run typecheck:test", - "start": "node lib/main.js", - "connect": "vertesia worker connect", - "test": "vitest" - }, - "keywords": [ - "workflow", - "temporal", - "llm", - "vertesia", - "worker" - ], - "author": "Vertesia", - "license": "Apache-2.0", - "dependencies": { - "@dglabs/worker": "^0.11.0", - "@temporalio/activity": "^1.13.0", - "@temporalio/client": "^1.13.0", - "@temporalio/worker": "^1.13.0", - "@temporalio/workflow": "^1.13.0", - "@vertesia/client": "workspace:*", - "@vertesia/common": "workspace:*", - "@vertesia/workflow": "workspace:*" - }, - "devDependencies": { - "@eslint/compat": "^2.0.2", - "@eslint/js": "^10.0.1", - "eslint": "^10.0.2", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.5", - "@types/node": "^24.1.0", - "prettier": "3.5.3", - "rimraf": "^6.0.1", - "@temporalio/proto": "^1.13.0", - "@temporalio/testing": "^1.13.0", - "typescript": "^5.9.2", - "typescript-eslint": "^8.56.1", - "vitest": "^4.0.16", - "vitest-fetch-mock": "^0.4.5" - }, - "vertesia": { - "image": { - "repository": "us-docker.pkg.dev/dengenlabs/us.gcr.io", - "organization": "{{WORKER_ORG}}", - "name": "{{WORKER_NAME}}" - } - } -} diff --git a/templates/worker-template/src/activities.test.ts b/templates/worker-template/src/activities.test.ts deleted file mode 100644 index deecb066b..000000000 --- a/templates/worker-template/src/activities.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { MockActivityEnvironment } from '@temporalio/testing'; -import { ContentObject } from '@vertesia/common'; -import { getVertesiaClient } from '@vertesia/workflow'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import * as activities from './activities.js'; -import { ActivityExecutionPayload, ProcessObjectParams, ProcessObjectResult } from './activities.js'; -import { getMockActivityPayload } from './test/utils.js'; - -// Mock the @vertesia/workflow module -vi.mock('@vertesia/workflow', async () => { - const actual = await vi.importActual('@vertesia/workflow'); - return { - ...actual, - getVertesiaClient: vi.fn(), - }; -}); - -beforeEach(() => { - vi.clearAllMocks(); -}); - -afterEach(() => { - vi.clearAllMocks(); -}); - -describe('processObjectActivity', () => { - it('successfully processes an object', async () => { - const mockObjectId = 'test-object-id'; - const mockObjectName = 'Test Document'; - - // Mock the Vertesia client - vi.mocked(getVertesiaClient).mockReturnValue({ - objects: { - retrieve: vi.fn().mockResolvedValue({ - id: mockObjectId, - name: mockObjectName, - } satisfies Partial), - }, - } as never); - - const env = new MockActivityEnvironment(); - const payload: ActivityExecutionPayload = getMockActivityPayload({ - objectId: mockObjectId, - }); - - const result: ProcessObjectResult = await env.run(activities.processObjectActivity, payload); - - expect(result).toEqual({ - objectId: mockObjectId, - name: mockObjectName, - success: true, - }); - }); - - it('handles dry run mode', async () => { - const mockObjectId = 'test-object-id'; - const mockObjectName = 'Test Document'; - - // Mock the Vertesia client - vi.mocked(getVertesiaClient).mockReturnValue({ - objects: { - retrieve: vi.fn().mockResolvedValue({ - id: mockObjectId, - name: mockObjectName, - } satisfies Partial), - }, - } as never); - - const env = new MockActivityEnvironment(); - const payload: ActivityExecutionPayload = { - ...getMockActivityPayload({ objectId: mockObjectId }), - vars: { dryRun: true }, - }; - - const result: ProcessObjectResult = await env.run(activities.processObjectActivity, payload); - - expect(result).toEqual({ - objectId: mockObjectId, - name: mockObjectName, - success: true, - message: 'Dry run - no changes made', - }); - }); -}); - -describe('getObjectMetadataActivity', () => { - it('successfully retrieves object metadata', async () => { - const mockObjectId = 'test-object-id'; - const mockObject = { - id: mockObjectId, - name: 'Test Document', - type: { id: '123', name: 'Document' }, - properties: { author: 'Test Author' }, - } satisfies Partial; - - // Mock the Vertesia client - vi.mocked(getVertesiaClient).mockReturnValue({ - objects: { - retrieve: vi.fn().mockResolvedValue(mockObject), - }, - } as never); - - const env = new MockActivityEnvironment(); - const payload: ActivityExecutionPayload = getMockActivityPayload({ - objectId: mockObjectId, - }); - - const result = await env.run(activities.getObjectMetadataActivity, payload); - - expect(result).toEqual({ - objectId: mockObjectId, - name: 'Test Document', - type: 'Document', - properties: { author: 'Test Author' }, - }); - }); - - it('handles objects without type or properties', async () => { - const mockObjectId = 'test-object-id'; - const mockObject = { - id: mockObjectId, - name: 'Simple Document', - } satisfies Partial; - - // Mock the Vertesia client - vi.mocked(getVertesiaClient).mockReturnValue({ - objects: { - retrieve: vi.fn().mockResolvedValue(mockObject), - }, - } as never); - - const env = new MockActivityEnvironment(); - const payload: ActivityExecutionPayload = getMockActivityPayload({ - objectId: mockObjectId, - }); - - const result = await env.run(activities.getObjectMetadataActivity, payload); - - expect(result).toEqual({ - objectId: mockObjectId, - name: 'Simple Document', - type: undefined, - properties: {}, - }); - }); -}); diff --git a/templates/worker-template/src/activities.ts b/templates/worker-template/src/activities.ts deleted file mode 100644 index e1516bf7a..000000000 --- a/templates/worker-template/src/activities.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { log } from '@temporalio/activity'; -import { WorkflowExecutionPayload } from '@vertesia/common'; -import { getVertesiaClient } from '@vertesia/workflow'; - -/** - * Extended payload interface for activities that need additional parameters - * beyond the standard workflow execution payload. - */ -export interface ActivityExecutionPayload extends WorkflowExecutionPayload { - params: T; -} - -/** - * Parameters for the example workflow. - * Customize these based on your workflow requirements. - */ -export interface ExampleWorkflowParams { - dryRun?: boolean; -} - -/** - * Parameters for processing a single content object. - */ -export interface ProcessObjectParams { - objectId: string; -} - -/** - * Result returned from processing an object. - */ -export interface ProcessObjectResult { - objectId: string; - name: string; - success: boolean; - message?: string; -} - -/** - * Metadata returned from getObjectMetadataActivity. - */ -export interface ObjectMetadata { - objectId: string; - name: string; - type?: string; - properties: Record; -} - -/** - * Example activity that retrieves and processes a content object from Vertesia. - * - * This demonstrates the pattern for: - * - Getting the Vertesia client from the workflow payload - * - Making API calls to the Vertesia platform - * - Supporting dry-run mode for testing - * - Proper error handling and logging - * - * @param payload - The activity execution payload containing auth and params - * @returns The result of processing the object - */ -export async function processObjectActivity( - payload: ActivityExecutionPayload -): Promise { - const { params, vars = {} } = payload; - const { objectId } = params; - - log.info(`Processing object: ${objectId}`); - - // Get the Vertesia client initialized with auth from the payload - const vertesia = getVertesiaClient(payload); - - // Retrieve the content object - const object = await vertesia.objects.retrieve(objectId); - - if (vars.dryRun) { - log.info(`Dry run: would process object "${object.name}"`); - return { - objectId, - name: object.name, - success: true, - message: 'Dry run - no changes made', - }; - } - - // Add your processing logic here - // For example: analyze content, update metadata, trigger other workflows, etc. - - log.info(`Successfully processed object: ${object.name}`); - - return { - objectId, - name: object.name, - success: true, - }; -} - -/** - * Example activity that fetches and returns object metadata. - * - * This is a simpler activity that just retrieves information - * without making changes - useful for validation or inspection workflows. - * - * @param payload - The activity execution payload - * @returns Object metadata - */ -export async function getObjectMetadataActivity( - payload: ActivityExecutionPayload -): Promise { - const { params } = payload; - const { objectId } = params; - - log.info(`Fetching metadata for object: ${objectId}`); - - const vertesia = getVertesiaClient(payload); - const object = await vertesia.objects.retrieve(objectId); - - return { - objectId: object.id, - name: object.name, - type: object.type?.name, - properties: (object.properties as Record) || {}, - }; -} diff --git a/templates/worker-template/src/debug-replayer.ts b/templates/worker-template/src/debug-replayer.ts deleted file mode 100644 index 6bcefbe57..000000000 --- a/templates/worker-template/src/debug-replayer.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Debug replayer for workflow testing. - * - * This tool allows you to replay workflows locally for debugging purposes. - * See https://docs.temporal.io/develop/typescript/debugging for more information. - */ -import { resolveScriptFile } from '@dglabs/worker'; -import { startDebugReplayer } from '@temporalio/worker'; - -await resolveScriptFile('./workflows', import.meta.url).then((p: string) => { - console.log('Debugging using workflows path', p); - startDebugReplayer({ - workflowsPath: p, - }); -}); diff --git a/templates/worker-template/src/main.ts b/templates/worker-template/src/main.ts deleted file mode 100644 index f4bb11c74..000000000 --- a/templates/worker-template/src/main.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Worker entry point. - * - * This file initializes and runs the Temporal worker that executes - * your workflows and activities on the Vertesia platform. - */ -import { resolveScriptFile, run } from '@dglabs/worker'; -import { readFileSync } from 'fs'; -import { fileURLToPath } from 'url'; -import { dirname, resolve } from 'path'; - -interface VertesiaConfig { - name: string; - vertesia?: { - image?: { - organization?: string; - name?: string; - }; - }; -} - -const pkg = readPackageJson(); - -// Construct the worker domain from package.json configuration -// Format: workers/{organization}/{worker-name} -let domain = `workers/${pkg.name}`; - -if (pkg.vertesia?.image?.organization) { - if (pkg.vertesia.image.name) { - domain = `workers/${pkg.vertesia.image.organization}/${pkg.vertesia.image.name}`; - } -} - -// Load the bundled workflow code -const workflowBundle = await resolveScriptFile('./workflows-bundle.js', import.meta.url); - -// Import activities module -const activities = await import('./activities.js'); - -// Start the worker -await run({ - workflowBundle, - activities, - domain, - local: process.env.IS_LOCAL_DEV === 'true', -}) - .catch((err: unknown) => { - console.error(err); - }) - .finally(() => { - process.exit(0); - }); - -function readPackageJson(): VertesiaConfig { - const scriptPath = fileURLToPath(import.meta.url); - const pkgFile = resolve(dirname(scriptPath), '../package.json'); - const content = readFileSync(pkgFile, 'utf8'); - return JSON.parse(content) as VertesiaConfig; -} diff --git a/templates/worker-template/src/test/utils.ts b/templates/worker-template/src/test/utils.ts deleted file mode 100644 index 258de4cb2..000000000 --- a/templates/worker-template/src/test/utils.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { ContentEventName, WorkflowExecutionPayload } from '@vertesia/common'; -import { ActivityExecutionPayload, ExampleWorkflowParams } from '../activities.js'; - -/** - * Creates a mock activity payload for testing. - * - * @param params - The activity-specific parameters - * @returns A complete ActivityExecutionPayload for use in tests - */ -export const getMockActivityPayload = (params: T): ActivityExecutionPayload => { - return { - event: ContentEventName.workflow_execution_request, - objectIds: ['test-object-id'], - auth_token: 'mock-auth-token', - account_id: 'mock-account-id', - project_id: 'mock-project-id', - config: { - studio_url: 'http://mock-studio', - store_url: 'http://mock-store', - }, - vars: {}, - params: params, - }; -}; - -/** - * Creates a mock workflow payload for testing. - * - * @param vars - The workflow variables - * @param objectIds - Optional array of object IDs (defaults to single test ID) - * @returns A complete WorkflowExecutionPayload for use in tests - */ -export const getMockWorkflowPayload = ( - vars: T, - objectIds: string[] = ['test-object-id'] -): WorkflowExecutionPayload => { - return { - event: ContentEventName.workflow_execution_request, - objectIds, - auth_token: 'mock-auth-token', - account_id: 'mock-account-id', - project_id: 'mock-project-id', - config: { - studio_url: 'http://mock-studio', - store_url: 'http://mock-store', - }, - vars: vars, - }; -}; diff --git a/templates/worker-template/src/vitest.d.ts b/templates/worker-template/src/vitest.d.ts deleted file mode 100644 index dd2e65487..000000000 --- a/templates/worker-template/src/vitest.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { vi as viTest } from 'vitest'; - -declare global { - const vi: typeof viTest; -} - -export {}; diff --git a/templates/worker-template/src/workflows.test.ts b/templates/worker-template/src/workflows.test.ts deleted file mode 100644 index 2627e4af8..000000000 --- a/templates/worker-template/src/workflows.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { TestWorkflowEnvironment } from '@temporalio/testing'; -import { Worker } from '@temporalio/worker'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { - ActivityExecutionPayload, - ObjectMetadata, - ProcessObjectParams, - ProcessObjectResult, -} from './activities.js'; -import { getMockWorkflowPayload } from './test/utils.js'; -import { exampleWorkflow, inspectObjectsWorkflow, type ExampleWorkflowResult } from './workflows.js'; - -describe('Workflows', () => { - let testEnv: TestWorkflowEnvironment; - - beforeAll(async () => { - testEnv = await TestWorkflowEnvironment.createLocal(); - }); - - afterAll(async () => { - await testEnv?.teardown(); - }); - - describe('exampleWorkflow', () => { - it('successfully processes multiple objects', async () => { - const { client, nativeConnection } = testEnv; - const taskQueue = 'test-example-workflow'; - - // Mock activity implementations - const mockResults: Record = { - 'object-1': { objectId: 'object-1', name: 'Document 1', success: true }, - 'object-2': { objectId: 'object-2', name: 'Document 2', success: true }, - }; - - const activities = { - processObjectActivity: ( - payload: ActivityExecutionPayload - ): Promise => Promise.resolve(mockResults[payload.params.objectId]), - getObjectMetadataActivity: ( - _payload: ActivityExecutionPayload - ): Promise => Promise.resolve({ objectId: '', name: '', properties: {} }), - }; - - const worker = await Worker.create({ - connection: nativeConnection, - taskQueue, - workflowsPath: new URL('./workflows.ts', import.meta.url).pathname, - activities, - }); - - const payload = getMockWorkflowPayload({}, ['object-1', 'object-2']); - - const result: ExampleWorkflowResult = await worker.runUntil( - client.workflow.execute(exampleWorkflow, { - args: [payload], - workflowId: 'test-example-workflow', - taskQueue, - }) - ); - - expect(result.processedObjects).toBe(2); - expect(result.results).toHaveLength(2); - expect(result.results[0]).toEqual({ - objectId: 'object-1', - name: 'Document 1', - success: true, - }); - expect(result.results[1]).toEqual({ - objectId: 'object-2', - name: 'Document 2', - success: true, - }); - }); - - it('handles dry run mode', async () => { - const { client, nativeConnection } = testEnv; - const taskQueue = 'test-example-workflow-dry-run'; - - const activities = { - processObjectActivity: ( - _payload: ActivityExecutionPayload - ): Promise => - Promise.resolve({ - objectId: 'object-1', - name: 'Document 1', - success: true, - message: 'Dry run - no changes made', - }), - getObjectMetadataActivity: ( - _payload: ActivityExecutionPayload - ): Promise => Promise.resolve({ objectId: '', name: '', properties: {} }), - }; - - const worker = await Worker.create({ - connection: nativeConnection, - taskQueue, - workflowsPath: new URL('./workflows.ts', import.meta.url).pathname, - activities, - }); - - const payload = getMockWorkflowPayload({ dryRun: true }, ['object-1']); - - const result: ExampleWorkflowResult = await worker.runUntil( - client.workflow.execute(exampleWorkflow, { - args: [payload], - workflowId: 'test-example-workflow-dry-run', - taskQueue, - }) - ); - - expect(result.processedObjects).toBe(1); - expect(result.results[0]).toEqual({ - objectId: 'object-1', - name: 'Document 1', - success: true, - message: 'Dry run - no changes made', - }); - }); - - it('handles empty objectIds array', async () => { - const { client, nativeConnection } = testEnv; - const taskQueue = 'test-example-workflow-empty'; - - const activities = { - processObjectActivity: ( - _payload: ActivityExecutionPayload - ): Promise => Promise.resolve({ objectId: '', name: '', success: true }), - getObjectMetadataActivity: ( - _payload: ActivityExecutionPayload - ): Promise => Promise.resolve({ objectId: '', name: '', properties: {} }), - }; - - const worker = await Worker.create({ - connection: nativeConnection, - taskQueue, - workflowsPath: new URL('./workflows.ts', import.meta.url).pathname, - activities, - }); - - const payload = getMockWorkflowPayload({}, []); - - const result: ExampleWorkflowResult = await worker.runUntil( - client.workflow.execute(exampleWorkflow, { - args: [payload], - workflowId: 'test-example-workflow-empty', - taskQueue, - }) - ); - - expect(result.processedObjects).toBe(0); - expect(result.results).toHaveLength(0); - }); - }); - - describe('inspectObjectsWorkflow', () => { - it('successfully retrieves metadata for multiple objects', async () => { - const { client, nativeConnection } = testEnv; - const taskQueue = 'test-inspect-workflow'; - - const mockMetadata: Record = { - 'object-1': { - objectId: 'object-1', - name: 'Document 1', - type: 'Report', - properties: { author: 'Alice' }, - }, - 'object-2': { - objectId: 'object-2', - name: 'Document 2', - type: 'Article', - properties: { author: 'Bob' }, - }, - }; - - const activities = { - processObjectActivity: ( - _payload: ActivityExecutionPayload - ): Promise => Promise.resolve({ objectId: '', name: '', success: true }), - getObjectMetadataActivity: ( - payload: ActivityExecutionPayload - ): Promise => Promise.resolve(mockMetadata[payload.params.objectId]), - }; - - const worker = await Worker.create({ - connection: nativeConnection, - taskQueue, - workflowsPath: new URL('./workflows.ts', import.meta.url).pathname, - activities, - }); - - const payload = getMockWorkflowPayload({}, ['object-1', 'object-2']); - - const result = await worker.runUntil( - client.workflow.execute(inspectObjectsWorkflow, { - args: [payload], - workflowId: 'test-inspect-workflow', - taskQueue, - }) - ); - - expect(result.objects).toHaveLength(2); - expect(result.objects[0]).toEqual({ - objectId: 'object-1', - name: 'Document 1', - type: 'Report', - properties: { author: 'Alice' }, - }); - expect(result.objects[1]).toEqual({ - objectId: 'object-2', - name: 'Document 2', - type: 'Article', - properties: { author: 'Bob' }, - }); - }); - }); -}); diff --git a/templates/worker-template/src/workflows.ts b/templates/worker-template/src/workflows.ts deleted file mode 100644 index 600570da2..000000000 --- a/templates/worker-template/src/workflows.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { log, proxyActivities } from '@temporalio/workflow'; -import { WorkflowExecutionPayload } from '@vertesia/common'; -import * as activities from './activities.js'; -import { ExampleWorkflowParams, ProcessObjectResult } from './activities.js'; - -/** - * Proxy activities with retry configuration. - * - * Temporal activities are executed outside the workflow sandbox and can perform - * I/O operations like API calls. The proxy configuration defines: - * - startToCloseTimeout: Maximum time for a single activity attempt - * - retry: Automatic retry behavior for transient failures - */ -const { processObjectActivity, getObjectMetadataActivity } = proxyActivities({ - startToCloseTimeout: '5 minute', - retry: { - initialInterval: '5s', - backoffCoefficient: 2, - maximumAttempts: 5, - maximumInterval: 30 * 1000, // ms - nonRetryableErrorTypes: ['ActivityParamInvalid', 'ActivityParamNotFound'], - }, -}); - -/** - * Result returned from the example workflow. - */ -export interface ExampleWorkflowResult { - processedObjects: number; - results: ProcessObjectResult[]; -} - -/** - * Example workflow that processes content objects from Vertesia. - * - * This workflow demonstrates: - * - Receiving the standard WorkflowExecutionPayload with objectIds - * - Processing multiple objects through activities - * - Aggregating results - * - Using workflow variables (vars) for configuration - * - * The workflow is triggered with: - * - objectIds: Array of content object IDs to process - * - vars: Optional configuration like { dryRun: true } - * - * @param payload - The workflow execution payload from Vertesia - * @returns Summary of processed objects - */ -export async function exampleWorkflow( - payload: WorkflowExecutionPayload -): Promise { - const { objectIds = [] } = payload; - - log.info(`Starting example workflow with ${objectIds.length} objects`); - - const results: ProcessObjectResult[] = []; - - // Process each object sequentially - // For parallel processing, you can use Promise.all with child workflows - for (const objectId of objectIds) { - const result = await processObjectActivity({ - ...payload, - params: { objectId }, - }); - results.push(result); - } - - const successCount = results.filter((r) => r.success).length; - log.info(`Workflow completed: ${successCount}/${objectIds.length} objects processed successfully`); - - return { - processedObjects: results.length, - results, - }; -} - -/** - * Example workflow that inspects objects without modifying them. - * - * This is useful for validation, reporting, or audit workflows - * that need to gather information about content objects. - * - * @param payload - The workflow execution payload from Vertesia - * @returns Metadata for all requested objects - */ -export async function inspectObjectsWorkflow( - payload: WorkflowExecutionPayload -): Promise<{ objects: Array<{ objectId: string; name: string; type?: string; properties: Record }> }> { - const { objectIds = [] } = payload; - - log.info(`Inspecting ${objectIds.length} objects`); - - const objects = []; - - for (const objectId of objectIds) { - const metadata = await getObjectMetadataActivity({ - ...payload, - params: { objectId }, - }); - objects.push(metadata); - } - - return { objects }; -} diff --git a/templates/worker-template/template.config.json b/templates/worker-template/template.config.json deleted file mode 100644 index ca8fd377a..000000000 --- a/templates/worker-template/template.config.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "version": "1.0", - "description": "A template for building Temporal workflow workers for Vertesia", - "packageManager": "pnpm", - "prompts": [ - { - "type": "text", - "name": "WORKER_ORG", - "message": "Organization name (e.g. mycompany)", - "initial": "my-org", - "validate": "^[a-zA-Z_][a-zA-Z_0-9-]*$" - }, - { - "type": "text", - "name": "WORKER_NAME", - "message": "Worker name (e.g. my-worker)", - "initial": "my-worker", - "validate": "^[a-zA-Z_][a-zA-Z_0-9-]*$" - }, - { - "type": "text", - "name": "PROJECT_DESCRIPTION", - "message": "Project description", - "initial": "A Temporal workflow worker for Vertesia" - } - ], - "derived": { - "SERVICE_NAME": { - "from": ["WORKER_ORG", "WORKER_NAME"], - "transform": "concat", - "separator": "_" - } - }, - "files": [ - "package.json", - ".env.template", - "Dockerfile", - "README.md" - ], - "renameFiles": { - ".env.template": ".env" - }, - "preInstall": { - "commands": [ - { - "name": "Connect to Vertesia", - "command": "vertesia worker connect", - "optional": false, - "prompt": "You must connect to a Vertesia project to authenticate with the private npm registry." - } - ], - "cliPackage": "@vertesia/cli" - }, - "removeAfterInstall": [ - "template.config.json" - ] -} diff --git a/templates/worker-template/tsconfig.eslint.json b/templates/worker-template/tsconfig.eslint.json deleted file mode 100644 index 1f165e005..000000000 --- a/templates/worker-template/tsconfig.eslint.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": ["node_modules", "lib"] -} \ No newline at end of file diff --git a/templates/worker-template/tsconfig.json b/templates/worker-template/tsconfig.json deleted file mode 100644 index 85db906b3..000000000 --- a/templates/worker-template/tsconfig.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "rootDir": "./src", - "sourceMap": true, - "outDir": "./lib", - "forceConsistentCasingInFileNames": true, - "experimentalDecorators": true /* Enable experimental support for TC39 stage 2 draft decorators. */, - "allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */, - "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, - "target": "ES2024", - "useDefineForClassFields": true, - "lib": ["ES2024", "DOM"], - "module": "NodeNext", - "moduleResolution": "NodeNext", - "declaration": true, - "declarationMap": true, - "skipLibCheck": true, - /* Bundler mode */ - //"allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "exclude": [ - "node_modules", - "lib", - "**/test", - "vitest.config.ts", - "vitest.setup.ts", - "**/*.test.ts", - "tools" - ], - "references": [ - { "path": "../../../packages/worker/tsconfig.json" }, - { "path": "../../packages/client/tsconfig.json" }, - { "path": "../../packages/common/tsconfig.json" }, - { "path": "../../packages/workflow/tsconfig.json" } - ] -} \ No newline at end of file diff --git a/templates/worker-template/tsconfig.test.json b/templates/worker-template/tsconfig.test.json deleted file mode 100644 index 167e265a2..000000000 --- a/templates/worker-template/tsconfig.test.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "rootDir": "." - }, - "include": [ - "src/**/*.test.ts", - "src/test/**/*.ts", - "vitest.config.ts", - "vitest.setup.ts" - ], - "exclude": ["node_modules", "lib"] -} diff --git a/templates/worker-template/vitest.config.ts b/templates/worker-template/vitest.config.ts deleted file mode 100644 index 304db752f..000000000 --- a/templates/worker-template/vitest.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - environment: "node", - globals: true, - setupFiles: ["./vitest.setup.ts"], - typecheck: { - enabled: true, - tsconfig: "./tsconfig.test.json", - }, - }, -}); diff --git a/templates/worker-template/vitest.setup.ts b/templates/worker-template/vitest.setup.ts deleted file mode 100644 index dd7f63669..000000000 --- a/templates/worker-template/vitest.setup.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { vi, type VitestUtils } from "vitest"; -import createFetchMock, { type FetchMock } from "vitest-fetch-mock"; - -declare global { - var vi: VitestUtils; - var fetchMock: FetchMock; -} - -// Create and configure fetch mock -const fetchMock = createFetchMock(vi); -fetchMock.enableMocks(); - -// Make vi and fetchMock globally available for compatibility -global.vi = vi; -global.fetchMock = fetchMock;