diff --git a/packages/builtin-tool-user-interaction/package.json b/packages/builtin-tool-user-interaction/package.json new file mode 100644 index 0000000000..99a6a8634a --- /dev/null +++ b/packages/builtin-tool-user-interaction/package.json @@ -0,0 +1,15 @@ +{ + "name": "@lobechat/builtin-tool-user-interaction", + "version": "1.0.0", + "private": true, + "exports": { + ".": "./src/index.ts", + "./client": "./src/client/index.ts", + "./executor": "./src/executor/index.ts", + "./executionRuntime": "./src/ExecutionRuntime/index.ts" + }, + "main": "./src/index.ts", + "devDependencies": { + "@lobechat/types": "workspace:*" + } +} diff --git a/packages/builtin-tool-user-interaction/src/ExecutionRuntime/index.test.ts b/packages/builtin-tool-user-interaction/src/ExecutionRuntime/index.test.ts new file mode 100644 index 0000000000..35ee125b15 --- /dev/null +++ b/packages/builtin-tool-user-interaction/src/ExecutionRuntime/index.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; + +import { UserInteractionExecutionRuntime } from './index'; + +describe('UserInteractionExecutionRuntime', () => { + it('creates a pending interaction request', async () => { + const runtime = new UserInteractionExecutionRuntime(); + const result = await runtime.askUserQuestion({ + question: { id: 'q1', mode: 'freeform', prompt: 'What is your name?' }, + }); + + expect(result.success).toBe(true); + expect(result.state).toMatchObject({ + requestId: 'q1', + status: 'pending', + question: { id: 'q1', mode: 'freeform', prompt: 'What is your name?' }, + }); + }); + + it('marks interaction as submitted with response', async () => { + const runtime = new UserInteractionExecutionRuntime(); + await runtime.askUserQuestion({ + question: { id: 'q2', mode: 'form', prompt: 'Fill this form' }, + }); + + const result = await runtime.submitUserResponse({ + requestId: 'q2', + response: { name: 'Alice' }, + }); + + expect(result.success).toBe(true); + expect(result.state).toMatchObject({ + requestId: 'q2', + status: 'submitted', + response: { name: 'Alice' }, + }); + }); + + it('marks interaction as skipped with reason', async () => { + const runtime = new UserInteractionExecutionRuntime(); + await runtime.askUserQuestion({ + question: { id: 'q3', mode: 'freeform', prompt: 'Optional question' }, + }); + + const result = await runtime.skipUserResponse({ + requestId: 'q3', + reason: 'Not relevant', + }); + + expect(result.success).toBe(true); + expect(result.state).toMatchObject({ + requestId: 'q3', + status: 'skipped', + skipReason: 'Not relevant', + }); + }); + + it('marks interaction as cancelled', async () => { + const runtime = new UserInteractionExecutionRuntime(); + await runtime.askUserQuestion({ + question: { id: 'q4', mode: 'freeform', prompt: 'Will be cancelled' }, + }); + + const result = await runtime.cancelUserResponse({ requestId: 'q4' }); + + expect(result.success).toBe(true); + expect(result.state).toMatchObject({ + requestId: 'q4', + status: 'cancelled', + }); + }); + + it('gets current interaction state', async () => { + const runtime = new UserInteractionExecutionRuntime(); + await runtime.askUserQuestion({ + question: { id: 'q5', mode: 'freeform', prompt: 'Check state' }, + }); + + const result = await runtime.getInteractionState({ requestId: 'q5' }); + + expect(result.success).toBe(true); + expect(result.state).toMatchObject({ + requestId: 'q5', + status: 'pending', + }); + }); + + it('returns error for non-existent interaction', async () => { + const runtime = new UserInteractionExecutionRuntime(); + const result = await runtime.getInteractionState({ requestId: 'nonexistent' }); + + expect(result.success).toBe(false); + }); + + it('prevents submitting a non-pending interaction', async () => { + const runtime = new UserInteractionExecutionRuntime(); + await runtime.askUserQuestion({ + question: { id: 'q6', mode: 'freeform', prompt: 'Already done' }, + }); + await runtime.cancelUserResponse({ requestId: 'q6' }); + + const result = await runtime.submitUserResponse({ + requestId: 'q6', + response: { late: true }, + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/packages/builtin-tool-user-interaction/src/ExecutionRuntime/index.ts b/packages/builtin-tool-user-interaction/src/ExecutionRuntime/index.ts new file mode 100644 index 0000000000..a663071330 --- /dev/null +++ b/packages/builtin-tool-user-interaction/src/ExecutionRuntime/index.ts @@ -0,0 +1,125 @@ +import type { BuiltinServerRuntimeOutput } from '@lobechat/types'; + +import type { + AskUserQuestionArgs, + CancelUserResponseArgs, + GetInteractionStateArgs, + InteractionState, + SkipUserResponseArgs, + SubmitUserResponseArgs, +} from '../types'; + +export class UserInteractionExecutionRuntime { + private interactions: Map = new Map(); + + async askUserQuestion(args: AskUserQuestionArgs): Promise { + const { question } = args; + const requestId = question.id; + + const state: InteractionState = { + question, + requestId, + status: 'pending', + }; + + this.interactions.set(requestId, state); + + return { + content: `Question "${question.prompt}" is now pending user response.`, + state, + success: true, + }; + } + + async submitUserResponse(args: SubmitUserResponseArgs): Promise { + const { requestId, response } = args; + const state = this.interactions.get(requestId); + + if (!state) { + return { content: `Interaction not found: ${requestId}`, success: false }; + } + + if (state.status !== 'pending') { + return { + content: `Interaction ${requestId} is already ${state.status}, cannot submit.`, + success: false, + }; + } + + state.status = 'submitted'; + state.response = response; + this.interactions.set(requestId, state); + + return { + content: `User response submitted for interaction ${requestId}.`, + state, + success: true, + }; + } + + async skipUserResponse(args: SkipUserResponseArgs): Promise { + const { requestId, reason } = args; + const state = this.interactions.get(requestId); + + if (!state) { + return { content: `Interaction not found: ${requestId}`, success: false }; + } + + if (state.status !== 'pending') { + return { + content: `Interaction ${requestId} is already ${state.status}, cannot skip.`, + success: false, + }; + } + + state.status = 'skipped'; + state.skipReason = reason; + this.interactions.set(requestId, state); + + return { + content: `Interaction ${requestId} skipped.${reason ? ` Reason: ${reason}` : ''}`, + state, + success: true, + }; + } + + async cancelUserResponse(args: CancelUserResponseArgs): Promise { + const { requestId } = args; + const state = this.interactions.get(requestId); + + if (!state) { + return { content: `Interaction not found: ${requestId}`, success: false }; + } + + if (state.status !== 'pending') { + return { + content: `Interaction ${requestId} is already ${state.status}, cannot cancel.`, + success: false, + }; + } + + state.status = 'cancelled'; + this.interactions.set(requestId, state); + + return { + content: `Interaction ${requestId} cancelled.`, + state, + success: true, + }; + } + + async getInteractionState(args: GetInteractionStateArgs): Promise { + const { requestId } = args; + const state = this.interactions.get(requestId); + + if (!state) { + return { content: `Interaction not found: ${requestId}`, success: false }; + } + + return { + content: `Interaction ${requestId} is ${state.status}.`, + state, + success: true, + }; + } +} diff --git a/packages/builtin-tool-user-interaction/src/client/Intervention/AskUserQuestion/index.tsx b/packages/builtin-tool-user-interaction/src/client/Intervention/AskUserQuestion/index.tsx new file mode 100644 index 0000000000..163199665b --- /dev/null +++ b/packages/builtin-tool-user-interaction/src/client/Intervention/AskUserQuestion/index.tsx @@ -0,0 +1,37 @@ +'use client'; + +import type { BuiltinInterventionProps } from '@lobechat/types'; +import { memo } from 'react'; + +import type { AskUserQuestionArgs } from '../../../types'; + +const AskUserQuestionIntervention = memo>( + ({ args }) => { + const { question } = args; + + return ( +
+

{question.prompt}

+ {question.description && ( +

+ {question.description} +

+ )} + {question.fields && question.fields.length > 0 && ( +
    + {question.fields.map((field) => ( +
  • + {field.label} + {field.required && ' *'} +
  • + ))} +
+ )} +
+ ); + }, +); + +AskUserQuestionIntervention.displayName = 'AskUserQuestionIntervention'; + +export default AskUserQuestionIntervention; diff --git a/packages/builtin-tool-user-interaction/src/client/Intervention/index.ts b/packages/builtin-tool-user-interaction/src/client/Intervention/index.ts new file mode 100644 index 0000000000..2ee129a2ac --- /dev/null +++ b/packages/builtin-tool-user-interaction/src/client/Intervention/index.ts @@ -0,0 +1,8 @@ +import type { BuiltinIntervention } from '@lobechat/types'; + +import { UserInteractionApiName } from '../../types'; +import AskUserQuestionIntervention from './AskUserQuestion'; + +export const UserInteractionInterventions: Record = { + [UserInteractionApiName.askUserQuestion]: AskUserQuestionIntervention as BuiltinIntervention, +}; diff --git a/packages/builtin-tool-user-interaction/src/client/index.ts b/packages/builtin-tool-user-interaction/src/client/index.ts new file mode 100644 index 0000000000..79dc312caf --- /dev/null +++ b/packages/builtin-tool-user-interaction/src/client/index.ts @@ -0,0 +1,3 @@ +export { UserInteractionManifest } from '../manifest'; +export * from '../types'; +export { UserInteractionInterventions } from './Intervention'; diff --git a/packages/builtin-tool-user-interaction/src/executor/index.ts b/packages/builtin-tool-user-interaction/src/executor/index.ts new file mode 100644 index 0000000000..89178430dc --- /dev/null +++ b/packages/builtin-tool-user-interaction/src/executor/index.ts @@ -0,0 +1,63 @@ +import { BaseExecutor, type BuiltinToolContext, type BuiltinToolResult } from '@lobechat/types'; + +import { UserInteractionExecutionRuntime } from '../ExecutionRuntime'; +import { + type AskUserQuestionArgs, + type CancelUserResponseArgs, + type GetInteractionStateArgs, + type SkipUserResponseArgs, + type SubmitUserResponseArgs, + UserInteractionApiName, + UserInteractionIdentifier, +} from '../types'; + +export class UserInteractionExecutor extends BaseExecutor { + readonly identifier = UserInteractionIdentifier; + protected readonly apiEnum = UserInteractionApiName; + + private runtime: UserInteractionExecutionRuntime; + + constructor(runtime: UserInteractionExecutionRuntime) { + super(); + this.runtime = runtime; + } + + askUserQuestion = async ( + params: AskUserQuestionArgs, + _ctx: BuiltinToolContext, + ): Promise => { + return this.runtime.askUserQuestion(params); + }; + + submitUserResponse = async ( + params: SubmitUserResponseArgs, + _ctx: BuiltinToolContext, + ): Promise => { + return this.runtime.submitUserResponse(params); + }; + + skipUserResponse = async ( + params: SkipUserResponseArgs, + _ctx: BuiltinToolContext, + ): Promise => { + return this.runtime.skipUserResponse(params); + }; + + cancelUserResponse = async ( + params: CancelUserResponseArgs, + _ctx: BuiltinToolContext, + ): Promise => { + return this.runtime.cancelUserResponse(params); + }; + + getInteractionState = async ( + params: GetInteractionStateArgs, + _ctx: BuiltinToolContext, + ): Promise => { + return this.runtime.getInteractionState(params); + }; +} + +const fallbackRuntime = new UserInteractionExecutionRuntime(); + +export const userInteractionExecutor = new UserInteractionExecutor(fallbackRuntime); diff --git a/packages/builtin-tool-user-interaction/src/index.ts b/packages/builtin-tool-user-interaction/src/index.ts new file mode 100644 index 0000000000..807b12a89f --- /dev/null +++ b/packages/builtin-tool-user-interaction/src/index.ts @@ -0,0 +1,18 @@ +export * from './ExecutionRuntime'; +export { UserInteractionManifest } from './manifest'; +export { systemPrompt } from './systemRole'; +export { + type AskUserQuestionArgs, + type CancelUserResponseArgs, + type GetInteractionStateArgs, + type InteractionField, + type InteractionFieldOption, + type InteractionMode, + type InteractionState, + type InteractionStatus, + type SkipUserResponseArgs, + type SubmitUserResponseArgs, + UserInteractionApiName, + UserInteractionIdentifier, + type UserInteractionResult, +} from './types'; diff --git a/packages/builtin-tool-user-interaction/src/manifest.ts b/packages/builtin-tool-user-interaction/src/manifest.ts new file mode 100644 index 0000000000..a09a297195 --- /dev/null +++ b/packages/builtin-tool-user-interaction/src/manifest.ts @@ -0,0 +1,143 @@ +import type { BuiltinToolManifest } from '@lobechat/types'; + +import { systemPrompt } from './systemRole'; +import { UserInteractionApiName, UserInteractionIdentifier } from './types'; + +export const UserInteractionManifest: BuiltinToolManifest = { + api: [ + { + description: + 'Present a question to the user with either structured form fields or freeform input. Returns the interaction request in pending state.', + name: UserInteractionApiName.askUserQuestion, + parameters: { + properties: { + question: { + properties: { + description: { type: 'string' }, + fields: { + items: { + properties: { + key: { type: 'string' }, + kind: { + enum: ['multiselect', 'select', 'text', 'textarea'], + type: 'string', + }, + label: { type: 'string' }, + options: { + items: { + properties: { + label: { type: 'string' }, + value: { type: 'string' }, + }, + required: ['label', 'value'], + type: 'object', + }, + type: 'array', + }, + placeholder: { type: 'string' }, + required: { type: 'boolean' }, + value: { + oneOf: [{ type: 'string' }, { items: { type: 'string' }, type: 'array' }], + }, + }, + required: ['key', 'kind', 'label'], + type: 'object', + }, + type: 'array', + }, + id: { type: 'string' }, + metadata: { + additionalProperties: true, + type: 'object', + }, + mode: { + enum: ['form', 'freeform'], + type: 'string', + }, + prompt: { type: 'string' }, + }, + required: ['id', 'mode', 'prompt'], + type: 'object', + }, + }, + required: ['question'], + type: 'object', + }, + }, + { + description: "Record the user's submitted response for a pending interaction request.", + name: UserInteractionApiName.submitUserResponse, + parameters: { + properties: { + requestId: { + description: 'The interaction request ID to submit a response for.', + type: 'string', + }, + response: { + additionalProperties: true, + description: "The user's response data.", + type: 'object', + }, + }, + required: ['requestId', 'response'], + type: 'object', + }, + }, + { + description: 'Mark a pending interaction request as skipped with an optional reason.', + name: UserInteractionApiName.skipUserResponse, + parameters: { + properties: { + reason: { + description: 'Optional reason for skipping.', + type: 'string', + }, + requestId: { + description: 'The interaction request ID to skip.', + type: 'string', + }, + }, + required: ['requestId'], + type: 'object', + }, + }, + { + description: 'Cancel a pending interaction request.', + name: UserInteractionApiName.cancelUserResponse, + parameters: { + properties: { + requestId: { + description: 'The interaction request ID to cancel.', + type: 'string', + }, + }, + required: ['requestId'], + type: 'object', + }, + }, + { + description: 'Get the current state of an interaction request.', + name: UserInteractionApiName.getInteractionState, + parameters: { + properties: { + requestId: { + description: 'The interaction request ID to query.', + type: 'string', + }, + }, + required: ['requestId'], + type: 'object', + }, + renderDisplayControl: 'collapsed', + }, + ], + identifier: UserInteractionIdentifier, + meta: { + avatar: '💬', + description: + 'Ask users questions and collect structured responses with submit/skip/cancel semantics', + title: 'User Interaction', + }, + systemRole: systemPrompt, + type: 'builtin', +}; diff --git a/packages/builtin-tool-user-interaction/src/systemRole.ts b/packages/builtin-tool-user-interaction/src/systemRole.ts new file mode 100644 index 0000000000..4fe2ecf4d4 --- /dev/null +++ b/packages/builtin-tool-user-interaction/src/systemRole.ts @@ -0,0 +1,29 @@ +export const systemPrompt = `You have access to a User Interaction tool for asking users questions and collecting structured responses. + + +1. Ask question (askUserQuestion) - present a question with form fields or freeform input +2. Submit response (submitUserResponse) - record the user's submitted answer +3. Skip response (skipUserResponse) - mark a question as skipped with optional reason +4. Cancel response (cancelUserResponse) - cancel a pending question +5. Get state (getInteractionState) - check the current state of an interaction request + + + +1. Call askUserQuestion with a question object (id, mode, prompt, optional fields). +2. The UI surfaces the question to the user. The interaction enters "pending" state. +3. The user responds in one of three ways: + - Submit: submitUserResponse is called with the user's answer → status becomes "submitted" + - Skip: skipUserResponse is called → status becomes "skipped" + - Cancel: cancelUserResponse is called → status becomes "cancelled" +4. Use getInteractionState to check the outcome if needed. +5. Process the result and continue the conversation accordingly. + + + +- Use "form" mode when you need structured data with specific fields. +- Use "freeform" mode for open-ended questions where the user types a free response. +- Always provide a clear, concise prompt so the user knows what is being asked. +- Handle all three outcomes (submit/skip/cancel) gracefully. +- Do not ask multiple questions simultaneously; wait for one to resolve before asking another. + +`; diff --git a/packages/builtin-tool-user-interaction/src/types.ts b/packages/builtin-tool-user-interaction/src/types.ts new file mode 100644 index 0000000000..49e8d6594e --- /dev/null +++ b/packages/builtin-tool-user-interaction/src/types.ts @@ -0,0 +1,70 @@ +export const UserInteractionIdentifier = 'lobe-user-interaction'; + +export const UserInteractionApiName = { + askUserQuestion: 'askUserQuestion', + cancelUserResponse: 'cancelUserResponse', + getInteractionState: 'getInteractionState', + skipUserResponse: 'skipUserResponse', + submitUserResponse: 'submitUserResponse', +} as const; + +export type InteractionMode = 'form' | 'freeform'; + +export type InteractionStatus = 'cancelled' | 'pending' | 'skipped' | 'submitted'; + +export interface InteractionFieldOption { + label: string; + value: string; +} + +export interface InteractionField { + key: string; + kind: 'multiselect' | 'select' | 'text' | 'textarea'; + label: string; + options?: InteractionFieldOption[]; + placeholder?: string; + required?: boolean; + value?: string | string[]; +} + +export interface AskUserQuestionArgs { + question: { + description?: string; + fields?: InteractionField[]; + id: string; + metadata?: Record; + mode: InteractionMode; + prompt: string; + }; +} + +export interface SubmitUserResponseArgs { + requestId: string; + response: Record; +} + +export interface SkipUserResponseArgs { + reason?: string; + requestId: string; +} + +export interface CancelUserResponseArgs { + requestId: string; +} + +export interface GetInteractionStateArgs { + requestId: string; +} + +export interface InteractionState { + question?: AskUserQuestionArgs['question']; + requestId: string; + response?: Record; + skipReason?: string; + status: InteractionStatus; +} + +export type UserInteractionResult = + | { requestId: string; response: Record; type: 'submitted' } + | { reason?: string; requestId: string; type: 'skipped' } + | { requestId: string; type: 'cancelled' }; diff --git a/packages/builtin-tool-user-interaction/tsconfig.json b/packages/builtin-tool-user-interaction/tsconfig.json new file mode 100644 index 0000000000..596e2cf729 --- /dev/null +++ b/packages/builtin-tool-user-interaction/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/packages/builtin-tool-user-interaction/vitest.config.mts b/packages/builtin-tool-user-interaction/vitest.config.mts new file mode 100644 index 0000000000..4ac6027d57 --- /dev/null +++ b/packages/builtin-tool-user-interaction/vitest.config.mts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + }, +});