@skaile/forge-app-actions 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -2
- package/dist/module.d.mts +23 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +37 -0
- package/dist/runtime/chat/index.d.ts +52 -0
- package/dist/runtime/chat/index.js +100 -0
- package/dist/runtime/chat/server.d.ts +36 -0
- package/dist/runtime/chat/server.js +27 -0
- package/dist/runtime/chat/types.d.ts +10 -0
- package/dist/runtime/chat/types.js +0 -0
- package/dist/runtime/client-capabilities.d.ts +29 -0
- package/dist/runtime/client-capabilities.js +25 -0
- package/dist/runtime/components-nuxt-ui/ForgeCapabilityApproval.d.vue.ts +2 -0
- package/dist/runtime/components-nuxt-ui/ForgeCapabilityApproval.vue +125 -0
- package/dist/runtime/components-nuxt-ui/ForgeCapabilityApproval.vue.d.ts +2 -0
- package/dist/runtime/components-nuxt-ui/ForgeCommandPalette.d.vue.ts +2 -0
- package/dist/runtime/components-nuxt-ui/ForgeCommandPalette.vue +218 -0
- package/dist/runtime/components-nuxt-ui/ForgeCommandPalette.vue.d.ts +2 -0
- package/dist/runtime/composables/defineClientCapability.d.ts +64 -0
- package/dist/runtime/composables/defineClientCapability.js +40 -0
- package/dist/runtime/composables/defineForgeAction.d.ts +82 -0
- package/dist/runtime/composables/defineForgeAction.js +147 -0
- package/dist/runtime/composables/useAgentCapabilities.d.ts +78 -0
- package/dist/runtime/composables/useAgentCapabilities.js +94 -0
- package/dist/runtime/composables/useCapabilityApproval.d.ts +109 -0
- package/dist/runtime/composables/useCapabilityApproval.js +74 -0
- package/dist/runtime/composables/useCapabilityInvocations.d.ts +150 -0
- package/dist/runtime/composables/useCapabilityInvocations.js +118 -0
- package/dist/runtime/composables/useCommandPalette.d.ts +28 -0
- package/dist/runtime/composables/useCommandPalette.js +49 -0
- package/dist/runtime/composables/usePaletteArgStep.d.ts +47 -0
- package/dist/runtime/composables/usePaletteArgStep.js +51 -0
- package/dist/runtime/internal/describe-error.d.ts +10 -0
- package/dist/runtime/internal/describe-error.js +12 -0
- package/dist/runtime/server/index.d.ts +7 -0
- package/dist/runtime/server/index.js +2 -0
- package/dist/runtime/server/utils/capability-registry.d.ts +35 -0
- package/dist/runtime/server/utils/capability-registry.js +24 -0
- package/dist/runtime/server/utils/client-capability-bridge.d.ts +647 -0
- package/dist/runtime/server/utils/client-capability-bridge.js +755 -0
- package/dist/runtime/shims-vue.d.ts +8 -0
- package/dist/runtime/types/client-capability.d.ts +159 -0
- package/dist/runtime/types/client-capability.js +0 -0
- package/dist/runtime/types/command-palette.d.ts +75 -0
- package/dist/runtime/types/command-palette.js +0 -0
- package/dist/runtime/types/forge-action.d.ts +152 -0
- package/dist/runtime/types/forge-action.js +0 -0
- package/dist/runtime/types/index.d.ts +3 -0
- package/dist/runtime/types/index.js +0 -0
- package/dist/types.d.mts +9 -0
- package/package.json +94 -4
- package/src/runtime/chat/index.ts +190 -0
- package/src/runtime/chat/server.ts +64 -0
- package/src/runtime/chat/types.ts +13 -0
- package/src/runtime/client-capabilities.ts +82 -0
- package/src/runtime/components-nuxt-ui/ForgeCapabilityApproval.vue +240 -0
- package/src/runtime/components-nuxt-ui/ForgeCommandPalette.vue +363 -0
- package/src/runtime/composables/defineClientCapability.ts +119 -0
- package/src/runtime/composables/defineForgeAction.ts +278 -0
- package/src/runtime/composables/useAgentCapabilities.ts +215 -0
- package/src/runtime/composables/useCapabilityApproval.ts +219 -0
- package/src/runtime/composables/useCapabilityInvocations.ts +325 -0
- package/src/runtime/composables/useCommandPalette.ts +97 -0
- package/src/runtime/composables/usePaletteArgStep.ts +110 -0
- package/src/runtime/internal/describe-error.ts +21 -0
- package/src/runtime/server/index.ts +8 -0
- package/src/runtime/server/utils/capability-registry.ts +66 -0
- package/src/runtime/server/utils/client-capability-bridge.ts +1201 -0
- package/src/runtime/shims-vue.d.ts +8 -0
- package/src/runtime/types/client-capability.ts +178 -0
- package/src/runtime/types/command-palette.ts +78 -0
- package/src/runtime/types/forge-action.ts +166 -0
- package/src/runtime/types/index.ts +24 -0
package/README.md
CHANGED
|
@@ -1,3 +1,62 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @skaile/forge-app-actions
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Browser-declared app actions for Skaile forge apps, as a Nuxt module.
|
|
4
|
+
|
|
5
|
+
One `defineForgeAction` declaration is both a command-palette row and an agent capability. The package ships
|
|
6
|
+
|
|
7
|
+
- `defineForgeAction`, `useForgeActions`, `defineClientCapability` (auto-imported in a Nuxt app, or imported from `@skaile/forge-app-actions/client-capabilities`),
|
|
8
|
+
- `useAgentCapabilities` (announce) and `useCapabilityInvocations` (handle `capability_invoked` from the app's SSE and post the result),
|
|
9
|
+
- `useCapabilityApproval` + `<ForgeCapabilityApproval />` (approval dialog),
|
|
10
|
+
- `useCommandPalette` + `<ForgeCommandPalette />`,
|
|
11
|
+
- the Nitro-side bridge (`clientCapabilityBridge`, `capabilityRegistry`) via `@skaile/forge-app-actions/server`.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// nuxt.config.ts — @nuxt/ui must come first
|
|
17
|
+
export default defineNuxtConfig({
|
|
18
|
+
modules: ['@nuxt/ui', '@skaile/forge-app-actions'],
|
|
19
|
+
})
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Peers: `@nuxt/ui ^4.11`, `nuxt`, `vue`; `@skaile/workspaces ^6` is an optional peer (type-only imports from `@skaile/workspaces/sdk/types`).
|
|
23
|
+
|
|
24
|
+
`@skaile/forge-common` installs this module for you.
|
|
25
|
+
|
|
26
|
+
## Exports
|
|
27
|
+
|
|
28
|
+
| Subpath | Contents |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `.` | the Nuxt module (`forgeAppActions` config key) |
|
|
31
|
+
| `./client-capabilities` | browser-side API, explicit-import entry for tests and non-auto-import contexts |
|
|
32
|
+
| `./chat` | `useChatActions`, `toolNameOf` — forge actions as forge-chat client tools (browser) |
|
|
33
|
+
| `./chat/server` | `clientToolsFromBody` — validates the `clientTools` request field (no Vue) |
|
|
34
|
+
| `./server` | `clientCapabilityBridge`, `capabilityRegistry`, `routeCapabilityInvocation`, … |
|
|
35
|
+
| `./types` | `ClientCapability*`, `ForgeAction*`, `Palette*` types |
|
|
36
|
+
|
|
37
|
+
Moved here from `@skaile-ai/forge-common` 9.x (`./client-capabilities` and the capability parts of `./server` and `./types`).
|
|
38
|
+
|
|
39
|
+
## Using actions from an AI-SDK chat
|
|
40
|
+
|
|
41
|
+
A plain Nuxt app with an AI-SDK route (no Skaile platform, no SSE agent session) can let the model call the browser's actions through `@skaile/forge-chat` ≥ 0.2 client tools. Every action whose `audience` includes `'llm'` becomes a tool; `requiresApproval` actions wait for the user's confirmation first.
|
|
42
|
+
|
|
43
|
+
**Tool names.** Model providers reject dots in tool names (Anthropic `^[a-zA-Z0-9_-]{1,64}$`, OpenAI `^[a-zA-Z0-9_-]+$`), so the tool name is `toolNameOf(capabilityName)`: every character outside `[A-Za-z0-9_-]` becomes `_`, truncated to 64. Action `doc.read` (capability `app.doc.read`) is the tool `app_doc_read`. Name tools in prompts with `toolNameOf('app.doc.read')` (exported from `@skaile/forge-app-actions/chat`). Two actions that map to the same tool name make `descriptors()` and the handler lookup throw. The handler still runs the action under its real capability name (`ctx.name === 'app.doc.read'`). Descriptor schemas are the zod-derived JSON Schema without the top-level `$schema` key.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
// app/pages/index.vue
|
|
47
|
+
const chatActions = useChatActions({ actions: () => actions }) // from '@skaile/forge-app-actions/chat'
|
|
48
|
+
const transport = createAiSdkTransport({ url: '/api/chat', body: () => ({ clientTools: chatActions.descriptors() }) })
|
|
49
|
+
const session = createChatSession({ transport, tools: chatActions.handlers })
|
|
50
|
+
// app/app.vue: <ForgeCapabilityApproval /> — mount once
|
|
51
|
+
|
|
52
|
+
// server/api/chat.post.ts
|
|
53
|
+
import { clientToolsFromBody } from '@skaile/forge-app-actions/chat/server'
|
|
54
|
+
export default defineEventHandler((event) =>
|
|
55
|
+
createAiSdkChatHandler({ model, clientTools: (ctx) => clientToolsFromBody(ctx.body) })(toWebRequest(event)))
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Round trip: the model emits a tool call → the handler ends the turn with `turn-end` reason `tool-calls` → the session runs `chatActions.handlers[name]` → the result is pushed and the turn resumes. A handler validates the input against the action's zod schema first (invalid → the model gets `invalid input: <issues>`), then asks for approval when required (`declined` → the model gets `declined`), then runs the action with `source: 'llm'`.
|
|
59
|
+
|
|
60
|
+
`clientToolsFromBody` returns `undefined` when the body has no `clientTools`, and throws an error with `statusCode: 400` (`clientTools: <issues>`) for a bad tool name, an over-long description or a non-object schema — `createAiSdkChatHandler` answers such an error with that status.
|
|
61
|
+
|
|
62
|
+
Peer: `@skaile/forge-chat ^0.2.0` (optional; type-only imports).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { NuxtModule } from '@nuxt/schema';
|
|
2
|
+
|
|
3
|
+
interface ForgeAppActionsOptions {
|
|
4
|
+
/** Disable server-side auto-imports (use explicit imports via the `./server` subpath instead). */
|
|
5
|
+
serverAutoImport?: boolean;
|
|
6
|
+
/** Disable component auto-registration. */
|
|
7
|
+
componentAutoRegister?: boolean;
|
|
8
|
+
/** Disable composable auto-imports. */
|
|
9
|
+
composableAutoImport?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Nuxt module for browser-declared app actions: `defineForgeAction` /
|
|
13
|
+
* `defineClientCapability`, the agent capability bridge, the command palette
|
|
14
|
+
* and the approval dialog.
|
|
15
|
+
*
|
|
16
|
+
* Requires `@nuxt/ui` (^4.11) registered BEFORE this module in `modules`; the
|
|
17
|
+
* components render Nuxt UI primitives (<UModal>, <UButton>, <UInput>) and the
|
|
18
|
+
* host root must be wrapped in `<UApp>`.
|
|
19
|
+
*/
|
|
20
|
+
declare const forgeAppActionsModule: NuxtModule<ForgeAppActionsOptions>;
|
|
21
|
+
|
|
22
|
+
export { forgeAppActionsModule as default };
|
|
23
|
+
export type { ForgeAppActionsOptions };
|
package/dist/module.json
ADDED
package/dist/module.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { defineNuxtModule, createResolver, hasNuxtModule, addServerImportsDir, addComponentsDir, addImportsDir } from '@nuxt/kit';
|
|
2
|
+
|
|
3
|
+
const forgeAppActionsModule = defineNuxtModule({
|
|
4
|
+
meta: {
|
|
5
|
+
name: "@skaile/forge-app-actions",
|
|
6
|
+
configKey: "forgeAppActions"
|
|
7
|
+
},
|
|
8
|
+
defaults: {
|
|
9
|
+
serverAutoImport: true,
|
|
10
|
+
componentAutoRegister: true,
|
|
11
|
+
composableAutoImport: true
|
|
12
|
+
},
|
|
13
|
+
async setup(options, nuxt) {
|
|
14
|
+
const { resolve } = createResolver(import.meta.url);
|
|
15
|
+
if (!nuxt.options._prepare && !hasNuxtModule("@nuxt/ui", nuxt)) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
"[@skaile/forge-app-actions] requires the '@nuxt/ui' module, but it was not registered before '@skaile/forge-app-actions'. Add '@nuxt/ui' to the `modules` array in your nuxt.config *before* '@skaile/forge-app-actions' (and install it as a dependency): the command palette and approval dialog render Nuxt UI primitives (<UModal>, <UButton>, \u2026)."
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
if (options.serverAutoImport) {
|
|
21
|
+
addServerImportsDir(resolve("./runtime/server/utils"));
|
|
22
|
+
}
|
|
23
|
+
if (options.componentAutoRegister) {
|
|
24
|
+
addComponentsDir({
|
|
25
|
+
path: resolve("./runtime/components-nuxt-ui"),
|
|
26
|
+
prefix: "Forge",
|
|
27
|
+
global: false,
|
|
28
|
+
pathPrefix: false
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (options.composableAutoImport) {
|
|
32
|
+
addImportsDir(resolve("./runtime/composables"));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export { forgeAppActionsModule as default };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of the AI-SDK chat binding (`@skaile/forge-app-actions/chat`).
|
|
3
|
+
*
|
|
4
|
+
* Turns registered {@link ForgeAction}s into forge-chat **client tools**:
|
|
5
|
+
*
|
|
6
|
+
* - `descriptors()` — what the model may call. Goes into the chat request body
|
|
7
|
+
* (`createAiSdkTransport({ body })`); the server validates it with
|
|
8
|
+
* `clientToolsFromBody` from `@skaile/forge-app-actions/chat/server`.
|
|
9
|
+
* - `handlers` — what runs when the model calls one. Pass as
|
|
10
|
+
* `createChatSession({ tools })`. Same contract as the SSE bridge's
|
|
11
|
+
* `useCapabilityInvocations().handle`: validate against the live zod schema,
|
|
12
|
+
* ask for approval (`<ForgeCapabilityApproval />`) when the action declares
|
|
13
|
+
* `requiresApproval`, then run the handler with a `ClientCapabilityContext`.
|
|
14
|
+
*
|
|
15
|
+
* Only actions whose audience includes `'llm'` are exposed. Tools are named
|
|
16
|
+
* {@link toolNameOf}(capability name) — `app.<id>` becomes `app_<id>` — because
|
|
17
|
+
* model providers reject dots in tool names.
|
|
18
|
+
*/
|
|
19
|
+
import type { ClientCapabilityLogger } from "../types/client-capability.js";
|
|
20
|
+
import type { ForgeAction } from "../types/forge-action.js";
|
|
21
|
+
import type { ClientToolDecls, ClientToolHandlers } from "./types.js";
|
|
22
|
+
export type { ClientToolDecls, ClientToolHandlers } from "./types.js";
|
|
23
|
+
export interface UseChatActionsOptions {
|
|
24
|
+
/**
|
|
25
|
+
* The actions to expose, read on every use — the list may change over the
|
|
26
|
+
* lifetime of the chat (an editor that mounts later, a route-scoped action).
|
|
27
|
+
*/
|
|
28
|
+
actions: () => readonly ForgeAction[];
|
|
29
|
+
/** Opaque session key for the handler's {@link ClientCapabilityContext}. Informational. */
|
|
30
|
+
sessionKey?: string;
|
|
31
|
+
/** Logger handed to handlers. Defaults to `console`. */
|
|
32
|
+
log?: ClientCapabilityLogger;
|
|
33
|
+
/**
|
|
34
|
+
* Deadline for an unanswered approval dialog, in ms; the action's own
|
|
35
|
+
* `callTimeoutMs` wins. Defaults to {@link DEFAULT_APPROVAL_TIMEOUT_MS}; `0`
|
|
36
|
+
* disables the deadline.
|
|
37
|
+
*/
|
|
38
|
+
approvalTimeoutMs?: number;
|
|
39
|
+
}
|
|
40
|
+
export interface UseChatActionsReturn {
|
|
41
|
+
/** Tool descriptors for the request body: one per LLM-audience action. */
|
|
42
|
+
descriptors(): ClientToolDecls;
|
|
43
|
+
/** Tool handlers for `createChatSession({ tools })`, resolved against `actions()` at call time. */
|
|
44
|
+
handlers: ClientToolHandlers;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The provider-safe tool name of a capability name: every character outside
|
|
48
|
+
* `[A-Za-z0-9_-]` becomes `_` (Anthropic and OpenAI reject others), truncated
|
|
49
|
+
* to 64. `app.doc.read` → `app_doc_read`. Hosts use it to name tools in prompts.
|
|
50
|
+
*/
|
|
51
|
+
export declare function toolNameOf(capabilityName: string): string;
|
|
52
|
+
export declare function useChatActions(options: UseChatActionsOptions): UseChatActionsReturn;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_APPROVAL_TIMEOUT_MS,
|
|
3
|
+
useCapabilityApproval
|
|
4
|
+
} from "../composables/useCapabilityApproval.js";
|
|
5
|
+
import { describeError } from "../internal/describe-error.js";
|
|
6
|
+
const defaultLog = {
|
|
7
|
+
info: (message, ...args) => console.log(message, ...args),
|
|
8
|
+
warn: (message, ...args) => console.warn(message, ...args),
|
|
9
|
+
error: (message, ...args) => console.error(message, ...args)
|
|
10
|
+
};
|
|
11
|
+
const TOOL_NAME_MAX = 64;
|
|
12
|
+
export function toolNameOf(capabilityName) {
|
|
13
|
+
return capabilityName.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, TOOL_NAME_MAX);
|
|
14
|
+
}
|
|
15
|
+
function withoutSchemaKey(schema) {
|
|
16
|
+
const { $schema: _draft, ...rest } = schema;
|
|
17
|
+
return rest;
|
|
18
|
+
}
|
|
19
|
+
export function useChatActions(options) {
|
|
20
|
+
const log = options.log ?? defaultLog;
|
|
21
|
+
function exposed() {
|
|
22
|
+
const byTool = /* @__PURE__ */ new Map();
|
|
23
|
+
for (const action of options.actions()) {
|
|
24
|
+
const capability = action.capability;
|
|
25
|
+
if (!capability || !action.audience.includes("llm")) continue;
|
|
26
|
+
const toolName = toolNameOf(capability.name);
|
|
27
|
+
const other = byTool.get(toolName);
|
|
28
|
+
if (other && other.name !== capability.name) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`[useChatActions] "${other.name}" and "${capability.name}" both map to the tool name "${toolName}"; rename one action.`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
byTool.set(toolName, capability);
|
|
34
|
+
}
|
|
35
|
+
return byTool;
|
|
36
|
+
}
|
|
37
|
+
function descriptors() {
|
|
38
|
+
const decls = {};
|
|
39
|
+
for (const [toolName, capability] of exposed()) {
|
|
40
|
+
decls[toolName] = {
|
|
41
|
+
description: capability.description,
|
|
42
|
+
// Derived by `defineClientCapability` via `z.toJSONSchema()`; the
|
|
43
|
+
// draft marker is dropped, providers do not take it.
|
|
44
|
+
inputSchema: withoutSchemaKey(capability.inputSchema)
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return decls;
|
|
48
|
+
}
|
|
49
|
+
function handlerFor(toolName) {
|
|
50
|
+
const capability = exposed().get(toolName);
|
|
51
|
+
if (!capability) return void 0;
|
|
52
|
+
const name = capability.name;
|
|
53
|
+
return async (rawInput, { toolCallId, signal }) => {
|
|
54
|
+
if (signal.aborted) throw new Error("aborted");
|
|
55
|
+
const checked = capability.inputZod.safeParse(rawInput ?? {});
|
|
56
|
+
if (!checked.success) throw new Error(`invalid input: ${describeError(checked.error)}`);
|
|
57
|
+
const input = checked.data;
|
|
58
|
+
if (capability.requiresApproval) {
|
|
59
|
+
const decision = await useCapabilityApproval().request({
|
|
60
|
+
callId: toolCallId,
|
|
61
|
+
name,
|
|
62
|
+
displayName: capability.displayName,
|
|
63
|
+
description: capability.description,
|
|
64
|
+
input,
|
|
65
|
+
timeoutMs: capability.callTimeoutMs ?? options.approvalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS,
|
|
66
|
+
signal
|
|
67
|
+
});
|
|
68
|
+
if (decision === "declined") throw new Error("declined");
|
|
69
|
+
if (decision === "timeout") throw new Error("approval timed out");
|
|
70
|
+
if (decision !== "approved") throw new Error("approval cancelled");
|
|
71
|
+
}
|
|
72
|
+
const ctx = {
|
|
73
|
+
sessionKey: options.sessionKey ?? "",
|
|
74
|
+
callId: toolCallId,
|
|
75
|
+
name,
|
|
76
|
+
log
|
|
77
|
+
};
|
|
78
|
+
const result = await capability.handler(input, ctx);
|
|
79
|
+
if (capability.outputZod) {
|
|
80
|
+
const check = capability.outputZod.safeParse(result);
|
|
81
|
+
if (!check.success) {
|
|
82
|
+
log.warn(
|
|
83
|
+
`[useChatActions] ${name} returned a value that does not match its declared output schema: ${describeError(check.error)}`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return result ?? null;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const handlers = new Proxy({}, {
|
|
91
|
+
get: (_target, key) => typeof key === "string" ? handlerFor(key) : void 0,
|
|
92
|
+
has: (_target, key) => typeof key === "string" && exposed().has(key),
|
|
93
|
+
ownKeys: () => [...exposed().keys()],
|
|
94
|
+
getOwnPropertyDescriptor: (_target, key) => {
|
|
95
|
+
const handler = typeof key === "string" ? handlerFor(key) : void 0;
|
|
96
|
+
return handler ? { value: handler, enumerable: true, configurable: true, writable: false } : void 0;
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
return { descriptors, handlers };
|
|
100
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server half of the AI-SDK chat binding (`@skaile/forge-app-actions/chat/server`).
|
|
3
|
+
*
|
|
4
|
+
* The browser sends the descriptors of its LLM-audience actions in the chat
|
|
5
|
+
* request body (`clientTools`). This module validates that untrusted blob so a
|
|
6
|
+
* host can hand it straight to `createAiSdkChatHandler`:
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* createAiSdkChatHandler({ clientTools: (ctx) => clientToolsFromBody(ctx.body), … })
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* No Vue, no Nuxt, no `ai` import — safe for any Nitro route.
|
|
13
|
+
*/
|
|
14
|
+
import type { ClientToolDecls } from "./types.js";
|
|
15
|
+
/** Same pattern forge-chat's handler enforces; checked here first for a precise message. */
|
|
16
|
+
export declare const CLIENT_TOOL_NAME_PATTERN: RegExp;
|
|
17
|
+
/** Upper bound on a tool description — descriptors come from an untrusted browser. */
|
|
18
|
+
export declare const CLIENT_TOOL_DESCRIPTION_MAX = 2000;
|
|
19
|
+
/**
|
|
20
|
+
* Thrown for a malformed `clientTools` body field. Carries `statusCode: 400`, so
|
|
21
|
+
* `createAiSdkChatHandler` answers with that status when it is thrown from an
|
|
22
|
+
* option callback.
|
|
23
|
+
*/
|
|
24
|
+
export declare class ClientToolsBodyError extends Error {
|
|
25
|
+
readonly statusCode = 400;
|
|
26
|
+
constructor(message: string);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Read and validate `body.clientTools`.
|
|
30
|
+
*
|
|
31
|
+
* - absent (`undefined`) → `undefined`
|
|
32
|
+
* - valid → the descriptors, unchanged
|
|
33
|
+
* - malformed → throws {@link ClientToolsBodyError} (`statusCode` 400,
|
|
34
|
+
* message `clientTools: <path>: <issue>; …`)
|
|
35
|
+
*/
|
|
36
|
+
export declare function clientToolsFromBody(body: Record<string, unknown>): ClientToolDecls | undefined;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { describeError } from "../internal/describe-error.js";
|
|
3
|
+
export const CLIENT_TOOL_NAME_PATTERN = /^[a-z][a-z0-9_.-]{0,63}$/i;
|
|
4
|
+
export const CLIENT_TOOL_DESCRIPTION_MAX = 2e3;
|
|
5
|
+
const clientToolsSchema = z.record(
|
|
6
|
+
z.string().regex(CLIENT_TOOL_NAME_PATTERN, "invalid tool name"),
|
|
7
|
+
z.object({
|
|
8
|
+
description: z.string().max(CLIENT_TOOL_DESCRIPTION_MAX),
|
|
9
|
+
inputSchema: z.record(z.string(), z.unknown())
|
|
10
|
+
})
|
|
11
|
+
);
|
|
12
|
+
export class ClientToolsBodyError extends Error {
|
|
13
|
+
statusCode = 400;
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "ClientToolsBodyError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function clientToolsFromBody(body) {
|
|
20
|
+
const raw = body.clientTools;
|
|
21
|
+
if (raw === void 0) return void 0;
|
|
22
|
+
const parsed = clientToolsSchema.safeParse(raw);
|
|
23
|
+
if (!parsed.success) {
|
|
24
|
+
throw new ClientToolsBodyError(`clientTools: ${describeError(parsed.error)}`);
|
|
25
|
+
}
|
|
26
|
+
return parsed.data;
|
|
27
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two forge-chat contracts this binding speaks. Type-only, so
|
|
3
|
+
* `@skaile/forge-chat` stays an optional peer.
|
|
4
|
+
*
|
|
5
|
+
* `ClientToolHandlers` is derived from `ChatSessionOptions['tools']` because
|
|
6
|
+
* forge-chat does not export the alias by name.
|
|
7
|
+
*/
|
|
8
|
+
import type { ChatSessionOptions } from "@skaile/forge-chat/core";
|
|
9
|
+
export type { ClientToolDecls } from "@skaile/forge-chat/server/ai-sdk";
|
|
10
|
+
export type ClientToolHandlers = NonNullable<ChatSessionOptions["tools"]>;
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit entry point for the browser-side agent capability API.
|
|
3
|
+
*
|
|
4
|
+
* In a Nuxt consumer both symbols are auto-imported (the module's
|
|
5
|
+
* `addImportsDir` on `runtime/composables`), so application code needs no
|
|
6
|
+
* import at all. This subpath exists for the contexts auto-imports do not
|
|
7
|
+
* reach: vitest specs, plain `.ts` modules outside the Nuxt app dir, and
|
|
8
|
+
* non-Nuxt consumers.
|
|
9
|
+
*
|
|
10
|
+
* import { defineClientCapability } from "@skaile/forge-app-actions/client-capabilities"
|
|
11
|
+
*
|
|
12
|
+
* Since ticket 07 this is also where the *unified* action API lives —
|
|
13
|
+
* `defineForgeAction` / `useForgeActions`, plus `useCommandPalette` itself, so a
|
|
14
|
+
* consumer's tests can reach both halves of one declaration without relying on
|
|
15
|
+
* Nuxt's auto-imports.
|
|
16
|
+
*
|
|
17
|
+
* The Nitro-side counterpart (`ClientCapabilityBridge`) is server-only and
|
|
18
|
+
* ships through the `./server` subpath instead.
|
|
19
|
+
*/
|
|
20
|
+
export { defineClientCapability, deprecatedClientCapabilityAlias, } from "./composables/defineClientCapability.js";
|
|
21
|
+
export { DEFAULT_FORGE_ACTION_AUDIENCE, defineForgeAction, type UseForgeActionsReturn, useForgeActions, } from "./composables/defineForgeAction.js";
|
|
22
|
+
export { type AnnounceOptions, type AnnounceResult, type CapabilityAnnounceFetcher, type UseAgentCapabilitiesReturn, useAgentCapabilities, } from "./composables/useAgentCapabilities.js";
|
|
23
|
+
export { type CapabilityApprovalDecision, type CapabilityApprovalOptions, type CapabilityApprovalRequest, DEFAULT_APPROVAL_TIMEOUT_MS, type UseCapabilityApprovalReturn, useCapabilityApproval, } from "./composables/useCapabilityApproval.js";
|
|
24
|
+
export { type CapabilityInvokedEvent, type HandleInvocationOptions, type InvocationFailure, type InvocationOutcome, type UseCapabilityInvocationsReturn, useCapabilityInvocations, } from "./composables/useCapabilityInvocations.js";
|
|
25
|
+
export { type UseCommandPaletteReturn, useCommandPalette, } from "./composables/useCommandPalette.js";
|
|
26
|
+
export { type UsePaletteArgStepReturn, usePaletteArgStep, } from "./composables/usePaletteArgStep.js";
|
|
27
|
+
export type { ClientCapabilityAnnouncement, ClientCapabilityContext, ClientCapabilityDescriptor, ClientCapabilityLogger, ClientCapabilityOrigin, DefineClientCapabilityArgs, DefinedClientCapability, } from "./types/client-capability.js";
|
|
28
|
+
export type { PaletteAction, PaletteArgOption, PaletteArgPrompt, } from "./types/command-palette.js";
|
|
29
|
+
export type { DefineForgeActionArgs, ForgeAction, ForgeActionArg, ForgeActionAudience, ForgeActionContext, } from "./types/forge-action.js";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export {
|
|
2
|
+
defineClientCapability,
|
|
3
|
+
deprecatedClientCapabilityAlias
|
|
4
|
+
} from "./composables/defineClientCapability.js";
|
|
5
|
+
export {
|
|
6
|
+
DEFAULT_FORGE_ACTION_AUDIENCE,
|
|
7
|
+
defineForgeAction,
|
|
8
|
+
useForgeActions
|
|
9
|
+
} from "./composables/defineForgeAction.js";
|
|
10
|
+
export {
|
|
11
|
+
useAgentCapabilities
|
|
12
|
+
} from "./composables/useAgentCapabilities.js";
|
|
13
|
+
export {
|
|
14
|
+
DEFAULT_APPROVAL_TIMEOUT_MS,
|
|
15
|
+
useCapabilityApproval
|
|
16
|
+
} from "./composables/useCapabilityApproval.js";
|
|
17
|
+
export {
|
|
18
|
+
useCapabilityInvocations
|
|
19
|
+
} from "./composables/useCapabilityInvocations.js";
|
|
20
|
+
export {
|
|
21
|
+
useCommandPalette
|
|
22
|
+
} from "./composables/useCommandPalette.js";
|
|
23
|
+
export {
|
|
24
|
+
usePaletteArgStep
|
|
25
|
+
} from "./composables/usePaletteArgStep.js";
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
export default _default;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, onMounted, onUnmounted, ref } from "vue";
|
|
3
|
+
import { useCapabilityApproval } from "../composables/useCapabilityApproval";
|
|
4
|
+
const { current, approve, decline } = useCapabilityApproval();
|
|
5
|
+
const title = computed(() => current.value?.displayName || current.value?.name || "");
|
|
6
|
+
const argumentRows = computed(() => {
|
|
7
|
+
const input = current.value?.input;
|
|
8
|
+
if (input === void 0 || input === null) return [];
|
|
9
|
+
if (typeof input === "object" && !Array.isArray(input)) {
|
|
10
|
+
return Object.entries(input).map(([key, value]) => ({
|
|
11
|
+
key,
|
|
12
|
+
value: format(value)
|
|
13
|
+
}));
|
|
14
|
+
}
|
|
15
|
+
return [{ key: "", value: format(input) }];
|
|
16
|
+
});
|
|
17
|
+
function format(value) {
|
|
18
|
+
if (typeof value === "string") return value;
|
|
19
|
+
try {
|
|
20
|
+
return JSON.stringify(value);
|
|
21
|
+
} catch {
|
|
22
|
+
return String(value);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const now = ref(Date.now());
|
|
26
|
+
let ticker;
|
|
27
|
+
onMounted(() => {
|
|
28
|
+
ticker = setInterval(() => {
|
|
29
|
+
now.value = Date.now();
|
|
30
|
+
}, 500);
|
|
31
|
+
});
|
|
32
|
+
onUnmounted(() => clearInterval(ticker));
|
|
33
|
+
const secondsLeft = computed(() => {
|
|
34
|
+
const request = current.value;
|
|
35
|
+
if (request?.expiresAt === void 0) return null;
|
|
36
|
+
const remaining = Math.min(
|
|
37
|
+
request.expiresAt - now.value,
|
|
38
|
+
request.timeoutMs ?? Number.MAX_SAFE_INTEGER
|
|
39
|
+
);
|
|
40
|
+
return Math.max(0, Math.ceil(remaining / 1e3));
|
|
41
|
+
});
|
|
42
|
+
function onApprove() {
|
|
43
|
+
const id = current.value?.id;
|
|
44
|
+
if (id) approve(id);
|
|
45
|
+
}
|
|
46
|
+
function onDecline() {
|
|
47
|
+
const id = current.value?.id;
|
|
48
|
+
if (id) decline(id);
|
|
49
|
+
}
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<template>
|
|
53
|
+
<UModal
|
|
54
|
+
:open="current !== null"
|
|
55
|
+
title="Allow this action?"
|
|
56
|
+
:ui="{ content: 'max-w-[480px] w-[95vw]' }"
|
|
57
|
+
@update:open="(v) => !v && onDecline()"
|
|
58
|
+
>
|
|
59
|
+
<template #content>
|
|
60
|
+
<div class="forge-capability-approval" data-testid="capability-approval" @keydown.esc.prevent="onDecline">
|
|
61
|
+
<h2 class="forge-capability-approval__heading">Allow this action?</h2>
|
|
62
|
+
|
|
63
|
+
<template v-if="current">
|
|
64
|
+
<p class="forge-capability-approval__intro">
|
|
65
|
+
The agent wants to run
|
|
66
|
+
<strong data-testid="capability-approval-title">{{ title }}</strong>
|
|
67
|
+
</p>
|
|
68
|
+
|
|
69
|
+
<code class="forge-capability-approval__name" data-testid="capability-approval-name">{{
|
|
70
|
+
current.name
|
|
71
|
+
}}</code>
|
|
72
|
+
|
|
73
|
+
<p
|
|
74
|
+
v-if="current.description"
|
|
75
|
+
class="forge-capability-approval__description"
|
|
76
|
+
data-testid="capability-approval-description"
|
|
77
|
+
>
|
|
78
|
+
{{ current.description }}
|
|
79
|
+
</p>
|
|
80
|
+
|
|
81
|
+
<div class="forge-capability-approval__args" data-testid="capability-approval-args">
|
|
82
|
+
<div class="forge-capability-approval__args-label">Arguments</div>
|
|
83
|
+
<div v-if="argumentRows.length === 0" class="forge-capability-approval__arg-empty">
|
|
84
|
+
none
|
|
85
|
+
</div>
|
|
86
|
+
<div
|
|
87
|
+
v-for="row in argumentRows"
|
|
88
|
+
:key="row.key"
|
|
89
|
+
class="forge-capability-approval__arg"
|
|
90
|
+
>
|
|
91
|
+
<span v-if="row.key" class="forge-capability-approval__arg-key">{{ row.key }}</span>
|
|
92
|
+
<span class="forge-capability-approval__arg-value">{{ row.value }}</span>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<p
|
|
97
|
+
v-if="secondsLeft !== null"
|
|
98
|
+
class="forge-capability-approval__countdown"
|
|
99
|
+
data-testid="capability-approval-countdown"
|
|
100
|
+
>
|
|
101
|
+
Expires in {{ secondsLeft }}s if unanswered.
|
|
102
|
+
</p>
|
|
103
|
+
</template>
|
|
104
|
+
|
|
105
|
+
<div class="forge-capability-approval__actions">
|
|
106
|
+
<!-- Decline is focused first on purpose: this dialog can appear while
|
|
107
|
+
the user is typing, and a stray Enter must not approve an action. -->
|
|
108
|
+
<UButton
|
|
109
|
+
label="Decline"
|
|
110
|
+
color="neutral"
|
|
111
|
+
variant="subtle"
|
|
112
|
+
autofocus
|
|
113
|
+
data-testid="capability-approval-decline"
|
|
114
|
+
@click="onDecline"
|
|
115
|
+
/>
|
|
116
|
+
<UButton label="Allow" data-testid="capability-approval-approve" @click="onApprove" />
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</template>
|
|
120
|
+
</UModal>
|
|
121
|
+
</template>
|
|
122
|
+
|
|
123
|
+
<style scoped>
|
|
124
|
+
.forge-capability-approval{display:flex;flex-direction:column;font-size:.9rem;gap:10px;padding:16px}.forge-capability-approval__heading{font-size:1rem;font-weight:600;margin:0}.forge-capability-approval__intro{margin:0}.forge-capability-approval__name{align-self:flex-start;background:#f1f5f9;border-radius:4px;color:#475569;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.8rem;padding:2px 6px}.forge-capability-approval__description{color:#64748b;margin:0}.forge-capability-approval__args{background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;display:flex;flex-direction:column;gap:4px;padding:8px 10px}.forge-capability-approval__args-label{color:#94a3b8;font-size:.7rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase}.forge-capability-approval__arg{align-items:baseline;display:flex;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.8rem;gap:8px;word-break:break-all}.forge-capability-approval__arg-key{color:#64748b;flex-shrink:0}.forge-capability-approval__arg-key:after{content:":"}.forge-capability-approval__arg-empty{color:#94a3b8;font-size:.8rem}.forge-capability-approval__countdown{color:#94a3b8;font-size:.75rem;margin:0}.forge-capability-approval__actions{display:flex;gap:8px;justify-content:flex-end;margin-top:4px}
|
|
125
|
+
</style>
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
export default _default;
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
export default _default;
|