From 3390122085e327b4f070a5a4695bb12aedef84c0 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Tue, 21 Apr 2026 17:17:20 +0800 Subject: [PATCH] feat(plugin-sdk-tamagotchi,airi-plugin-game-chess): tools api, gamelet api, init chess gamelet --- cspell.config.yaml | 4 + packages/plugin-sdk-tamagotchi/package.json | 49 ++++ .../src/gamelet/index.ts | 184 +++++++++++++++ .../plugin-sdk-tamagotchi/src/index.test.ts | 119 ++++++++++ packages/plugin-sdk-tamagotchi/src/index.ts | 2 + .../plugin-sdk-tamagotchi/src/tools/index.ts | 211 ++++++++++++++++++ packages/plugin-sdk-tamagotchi/tsconfig.json | 22 ++ .../plugin-sdk-tamagotchi/tsdown.config.ts | 11 + .../plugin-sdk-tamagotchi/vitest.config.ts | 8 + plugins/airi-plugin-game-chess/package.json | 37 +++ pnpm-lock.yaml | 25 ++- vitest.config.ts | 1 + 12 files changed, 670 insertions(+), 3 deletions(-) create mode 100644 packages/plugin-sdk-tamagotchi/package.json create mode 100644 packages/plugin-sdk-tamagotchi/src/gamelet/index.ts create mode 100644 packages/plugin-sdk-tamagotchi/src/index.test.ts create mode 100644 packages/plugin-sdk-tamagotchi/src/index.ts create mode 100644 packages/plugin-sdk-tamagotchi/src/tools/index.ts create mode 100644 packages/plugin-sdk-tamagotchi/tsconfig.json create mode 100644 packages/plugin-sdk-tamagotchi/tsdown.config.ts create mode 100644 packages/plugin-sdk-tamagotchi/vitest.config.ts create mode 100644 plugins/airi-plugin-game-chess/package.json diff --git a/cspell.config.yaml b/cspell.config.yaml index a579aeec1..9aeb03f97 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -31,6 +31,7 @@ words: - baichuan - baiducloud - bailian + - bestmove - bigserial - bilibili - Bitstream @@ -110,6 +111,7 @@ words: - flexsearch - formkit - frontmatter + - gamelet - Genshin - giteeai - gltf @@ -289,6 +291,7 @@ words: - ssml - staticlib - stepfun + - stockfish - sumimakito - supergroup - superjson @@ -322,6 +325,7 @@ words: - valibot - vaul - velin + - vieval - vishot - VITE - vitepress diff --git a/packages/plugin-sdk-tamagotchi/package.json b/packages/plugin-sdk-tamagotchi/package.json new file mode 100644 index 000000000..0201e916a --- /dev/null +++ b/packages/plugin-sdk-tamagotchi/package.json @@ -0,0 +1,49 @@ +{ + "name": "@proj-airi/plugin-sdk-tamagotchi", + "type": "module", + "version": "0.9.0", + "private": true, + "description": "Tamagotchi-specific DX helpers for Project AIRI plugins", + "author": { + "name": "Moeru AI Project AIRI Team", + "email": "airi@moeru.ai", + "url": "https://github.com/moeru-ai" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/moeru-ai/airi.git", + "directory": "packages/plugin-sdk-tamagotchi" + }, + "exports": { + ".": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "./gamelet": { + "types": "./dist/gamelet/index.d.mts", + "default": "./dist/gamelet/index.mjs" + }, + "./tools": { + "types": "./dist/tools/index.d.mts", + "default": "./dist/tools/index.mjs" + } + }, + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "files": [ + "README.md", + "dist", + "package.json" + ], + "scripts": { + "test": "vitest", + "typecheck": "tsc --noEmit", + "build": "tsdown" + }, + "dependencies": { + "@proj-airi/plugin-sdk": "workspace:*", + "valibot": "catalog:", + "xsschema": "catalog:" + } +} diff --git a/packages/plugin-sdk-tamagotchi/src/gamelet/index.ts b/packages/plugin-sdk-tamagotchi/src/gamelet/index.ts new file mode 100644 index 000000000..39f02a933 --- /dev/null +++ b/packages/plugin-sdk-tamagotchi/src/gamelet/index.ts @@ -0,0 +1,184 @@ +import type { ContextInit } from '@proj-airi/plugin-sdk' +import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host' + +/** + * Describes a widget hint contributed by a gamelet to the tamagotchi host. + * + * Use when: + * - A gamelet should expose one or more mountable widget surfaces + * + * Expects: + * - `id` is stable within the gamelet + * + * Returns: + * - A serializable host hint for widget registration + */ +export interface GameletWidgetDefinition { + id: string + kind: string +} + +/** + * Describes host-managed configuration defaults declared by a gamelet. + * + * Use when: + * - A gamelet wants the host to persist validated defaults + * + * Expects: + * - `defaults` is JSON-compatible + * + * Returns: + * - The configuration declaration stored in the gamelet module config + */ +export interface GameletConfigDefinition { + defaults?: TDefaults +} + +/** + * Describes the friendly tamagotchi authoring shape for a gamelet. + * + * Use when: + * - A plugin wants to register one UI-driven gamelet without raw kit/module calls + * + * Expects: + * - `entrypoint` points at the plugin-provided UI asset entry + * + * Returns: + * - A declarative gamelet definition consumed by {@link defineGamelet} + */ +export interface GameletDefinition { + id: string + title: string + entrypoint: string + widgets?: GameletWidgetDefinition[] + config?: GameletConfigDefinition +} + +/** + * Represents one registered tamagotchi gamelet. + * + * Use when: + * - Tools or plugin bootstrap code need to check whether host registration succeeded + * + * Expects: + * - Returned values come from a previously completed {@link defineGamelet} call + * + * Returns: + * - A minimal handle that keeps host lifecycle concerns internal + */ +export interface DefinedGamelet { + id: string + isSupported: () => Promise +} + +/** + * Normalizes one author-facing gamelet widget into host-safe binding config data. + * + * Before: + * - `{ id: 'main-board', kind: 'primary' }` + * + * After: + * - `{ id: 'main-board', kind: 'primary' }` + */ +function createWidgetHintRecord(definition: GameletWidgetDefinition): HostDataRecord { + return { + id: definition.id, + kind: definition.kind, + } +} + +/** + * Normalizes one gamelet definition into binding config stored in `kit.gamelet`. + * + * Before: + * - Friendly authoring fields that may include optional properties and typed helper objects + * + * After: + * - A plain `HostDataRecord` with only host-safe values and no `undefined` properties + */ +function buildModuleConfig(definition: GameletDefinition): HostDataRecord { + return { + title: definition.title, + entrypoint: definition.entrypoint, + widgets: (definition.widgets ?? []).map(createWidgetHintRecord), + widget: { + mount: 'iframe', + iframe: { + assetPath: definition.entrypoint, + sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', + }, + windowSize: { + width: 980, + height: 840, + minWidth: 640, + minHeight: 640, + }, + }, + ...(definition.config + ? { + config: { + defaults: definition.config.defaults ?? {}, + }, + } + : {}), + } +} + +/** + * Registers a tamagotchi gamelet through the low-level kit/binding APIs. + * + * Use when: + * - A plugin targets stage-tamagotchi and wants one-step gamelet registration + * + * Expects: + * - The host exposes the `kit.gamelet` kit through `ctx.apis.kits` + * + * Returns: + * - A handle that reports whether the host supports the gamelet kit + */ +export async function defineGamelet( + ctx: Pick, + definition: GameletDefinition, +): Promise { + const kits = await ctx.apis.kits.list() + const supported = kits.some(kit => kit.kitId === 'kit.gamelet') + + if (!supported) { + return { + id: definition.id, + async isSupported() { + return false + }, + } + } + + const existingModules = await ctx.apis.bindings.list() + const existingModule = existingModules.find(module => module.moduleId === definition.id) + const config = buildModuleConfig(definition) + + if (!existingModule) { + await ctx.apis.bindings.announce({ + moduleId: definition.id, + kitId: 'kit.gamelet', + kitModuleType: 'gamelet', + config, + }) + } + else { + await ctx.apis.bindings.update({ + moduleId: definition.id, + config, + }) + } + + await ctx.apis.bindings.activate({ + moduleId: definition.id, + }) + + return { + id: definition.id, + async isSupported() { + return true + }, + } +} diff --git a/packages/plugin-sdk-tamagotchi/src/index.test.ts b/packages/plugin-sdk-tamagotchi/src/index.test.ts new file mode 100644 index 000000000..e87278a50 --- /dev/null +++ b/packages/plugin-sdk-tamagotchi/src/index.test.ts @@ -0,0 +1,119 @@ +import { object, optional, string } from 'valibot' +import { describe, expect, it, vi } from 'vitest' + +import { defineGamelet, defineToolset } from './index' + +describe('plugin-sdk-tamagotchi', () => { + /** + * @example + * expect(registerBinding).toHaveBeenCalledWith(expect.objectContaining({ kitId: 'kit.gamelet' })) + * expect(registerTool).toHaveBeenCalledWith(expect.objectContaining({ tool: expect.any(Object) })) + */ + it('should allow a plugin to define a gamelet and toolset without raw kit or module calls', async () => { + const registerBinding = vi.fn() + const registerTool = vi.fn() + + const ctx = { + apis: { + tools: { + register: registerTool, + }, + kits: { + list: async () => [ + { + kitId: 'kit.gamelet', + version: '1.0.0', + runtimes: ['electron'], + capabilities: [], + }, + ], + getCapabilities: async () => [ + { + key: 'kit.gamelet.runtime', + actions: ['announce', 'activate', 'update'], + }, + ], + }, + bindings: { + list: async () => [], + announce: registerBinding, + update: registerBinding, + activate: registerBinding, + }, + }, + } + + const gamelet = await defineGamelet(ctx as never, { + id: 'chess', + title: 'Chess', + entrypoint: './ui/index.html', + widgets: [ + { + id: 'main-board', + kind: 'primary', + }, + ], + }) + + await defineToolset(ctx as never, { + tools: [ + { + id: 'play_chess', + title: 'Play Chess', + description: 'Open chess.', + inputSchema: object({ + opening: optional(string()), + }), + execute: async () => ({ ok: true }), + }, + ], + }) + + expect(gamelet).toBeDefined() + expect(registerBinding).toHaveBeenCalledWith({ + moduleId: 'chess', + kitId: 'kit.gamelet', + kitModuleType: 'gamelet', + config: { + title: 'Chess', + entrypoint: './ui/index.html', + widgets: [ + { + id: 'main-board', + kind: 'primary', + }, + ], + widget: { + mount: 'iframe', + iframe: { + assetPath: './ui/index.html', + sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', + }, + windowSize: { + width: 980, + height: 840, + minWidth: 640, + minHeight: 640, + }, + }, + }, + }) + expect(registerBinding).toHaveBeenCalledWith({ + moduleId: 'chess', + }) + expect(registerTool).toHaveBeenCalled() + expect(registerTool).toHaveBeenCalledWith(expect.objectContaining({ + tool: expect.objectContaining({ + id: 'play_chess', + parameters: expect.objectContaining({ + type: 'object', + properties: expect.objectContaining({ + opening: expect.objectContaining({ + type: 'string', + }), + }), + }), + }), + })) + }) +}) diff --git a/packages/plugin-sdk-tamagotchi/src/index.ts b/packages/plugin-sdk-tamagotchi/src/index.ts new file mode 100644 index 000000000..d0bc18c73 --- /dev/null +++ b/packages/plugin-sdk-tamagotchi/src/index.ts @@ -0,0 +1,2 @@ +export * from './gamelet' +export * from './tools' diff --git a/packages/plugin-sdk-tamagotchi/src/tools/index.ts b/packages/plugin-sdk-tamagotchi/src/tools/index.ts new file mode 100644 index 000000000..0c81e9780 --- /dev/null +++ b/packages/plugin-sdk-tamagotchi/src/tools/index.ts @@ -0,0 +1,211 @@ +import type { ContextInit } from '@proj-airi/plugin-sdk' +import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host' +import type { JsonSchema, Schema as StandardSchemaV1 } from 'xsschema' + +import { hostDataRecordSchema } from '@proj-airi/plugin-sdk/plugin-host' +import { parse } from 'valibot' +import { toJsonSchema } from 'xsschema' + +/** + * Describes the host services available while checking or executing a plugin tool. + * + * Use when: + * - Tool logic needs to orchestrate gamelet surfaces + * + * Expects: + * - All methods are provided by the host runtime, not the plugin + * + * Returns: + * - A runtime capability surface for tool execution + */ +export interface ToolExecutionContext { + gamelets: { + open: (id: string, params?: Record) => Promise + configure: (id: string, patch: Record) => Promise + close: (id: string) => Promise + isOpen: (id: string) => boolean + } + + // TODO: + // Add character/runtime orchestration APIs after the gamelet/tool path is stable. +} + +/** + * Describes renderer-side discovery hints for a plugin tool. + * + * Use when: + * - Tool pickers or activation matchers need keywords and regexp patterns + * + * Expects: + * - `patterns` are JavaScript `RegExp` instances and will be serialized by source + * + * Returns: + * - Optional metadata separate from xsai execution schema + */ +export interface PluginToolActivationDefinition { + keywords?: string[] + patterns?: RegExp[] +} + +/** + * Describes one high-level plugin tool declaration. + * + * Use when: + * - A plugin wants one declaration to drive host registry and xsai schema generation + * + * Expects: + * - `inputSchema` is either an xsschema-compatible schema or a prebuilt JSON Schema object + * + * Returns: + * - A friendly authoring record consumed by {@link defineToolset} + */ +export interface PluginToolDefinition { + id: string + title: string + description: string + activation?: PluginToolActivationDefinition + inputSchema: TInputSchema + isAvailable?: (context: ToolExecutionContext) => Promise | boolean + execute: (input: unknown, context: ToolExecutionContext) => Promise | unknown +} + +/** + * Declares a set of plugin tools in one call. + * + * Use when: + * - A plugin registers all of its tools during bootstrap + * + * Expects: + * - `ctx.apis.tools.register` is available from the host + * + * Returns: + * - Resolves once every tool has been registered with the host + */ +export interface DefineToolsetOptions { + tools: Array> +} + +function createToolExecutionContext(): ToolExecutionContext { + return { + gamelets: { + async open() {}, + async configure() {}, + async close() {}, + isOpen: () => false, + }, + } +} + +/** + * Checks whether one unknown value already looks like a JSON Schema root object. + * + * Use when: + * - Tool authoring code may pass either a prebuilt JSON Schema or a Standard Schema + * + * Expects: + * - JSON Schema roots are plain objects and commonly include `type`, `properties`, or `$schema` + * + * Returns: + * - `true` when the value should be cloned directly instead of converted with `toJsonSchema` + */ +function isJsonSchemaRecord(inputSchema: unknown): inputSchema is JsonSchema { + if (!inputSchema || typeof inputSchema !== 'object' || Array.isArray(inputSchema)) { + return false + } + + return 'type' in inputSchema || 'properties' in inputSchema || '$schema' in inputSchema || '$ref' in inputSchema +} + +/** + * Checks whether one unknown value implements the Standard Schema contract. + * + * Use when: + * - Tool authoring code passes a Valibot or other standard-schema-compatible validator + * + * Expects: + * - Standard schemas expose the `~standard` marker used by `xsschema` + * + * Returns: + * - `true` when the value can be converted by {@link toJsonSchema} + */ +function isStandardSchema(inputSchema: unknown): inputSchema is StandardSchemaV1 { + return Boolean( + inputSchema + && typeof inputSchema === 'object' + && '~standard' in inputSchema, + ) +} + +/** + * Validates that one plain object can cross the plugin-host boundary as `HostDataRecord`. + * + * Before: + * - A generic schema-shaped object with unknown property value types + * + * After: + * - The same object narrowed to `HostDataRecord` after runtime validation succeeds + */ +function toHostDataRecord(value: object): HostDataRecord { + parse(hostDataRecordSchema, value) + + return value as HostDataRecord +} + +/** + * Normalizes tool parameter schemas into the host-safe record shape expected by plugin-sdk. + * + * Before: + * - A Standard Schema instance or a JSON Schema-like authoring object + * + * After: + * - A validated `HostDataRecord` safe to store in the host tool registry + */ +async function serializeToolParameters(inputSchema: unknown): Promise { + if (isStandardSchema(inputSchema)) { + return toHostDataRecord(await toJsonSchema(inputSchema)) + } + + if (isJsonSchemaRecord(inputSchema)) { + return toHostDataRecord(structuredClone(inputSchema)) + } + + throw new TypeError('Tool input schema must be a JSON Schema object or a Standard Schema instance.') +} + +/** + * Registers one or more plugin tools with the tamagotchi host wrapper. + * + * Use when: + * - A plugin wants to declare xsai-compatible tools without low-level host records + * + * Expects: + * - The caller supplies stable tool ids and schemas + * + * Returns: + * - Resolves after all tool registrations complete + */ +export async function defineToolset( + ctx: Pick, + options: DefineToolsetOptions, +): Promise { + const executionContext = createToolExecutionContext() + + for (const definition of options.tools) { + await ctx.apis.tools.register({ + tool: { + id: definition.id, + title: definition.title, + description: definition.description, + activation: { + keywords: definition.activation?.keywords ?? [], + patterns: (definition.activation?.patterns ?? []).map(pattern => pattern.source), + }, + parameters: await serializeToolParameters(definition.inputSchema), + }, + availability: definition.isAvailable + ? () => definition.isAvailable?.(executionContext) + : undefined, + execute: input => definition.execute(input, executionContext), + }) + } +} diff --git a/packages/plugin-sdk-tamagotchi/tsconfig.json b/packages/plugin-sdk-tamagotchi/tsconfig.json new file mode 100644 index 000000000..c58de42e2 --- /dev/null +++ b/packages/plugin-sdk-tamagotchi/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": [ + "ESNext", + "DOM" + ], + "module": "ESNext", + "moduleResolution": "bundler", + "types": [ + "node" + ], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/plugin-sdk-tamagotchi/tsdown.config.ts b/packages/plugin-sdk-tamagotchi/tsdown.config.ts new file mode 100644 index 000000000..96e0b98f4 --- /dev/null +++ b/packages/plugin-sdk-tamagotchi/tsdown.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: [ + 'src/index.ts', + 'src/gamelet/index.ts', + 'src/tools/index.ts', + ], + dts: true, + format: 'esm', +}) diff --git a/packages/plugin-sdk-tamagotchi/vitest.config.ts b/packages/plugin-sdk-tamagotchi/vitest.config.ts new file mode 100644 index 000000000..ceafc2412 --- /dev/null +++ b/packages/plugin-sdk-tamagotchi/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}) diff --git a/plugins/airi-plugin-game-chess/package.json b/plugins/airi-plugin-game-chess/package.json new file mode 100644 index 000000000..984526626 --- /dev/null +++ b/plugins/airi-plugin-game-chess/package.json @@ -0,0 +1,37 @@ +{ + "name": "@proj-airi/airi-plugin-game-chess", + "type": "module", + "version": "0.1.0", + "private": true, + "description": "Chess plugin for AIRI gamelet/widget runtime", + "scripts": { + "test": "vitest run --root . --config vitest.config.ts", + "eval:run": "vieval run --root . --config ./vieval.config.ts", + "typecheck": "vue-tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@moeru/std": "catalog:", + "@proj-airi/plugin-sdk-tamagotchi": "workspace:*", + "@proj-airi/ui": "workspace:^", + "animejs": "^4.3.6", + "chess.js": "catalog:", + "reka-ui": "catalog:", + "stockfish": "catalog:", + "vue": "catalog:" + }, + "devDependencies": { + "@ax-llm/ax": "catalog:", + "@proj-airi/plugin-sdk": "workspace:*", + "@proj-airi/stage-ui": "workspace:^", + "@proj-airi/unocss-preset-chromatic": "^1.0.2", + "@unocss/reset": "^66.6.7", + "@vitejs/plugin-vue": "^6.0.5", + "@xsai-ext/providers": "catalog:", + "@xsai/generate-text": "catalog:", + "@xsai/shared-chat": "catalog:", + "@xsai/stream-text": "catalog:", + "pinia": "catalog:", + "unocss": "^66.6.7", + "vieval": "catalog:" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63d2a3dcc..1164b180b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2624,6 +2624,22 @@ importers: xstate: specifier: ^5.30.0 version: 5.30.0 + devDependencies: + es-toolkit: + specifier: 'catalog:' + version: 1.43.0 + + packages/plugin-sdk-tamagotchi: + dependencies: + '@proj-airi/plugin-sdk': + specifier: workspace:* + version: link:../plugin-sdk + valibot: + specifier: 'catalog:' + version: 1.2.0(typescript@5.9.3) + xsschema: + specifier: 'catalog:' + version: 0.5.0-beta.2(@valibot/to-json-schema@1.0.0-rc.0(valibot@1.3.1(typescript@5.9.3)))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6) packages/scenarios-stage-tamagotchi-browser: dependencies: @@ -3803,6 +3819,9 @@ importers: '@moeru/std': specifier: 'catalog:' version: 0.1.0-beta.17 + '@proj-airi/plugin-sdk-tamagotchi': + specifier: workspace:* + version: link:../../packages/plugin-sdk-tamagotchi '@proj-airi/ui': specifier: workspace:^ version: link:../../packages/ui @@ -3825,6 +3844,9 @@ importers: '@ax-llm/ax': specifier: 'catalog:' version: 19.0.45(zod@4.3.6) + '@proj-airi/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk '@proj-airi/stage-ui': specifier: workspace:^ version: link:../../packages/stage-ui @@ -3858,9 +3880,6 @@ importers: vieval: specifier: 'catalog:' version: 0.0.1(@types/node@25.6.0)(chokidar@5.0.0)(dotenv@17.4.2)(esbuild@0.27.2)(giget@2.0.0)(jiti@2.6.1)(less@4.6.4)(magicast@0.5.2)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vue-tsc: - specifier: ^3.1.1 - version: 3.2.6(typescript@5.9.3) plugins/airi-plugin-homeassistant: dependencies: diff --git a/vitest.config.ts b/vitest.config.ts index d6b13bb9e..3eb7a5b95 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ 'packages/cap-vite', 'packages/vishot-runner-browser', 'packages/plugin-sdk', + 'packages/plugin-sdk-tamagotchi', 'packages/server-runtime', 'packages/server-sdk', 'packages/stage-shared',