feat(onboarding): add generic user interaction builtin tool

This commit is contained in:
Innei
2026-03-25 19:48:07 +08:00
parent 8292db5702
commit 0ae3c19bba
13 changed files with 631 additions and 0 deletions

View File

@@ -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:*"
}
}

View File

@@ -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);
});
});

View File

@@ -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<string, InteractionState> = new Map();
async askUserQuestion(args: AskUserQuestionArgs): Promise<BuiltinServerRuntimeOutput> {
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<BuiltinServerRuntimeOutput> {
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<BuiltinServerRuntimeOutput> {
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<BuiltinServerRuntimeOutput> {
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<BuiltinServerRuntimeOutput> {
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,
};
}
}

View File

@@ -0,0 +1,37 @@
'use client';
import type { BuiltinInterventionProps } from '@lobechat/types';
import { memo } from 'react';
import type { AskUserQuestionArgs } from '../../../types';
const AskUserQuestionIntervention = memo<BuiltinInterventionProps<AskUserQuestionArgs>>(
({ args }) => {
const { question } = args;
return (
<div>
<p>{question.prompt}</p>
{question.description && (
<p style={{ color: 'var(--lobe-text-secondary)', fontSize: 13 }}>
{question.description}
</p>
)}
{question.fields && question.fields.length > 0 && (
<ul style={{ margin: 0, paddingLeft: 20 }}>
{question.fields.map((field) => (
<li key={field.key}>
{field.label}
{field.required && ' *'}
</li>
))}
</ul>
)}
</div>
);
},
);
AskUserQuestionIntervention.displayName = 'AskUserQuestionIntervention';
export default AskUserQuestionIntervention;

View File

@@ -0,0 +1,8 @@
import type { BuiltinIntervention } from '@lobechat/types';
import { UserInteractionApiName } from '../../types';
import AskUserQuestionIntervention from './AskUserQuestion';
export const UserInteractionInterventions: Record<string, BuiltinIntervention> = {
[UserInteractionApiName.askUserQuestion]: AskUserQuestionIntervention as BuiltinIntervention,
};

View File

@@ -0,0 +1,3 @@
export { UserInteractionManifest } from '../manifest';
export * from '../types';
export { UserInteractionInterventions } from './Intervention';

View File

@@ -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<typeof UserInteractionApiName> {
readonly identifier = UserInteractionIdentifier;
protected readonly apiEnum = UserInteractionApiName;
private runtime: UserInteractionExecutionRuntime;
constructor(runtime: UserInteractionExecutionRuntime) {
super();
this.runtime = runtime;
}
askUserQuestion = async (
params: AskUserQuestionArgs,
_ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
return this.runtime.askUserQuestion(params);
};
submitUserResponse = async (
params: SubmitUserResponseArgs,
_ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
return this.runtime.submitUserResponse(params);
};
skipUserResponse = async (
params: SkipUserResponseArgs,
_ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
return this.runtime.skipUserResponse(params);
};
cancelUserResponse = async (
params: CancelUserResponseArgs,
_ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
return this.runtime.cancelUserResponse(params);
};
getInteractionState = async (
params: GetInteractionStateArgs,
_ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
return this.runtime.getInteractionState(params);
};
}
const fallbackRuntime = new UserInteractionExecutionRuntime();
export const userInteractionExecutor = new UserInteractionExecutor(fallbackRuntime);

View File

@@ -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';

View File

@@ -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',
};

View File

@@ -0,0 +1,29 @@
export const systemPrompt = `You have access to a User Interaction tool for asking users questions and collecting structured responses.
<core_capabilities>
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
</core_capabilities>
<lifecycle>
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.
</lifecycle>
<best_practices>
- 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.
</best_practices>
`;

View File

@@ -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<string, unknown>;
mode: InteractionMode;
prompt: string;
};
}
export interface SubmitUserResponseArgs {
requestId: string;
response: Record<string, unknown>;
}
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<string, unknown>;
skipReason?: string;
status: InteractionStatus;
}
export type UserInteractionResult =
| { requestId: string; response: Record<string, unknown>; type: 'submitted' }
| { reason?: string; requestId: string; type: 'skipped' }
| { requestId: string; type: 'cancelled' };

View File

@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
},
});