diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index 93a53eb9f2..1a7637c5c7 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -1248,7 +1248,12 @@ function ChatPane({ t('console.ai.designingPlanHint.data', { defaultValue: 'Mapping out the data you’ll track…' }), t('console.ai.designingPlanHint.objects', { defaultValue: 'Shaping objects and their fields…' }), t('console.ai.designingPlanHint.relations', { defaultValue: 'Connecting related records…' }), + t('console.ai.designingPlanHint.lookups', { defaultValue: 'Setting up relationships and lookups…' }), t('console.ai.designingPlanHint.views', { defaultValue: 'Planning the screens and views…' }), + t('console.ai.designingPlanHint.forms', { defaultValue: 'Laying out forms and lists…' }), + t('console.ai.designingPlanHint.defaults', { defaultValue: 'Adding sensible defaults and validations…' }), + t('console.ai.designingPlanHint.dashboard', { defaultValue: 'Sketching a dashboard to track it…' }), + t('console.ai.designingPlanHint.review', { defaultValue: 'Double-checking the structure hangs together…' }), t('console.ai.designingPlanHint.finalize', { defaultValue: 'Pulling the plan together…' }), ], toolDetailsHidden: t('console.ai.toolDetailsHidden'), @@ -1348,6 +1353,7 @@ function ChatPane({ planTitleLabel={t('console.ai.planTitle', { defaultValue: 'Proposed plan' })} planQuestionsLabel={t('console.ai.planQuestions', { defaultValue: 'Confirm before building' })} planAssumptionsLabel={t('console.ai.planAssumptions', { defaultValue: 'Assumptions' })} + planDeferredLabel={t('console.ai.planDeferred', { defaultValue: 'Not yet built' })} planApproveHintLabel={t('console.ai.planApproveHint', { defaultValue: 'Reply to approve or adjust this plan.', })} diff --git a/packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx b/packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx index d71565c6dc..f1f91d5d2a 100644 --- a/packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx +++ b/packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx @@ -119,7 +119,12 @@ function buildChatLocale( '梳理需要记录的数据…', '设计对象与字段…', '关联相关记录…', + '配置关系与查找字段…', '规划页面与视图…', + '布置表单与列表…', + '补充默认值与校验…', + '规划一个看板来跟踪…', + '复核整体结构是否自洽…', '汇总成完整方案…', ], toolDetailsHidden: '已隐藏工具参数和原始结果,仅保留过程摘要。', @@ -145,6 +150,7 @@ function buildChatLocale( planTitle: '方案预览', planQuestions: '搭建前请确认', planAssumptions: '假设', + planDeferred: '待补 / 暂未搭建', planApproveHint: '回复以确认或调整该方案。', planApprove: '开始搭建', planAdjust: '调整方案', @@ -192,7 +198,12 @@ function buildChatLocale( 'Mapping out the data you’ll track…', 'Shaping objects and their fields…', 'Connecting related records…', + 'Setting up relationships and lookups…', 'Planning the screens and views…', + 'Laying out forms and lists…', + 'Adding sensible defaults and validations…', + 'Sketching a dashboard to track it…', + 'Double-checking the structure hangs together…', 'Pulling the plan together…', ], toolDetailsHidden: 'Tool inputs and raw results are hidden in this view.', @@ -218,6 +229,7 @@ function buildChatLocale( planTitle: 'Proposed plan', planQuestions: 'Confirm before building', planAssumptions: 'Assumptions', + planDeferred: 'Not yet built', planApproveHint: 'Reply to approve or adjust this plan.', planApprove: 'Build it', planAdjust: 'Adjust', @@ -740,6 +752,7 @@ function ChatbotInner({ planTitleLabel={locale.planTitle} planQuestionsLabel={locale.planQuestions} planAssumptionsLabel={locale.planAssumptions} + planDeferredLabel={locale.planDeferred} planApproveHintLabel={locale.planApproveHint} planApproveLabel={locale.planApprove} planAdjustLabel={locale.planAdjust} diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index 61b9f47e88..918b1b545b 100644 --- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -20,7 +20,7 @@ import * as React from 'react'; import { cn } from '@object-ui/components'; import { SchemaRenderer } from '@object-ui/react'; -import { AlertCircle, ArrowRight, Copy, Check, RefreshCw, CornerDownLeft, Bot, Eye, GitCompareArrows, Rocket, Clock3, CheckCircle2, XCircle, Loader2, ShieldCheck, TriangleAlert, ClipboardList, HelpCircle, Table2, WifiOff, Sparkles } from 'lucide-react'; +import { AlertCircle, ArrowRight, Copy, Check, RefreshCw, CornerDownLeft, Bot, Eye, GitCompareArrows, Rocket, Clock3, CheckCircle2, XCircle, Loader2, ShieldCheck, TriangleAlert, ClipboardList, HelpCircle, Table2, WifiOff, Sparkles, Hourglass } from 'lucide-react'; import type { ChatStatus } from 'ai'; import { humanizeToolName, @@ -543,6 +543,14 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes haystack.includes(m))) { + deferred.push(text); + } else { + designNotes.push(text); + } + } + return { designNotes, deferred }; +} + function shouldRenderDetailedTool(tool: ChatToolInvocation): boolean { const state = getToolState(tool); return ( @@ -975,6 +1052,7 @@ const ChatbotEnhanced = React.forwardRef( planExtendLabel = 'Adding to existing app', planQuestionsLabel = 'Confirm before building', planAssumptionsLabel = 'Assumptions', + planDeferredLabel = 'Not yet built', planApproveHintLabel = 'Reply to approve or adjust this plan.', planApproveLabel = 'Build it', planAdjustLabel = 'Adjust', @@ -1701,22 +1779,56 @@ const ChatbotEnhanced = React.forwardRef( {bits.join(' · ')} ) : null; })()} - {tool.proposedPlan.assumptions.length > 0 ? ( -
- - {planAssumptionsLabel} - - {tool.proposedPlan.assumptions.map((a, idx) => ( -
- · - {a} -
- ))} -
- ) : null} + {/* Assumptions split into ordinary design notes vs. business + rules the build is explicitly DEFERRING ("待补 / Not yet + built"). The deferred set gets its own labelled, tinted + section with an hourglass so a user can't mistake a + still-to-come rule for delivered behaviour (improvement 2). */} + {(() => { + const { designNotes, deferred } = classifyAssumptions( + tool.proposedPlan!.assumptions, + ); + return ( + <> + {designNotes.length > 0 ? ( +
+ + {planAssumptionsLabel} + + {designNotes.map((a, idx) => ( +
+ · + {a} +
+ ))} +
+ ) : null} + {deferred.length > 0 ? ( +
+ + + {planDeferredLabel} + + {deferred.map((a, idx) => ( +
+ · + {a} +
+ ))} +
+ ) : null} + + ); + })()} {tool.proposedPlan.questions.length > 0 ? (
0 ? elapsedMs : 0; + const step = stepMs > 0 ? stepMs : DESIGNING_HINT_ROTATE_MS; + const stage = Math.floor(safeElapsed / step); + return Math.min(stage, hintCount - 1); +} + /** * Friendly in-progress indicator for the build agent's `propose_blueprint` * step. Because that call is a SINGLE long, atomic LLM request (no token * stream, no partial results), a bare elapsed timer made it look like the UI * might be stuck. This pairs the live `ToolRunningTimer` with a short lead-in - * ("Designing your app…") and a hint that ROTATES every few seconds, so the - * wait visibly "moves" and reads as deliberate work. The rotation is purely - * presentational — it is NOT claiming real sub-step progress. An empty `hints` - * array (or a single entry) just pins the lead-in + timer with no rotation. + * ("Designing your app…"), a hint that ADVANCES through the design stages as the + * wait grows, and a row of step dots filled up to the current stage so the wait + * visibly "moves forward". The staging is purely presentational — it is NOT + * claiming real sub-step progress or a percentage; it just clamps on the final + * stage rather than looping. An empty `hints` array (or a single entry) just + * pins the lead-in + timer with no rotation or dots. */ function BuildProposalProgressHint({ label, @@ -2645,17 +2790,22 @@ function BuildProposalProgressHint({ hints: string[]; offlineLabel: string; }) { - const [index, setIndex] = React.useState(0); + // Re-read the real elapsed clock once per stage interval; the visible stage is + // DERIVED from it (via the pure selector) so the hint and the step dots advance + // off one source of truth. The live seconds timer beside this ticks every 1s on + // its own, so the strip keeps "moving" between stage changes. + const [elapsedMs, setElapsedMs] = React.useState(0); React.useEffect(() => { - if (hints.length <= 1) return; // nothing to rotate through - const id = setInterval( - () => setIndex((i) => (i + 1) % hints.length), - DESIGNING_HINT_ROTATE_MS, - ); + if (hints.length <= 1) return; // nothing to advance through + const start = Date.now(); + const id = setInterval(() => setElapsedMs(Date.now() - start), DESIGNING_HINT_ROTATE_MS); return () => clearInterval(id); }, [hints.length]); - // Guard the index against a shrinking `hints` list (label change mid-rotation). - const hint = hints.length > 0 ? hints[index % hints.length] : undefined; + const index = selectDesignHintIndex(elapsedMs, hints.length); + const hint = index >= 0 ? hints[index] : undefined; + // Show the lightweight step dots only once there are real stages to track and + // we won't crowd the strip (cap the dot count so a long custom list stays sane). + const showDots = hints.length > 1 && hints.length <= 12; return ( {label} {hint ? ( // `key` on the hint text restarts the fade each time it swaps, so the - // rotation reads as a gentle change rather than an instant flicker. + // stage change reads as a gentle transition rather than an instant flicker. {hint} ) : null} + {showDots ? ( + + {hints.map((_, i) => ( + + ))} + + ) : null} ); diff --git a/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.test.tsx b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.test.tsx index 719fc1deeb..b8c000fbc7 100644 --- a/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.test.tsx +++ b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.test.tsx @@ -9,7 +9,12 @@ import '@testing-library/jest-dom/vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; -import { ChatbotEnhanced, type ChatMessage } from '../ChatbotEnhanced'; +import { + ChatbotEnhanced, + classifyAssumptions, + selectDesignHintIndex, + type ChatMessage, +} from '../ChatbotEnhanced'; describe('ChatbotEnhanced (AI Elements composition)', () => { beforeEach(() => { @@ -1230,3 +1235,260 @@ describe('ChatbotEnhanced — propose_blueprint in-progress design hints', () => expect(container.querySelector('[data-tool-running-timer]')).not.toBeNull(); }); }); + +// Improvement 2: a plan's `assumptions` mix neutral design notes with business +// rules the build is explicitly DEFERRING ("…will be added later / 需要后续单独补"). +// `classifyAssumptions` splits them so the card can surface the deferred set +// apart, and the user can't mistake a still-to-come rule for delivered behaviour. +describe('classifyAssumptions (deferred vs. design-note split)', () => { + it('routes explicit "deferred" markers (zh + en) to `deferred`, keeps the rest as design notes', () => { + const { designNotes, deferred } = classifyAssumptions([ + '设备通过所属客户建立归属关系', // neutral design note + '技师只能看到分配给自己的工单,将在后续 Flow / 权限配置中实现', // deferred (将在 / 后续) + 'Each device belongs to one customer', // neutral design note + 'Role-based access for technicians will be added later', // deferred (will be added / later) + '审批流确认后一起补', // deferred (一起补) + '需要后续单独补权限/流程配置', // deferred (需要后续) + ]); + expect(designNotes).toEqual([ + '设备通过所属客户建立归属关系', + 'Each device belongs to one customer', + ]); + expect(deferred).toEqual([ + '技师只能看到分配给自己的工单,将在后续 Flow / 权限配置中实现', + 'Role-based access for technicians will be added later', + '审批流确认后一起补', + '需要后续单独补权限/流程配置', + ]); + }); + + it('matches deferral markers case-insensitively and is not fooled by a bare "flow"/"permission" mention', () => { + const { designNotes, deferred } = classifyAssumptions([ + 'Approvals are NOT YET wired up', // deferred — uppercase marker + 'A flow runs on every new work order', // built rule that merely mentions "flow" → design note + 'Permissions follow the org role', // built rule that mentions permission → design note + ]); + expect(deferred).toEqual(['Approvals are NOT YET wired up']); + expect(designNotes).toEqual([ + 'A flow runs on every new work order', + 'Permissions follow the org role', + ]); + }); + + it('trims and drops blank/whitespace assumptions and tolerates non-strings', () => { + const { designNotes, deferred } = classifyAssumptions([ + ' One shelf for now ', + '', + ' ', + // @ts-expect-error — guard against malformed backend data + null, + '暂不实现导出', // deferred (暂不) + ]); + expect(designNotes).toEqual(['One shelf for now']); + expect(deferred).toEqual(['暂不实现导出']); + }); + + it('handles the all-design-notes and all-deferred extremes', () => { + expect(classifyAssumptions(['plain a', 'plain b'])).toEqual({ + designNotes: ['plain a', 'plain b'], + deferred: [], + }); + expect(classifyAssumptions(['deferred later', '稍后补充'])).toEqual({ + designNotes: [], + deferred: ['deferred later', '稍后补充'], + }); + }); +}); + +// Improvement 2 (render): the plan card surfaces the deferred assumptions in a +// distinct "Not yet built" section, separate from the ordinary assumptions list. +describe('ChatbotEnhanced — proposed plan deferred-assumptions section', () => { + beforeEach(() => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + }); + + const planWithAssumptions = (assumptions: string[]): ChatMessage[] => [ + { + id: 'a1', + role: 'assistant', + content: '', + toolInvocations: [ + { + toolCallId: 't1', + toolName: 'propose_blueprint', + state: 'output-available', + proposedPlan: { + summary: 'A field-service app', + objects: [{ name: 'work_order', label: 'Work Order', fieldCount: 4 }], + counts: { objects: 1, views: 0, dashboards: 0, seedData: 0 }, + questions: [], + assumptions, + }, + }, + ], + }, + ]; + + it('renders deferred assumptions in their own "Not yet built" section, design notes in the normal list', () => { + render( + , + ); + const deferred = screen.getByTestId('proposed-plan-deferred'); + expect(deferred).toHaveTextContent('NOT_YET_BUILT'); + expect(deferred).toHaveTextContent('将在后续权限配置中实现'); + // The neutral note stays in the ordinary assumptions group, NOT the deferred box. + const notes = screen.getByTestId('proposed-plan-assumptions'); + expect(notes).toHaveTextContent('Devices belong to a customer'); + expect(notes).not.toHaveTextContent('将在后续权限配置中实现'); + }); + + it('omits the deferred section entirely when no assumption is deferred', () => { + render( + , + ); + expect(screen.getByTestId('proposed-plan-assumptions')).toBeInTheDocument(); + expect(screen.queryByTestId('proposed-plan-deferred')).not.toBeInTheDocument(); + }); + + it('omits the ordinary assumptions section when every assumption is deferred', () => { + render( + , + ); + expect(screen.getByTestId('proposed-plan-deferred')).toBeInTheDocument(); + expect(screen.queryByTestId('proposed-plan-assumptions')).not.toBeInTheDocument(); + }); +}); + +// Improvement 4: the design-wait hint should read as steady forward progress on a +// long (multi-minute) propose_blueprint call. `selectDesignHintIndex` advances one +// stage per interval, then CLAMPS on the last hint instead of wrapping — so it never +// looks like it restarted, and there is no fake percentage. +describe('selectDesignHintIndex (elapsed → design stage)', () => { + const step = 3500; + it('advances one stage per interval', () => { + expect(selectDesignHintIndex(0, 5, step)).toBe(0); + expect(selectDesignHintIndex(step - 1, 5, step)).toBe(0); + expect(selectDesignHintIndex(step, 5, step)).toBe(1); + expect(selectDesignHintIndex(step * 2, 5, step)).toBe(2); + expect(selectDesignHintIndex(step * 3, 5, step)).toBe(3); + }); + + it('clamps on the final stage rather than wrapping back to the start', () => { + // Way past the last stage (a multi-minute wait) — pins the last hint, never loops. + expect(selectDesignHintIndex(step * 4, 5, step)).toBe(4); + expect(selectDesignHintIndex(step * 50, 5, step)).toBe(4); + expect(selectDesignHintIndex(step * 999, 10, step)).toBe(9); + }); + + it('returns -1 for an empty list and pins index 0 for a single hint', () => { + expect(selectDesignHintIndex(0, 0, step)).toBe(-1); + expect(selectDesignHintIndex(step * 5, 0, step)).toBe(-1); + expect(selectDesignHintIndex(0, 1, step)).toBe(0); + expect(selectDesignHintIndex(step * 5, 1, step)).toBe(0); + }); + + it('treats negative / non-finite elapsed as stage 0 (no crash)', () => { + expect(selectDesignHintIndex(-100, 5, step)).toBe(0); + expect(selectDesignHintIndex(Number.NaN, 5, step)).toBe(0); + // Infinity is not finite → guarded to stage 0 rather than NaN/overflow. + expect(selectDesignHintIndex(Number.POSITIVE_INFINITY, 5, step)).toBe(0); + }); +}); + +// Improvement 4 (render): the running-proposal indicator shows step dots that fill +// up to the current stage and advances forward through a longer hint pool. +describe('ChatbotEnhanced — design-wait staging (dots + forward progress)', () => { + beforeEach(() => { + vi.useFakeTimers(); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + }); + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + const runningProposal: ChatMessage[] = [ + { id: 'u1', role: 'user', content: 'build me a CRM' }, + { + id: 'a1', + role: 'assistant', + content: '', + streaming: true, + toolInvocations: [ + { toolCallId: 't1', toolName: 'propose_blueprint', state: 'input-available' }, + ], + }, + ]; + + it('renders one step dot per hint and advances the hint forward as the wait grows', () => { + const { container } = render( + , + ); + const dots = () => container.querySelector('[data-testid="build-proposal-progress-dots"]'); + const hint = () => container.querySelector('[data-testid="build-proposal-progress"]'); + expect(dots()?.children.length).toBe(3); + expect(hint()?.textContent).toContain('HINT_A'); + act(() => { + vi.advanceTimersByTime(3500); + }); + expect(hint()?.textContent).toContain('HINT_B'); + act(() => { + vi.advanceTimersByTime(3500); + }); + expect(hint()?.textContent).toContain('HINT_C'); + }); + + it('clamps on the last hint on a long wait instead of looping back', () => { + const { container } = render( + , + ); + const hint = () => container.querySelector('[data-testid="build-proposal-progress"]'); + act(() => { + vi.advanceTimersByTime(3500 * 8); // well past the last stage + }); + expect(hint()?.textContent).toContain('HINT_B'); + expect(hint()?.textContent).not.toContain('HINT_A'); + }); + + it('shows no step dots when rotation is disabled (single hint)', () => { + const { container } = render( + , + ); + expect(container.querySelector('[data-testid="build-proposal-progress"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="build-proposal-progress-dots"]')).toBeNull(); + }); +});