Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1198,6 +1198,13 @@ function ChatPane({
planApproveDefaultsMessage={t('console.ai.planApproveDefaultsMessage', {
defaultValue: 'Build it with your best assumptions; use sensible defaults for the open questions.',
})}
planAnswerMessage={(question, option) =>
t('console.ai.planAnswerMessage', {
question,
option,
defaultValue: 'For "{{question}}", go with: {{option}}.',
})
}
// Self-use "magic moment": when the plan enables it, publish the drafted
// app automatically the moment the agent finishes — no manual click; the
// user refreshes and sees it live WITH data. Same governed endpoint.
Expand Down
3 changes: 3 additions & 0 deletions packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ function buildChatLocale(
planAdjust: '调整方案',
planApproveMessage: '就按这个方案搭建吧。',
planApproveDefaultsMessage: '就按你的合理假设直接搭建,未决问题用默认即可。',
planAnswer: (question: string, option: string) => `关于「${question}」,我选择「${option}」。`,
publishOk: '已发布,对象已生效。',
publishFailed: '发布失败',
seedWarn: '已发布,但部分示例数据未能载入。',
Expand Down Expand Up @@ -191,6 +192,7 @@ function buildChatLocale(
planAdjust: 'Adjust',
planApproveMessage: 'Looks good — build it as proposed.',
planApproveDefaultsMessage: 'Build it with your best assumptions; use sensible defaults for the open questions.',
planAnswer: (question: string, option: string) => `For "${question}", go with: ${option}.`,
publishOk: 'Published — objects are now live.',
publishFailed: 'Publish failed',
seedWarn: 'Published, but some sample data failed to load.',
Expand Down Expand Up @@ -691,6 +693,7 @@ function ChatbotInner({
planAdjustLabel={locale.planAdjust}
planApproveMessage={locale.planApproveMessage}
planApproveDefaultsMessage={locale.planApproveDefaultsMessage}
planAnswerMessage={locale.planAnswer}
publishedLabel={locale.published}
// Self-use "magic moment": when the plan enables it, auto-publish the
// drafted app the instant the agent finishes — the success path above
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1153,6 +1153,7 @@ const en = {
planApproveMessage: 'Looks good — build it as proposed.',
planApproveDefaultsMessage:
'Build it with your best assumptions; use sensible defaults for the open questions.',
planAnswerMessage: 'For "{{question}}", go with: {{option}}.',
justNow: 'just now',
minutesAgo: '{{count}}m ago',
hoursAgo: '{{count}}h ago',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1149,6 +1149,7 @@ const zh = {
planAdjust: '调整方案',
planApproveMessage: '就按这个方案搭建吧。',
planApproveDefaultsMessage: '就按你的合理假设直接搭建,未决问题用默认即可。',
planAnswerMessage: '关于「{{question}}」,我选择「{{option}}」。',
justNow: '刚刚',
minutesAgo: '{{count}} 分钟前',
hoursAgo: '{{count}} 小时前',
Expand Down
49 changes: 40 additions & 9 deletions packages/plugin-chatbot/src/ChatbotEnhanced.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ export interface ChatToolInvocation {
objects: Array<{ name: string; label?: string; fieldCount: number }>;
counts: { objects: number; views: number; dashboards: number; seedData: number };
questions: string[];
questionChoices?: Array<{ text: string; options: string[] }>;
assumptions: string[];
targetApp?: string;
};
Expand Down Expand Up @@ -502,6 +503,14 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes<HTMLDivElemen
planApproveMessage?: string;
/** Message sent when the user approves a plan that still has open questions — tells the agent to proceed on sensible defaults (default "Build it with your best assumptions; use sensible defaults for the open questions."). */
planApproveDefaultsMessage?: string;
/**
* Builds the message sent when the user clicks a one-click answer chip for a
* structure-deciding question (from `proposedPlan.questionChoices`). Receives
* the question text and the chosen option. Default:
* `For "<question>", go with: <option>.` — answers that question and lets the
* agent continue (ask the next question or build).
*/
planAnswerMessage?: (question: string, option: string) => string;
/**
* Live draft-status resolver: how many drafts are still PENDING in a
* package (e.g. `GET /metadata/_drafts?packageId=` count). When provided,
Expand Down Expand Up @@ -851,6 +860,7 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
planAdjustLabel = 'Adjust',
planApproveMessage = 'Looks good — build it as proposed.',
planApproveDefaultsMessage = 'Build it with your best assumptions; use sensible defaults for the open questions.',
planAnswerMessage = (question: string, option: string) => `For "${question}", go with: ${option}.`,
fetchPendingDraftCount,
autoPublishDrafts = false,
processVisibility = 'summary',
Expand Down Expand Up @@ -1499,15 +1509,36 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
<HelpCircle className="size-3.5" />
{planQuestionsLabel}
</span>
{tool.proposedPlan.questions.map((q, idx) => (
<div
key={idx}
className="flex items-start gap-1.5 text-[11px] text-amber-900/90 dark:text-amber-200/90"
>
<span className="mt-px shrink-0">?</span>
<span>{q}</span>
</div>
))}
{tool.proposedPlan.questions.map((q, idx) => {
// One-click answer chips when the backend derived options
// for this exact question; otherwise the user types a reply.
const choice = tool.proposedPlan!.questionChoices?.find((c) => c.text === q);
return (
<div key={idx} className="flex flex-col gap-1">
<div className="flex items-start gap-1.5 text-[11px] text-amber-900/90 dark:text-amber-200/90">
<span className="mt-px shrink-0">?</span>
<span>{q}</span>
</div>
{onSendMessage && choice && choice.options.length > 0 ? (
<div
className="flex flex-wrap gap-1 pl-3.5"
data-testid="proposed-plan-choice"
>
{choice.options.map((opt, oi) => (
<button
key={oi}
type="button"
onClick={() => onSendMessage(planAnswerMessage(q, opt))}
className="inline-flex h-6 items-center rounded-full border border-amber-300 bg-background px-2 text-[11px] font-medium text-amber-900 hover:bg-amber-100 dark:border-amber-800 dark:text-amber-200 dark:hover:bg-amber-900/40"
>
{opt}
</button>
))}
</div>
) : null}
</div>
);
})}
</div>
) : null}
{/* One-click confirm gate: "Build it" sends an approval message
Expand Down
44 changes: 44 additions & 0 deletions packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,50 @@ describe('ChatbotEnhanced (AI Elements composition)', () => {
expect(screen.getByText('HINT_TEXT')).toBeInTheDocument();
});

const Q_INTERVIEW = 'Track interviews as a separate object or a stage field?';
const planWithChoices: ChatMessage[] = [
{
id: 'a1',
role: 'assistant',
content: '',
toolInvocations: [
{
toolCallId: 't1',
toolName: 'propose_blueprint',
state: 'output-available',
proposedPlan: {
summary: 'A recruiting app',
objects: [{ name: 'candidate', label: 'Candidate', fieldCount: 3 }],
counts: { objects: 1, views: 0, dashboards: 0, seedData: 0 },
questions: [Q_INTERVIEW],
questionChoices: [{ text: Q_INTERVIEW, options: ['Separate object', 'Stage field'] }],
assumptions: [],
},
},
],
},
];

it('renders one-click answer chips for a question that has choices, and sends the answer on click', () => {
const onSendMessage = vi.fn();
render(
<ChatbotEnhanced
messages={planWithChoices}
onSendMessage={onSendMessage}
planAnswerMessage={(q, o) => `ANSWER:${q}=${o}`}
/>,
);
expect(screen.getByTestId('proposed-plan-choice')).toBeInTheDocument();
fireEvent.click(screen.getByText('Stage field'));
expect(onSendMessage).toHaveBeenCalledWith(`ANSWER:${Q_INTERVIEW}=Stage field`);
});

it('renders plain questions with NO chips when there are no matching choices', () => {
render(<ChatbotEnhanced messages={planMessage(['Some open-ended question?'])} onSendMessage={vi.fn()} />);
expect(screen.getByText(/Some open-ended question/)).toBeInTheDocument();
expect(screen.queryByTestId('proposed-plan-choice')).not.toBeInTheDocument();
});

it('shows an error banner with a retry affordance', () => {
const onReload = vi.fn();
render(
Expand Down
26 changes: 26 additions & 0 deletions packages/plugin-chatbot/src/__tests__/mapMessages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,32 @@ describe('proposedPlan detection (propose_blueprint)', () => {
expect(detectProposedPlan({ status: 'blueprint_proposed' })).toBeUndefined();
expect(detectProposedPlan({ foo: 1 })).toBeUndefined();
});

it('lifts well-formed questionChoices and drops malformed / <2-option ones', () => {
const plan = detectProposedPlan({
status: 'blueprint_proposed',
blueprint: { objects: [{ name: 'book', fields: [] }] },
questions: ['Track loans separately?', 'Star ratings or 1-10?'],
questionChoices: [
{ text: 'Track loans separately?', options: ['Separate object', 'Status field'] },
{ text: 'Star ratings or 1-10?', options: ['only one'] }, // <2 → dropped
{ text: '', options: ['a', 'b'] }, // no text → dropped
{ options: ['a', 'b'] }, // no text → dropped
],
});
expect(plan?.questionChoices).toEqual([
{ text: 'Track loans separately?', options: ['Separate object', 'Status field'] },
]);
});

it('omits questionChoices entirely when absent or not an array', () => {
const plan = detectProposedPlan({
status: 'blueprint_proposed',
blueprint: { objects: [{ name: 'book', fields: [] }] },
questions: ['Q?'],
});
expect(plan?.questionChoices).toBeUndefined();
});
});

describe('uiMessagesToChatMessages', () => {
Expand Down
21 changes: 21 additions & 0 deletions packages/plugin-chatbot/src/mapMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,13 @@ export interface ProposedPlan {
counts: { objects: number; views: number; dashboards: number; seedData: number };
/** ≤2 structure-deciding questions to confirm before building (may be empty). */
questions: string[];
/**
* One-click answer options for the structure-deciding questions, when the
* backend (propose_blueprint `questionChoices`) could derive them. Matched to
* a `questions` entry by `text`; a question with no entry here renders as
* plain text (type-to-answer). Each set has ≥2 options, recommended first.
*/
questionChoices?: Array<{ text: string; options: string[] }>;
/** Assumptions the agent made from an underspecified goal (may be empty). */
assumptions: string[];
/** Extend mode: the existing app the new objects would be added into. */
Expand Down Expand Up @@ -219,6 +226,19 @@ export function detectProposedPlan(result: unknown): ProposedPlan | undefined {
// `questions` ride on the envelope; older shapes carried them on the
// blueprint — accept either.
const questions = nonEmptyStrings((obj as { questions?: unknown }).questions);
// One-click options for the questions (propose_blueprint `questionChoices`).
// Defensive: keep only entries with a text and ≥2 non-empty string options.
const rawChoices = (obj as { questionChoices?: unknown }).questionChoices;
const questionChoices = Array.isArray(rawChoices)
? rawChoices.flatMap((c) => {
const r = c as { text?: unknown; options?: unknown };
if (typeof r?.text !== 'string' || !r.text) return [];
const options = Array.isArray(r.options)
? r.options.filter((o): o is string => typeof o === 'string' && o.trim().length > 0)
: [];
return options.length >= 2 ? [{ text: r.text, options }] : [];
})
: [];
const summary =
typeof obj.summary === 'string' && obj.summary
? obj.summary
Expand All @@ -232,6 +252,7 @@ export function detectProposedPlan(result: unknown): ProposedPlan | undefined {
objects,
counts,
questions: questions.length ? questions : nonEmptyStrings(bp.questions),
...(questionChoices.length ? { questionChoices } : {}),
assumptions: nonEmptyStrings(bp.assumptions),
...(typeof targetApp === 'string' && targetApp ? { targetApp } : {}),
};
Expand Down
Loading