diff --git a/apps/web/app/(app)/[organization]/[connectionId]/chatbot/chatbot-page.client.tsx b/apps/web/app/(app)/[organization]/[connectionId]/chatbot/chatbot-page.client.tsx index 03f98909..5e77b6fc 100644 --- a/apps/web/app/(app)/[organization]/[connectionId]/chatbot/chatbot-page.client.tsx +++ b/apps/web/app/(app)/[organization]/[connectionId]/chatbot/chatbot-page.client.tsx @@ -1,11 +1,12 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; import { Button } from '@/registry/new-york-v4/ui/button'; import { Loader2, X } from 'lucide-react'; import ChatBotComp from './thread/chatbox'; +import ChatWelcome from './components/empty'; import ChatSessionSidebar from './sessions/chat-session-sidebar'; import SessionDeleteDialog from './sessions/session-delete-dialog'; @@ -79,6 +80,15 @@ export default function ChatBotPageContent({ ], ); + const pendingPromptRef = useRef(null); + + const handleWelcomeSend = async (text: string) => { + pendingPromptRef.current = text; + await chat.handleCreateSession(); + // pendingPromptRef is read by ChatBotComp on mount, then cleared after a tick + requestAnimationFrame(() => { pendingPromptRef.current = null; }); + }; + const onExecuteAction: CopilotActionExecutor = async (action) => { if (action.type === 'sql.replace') { console.log('replace sql', action.sql); @@ -119,6 +129,7 @@ export default function ChatBotPageContent({ key={mode === 'copilot' ? (copilotEnvelope?.meta?.tabId ?? 'copilot') : chat.selectedSessionId} sessionId={chat.selectedSessionId} initialMessages={chat.initialMessages} + initialPrompt={pendingPromptRef.current} onConversationActivity={chat.handleConversationActivity} onExecuteAction={onExecuteAction} onSessionCreated={(sessionId) => chat.setSelectedSessionId(sessionId)} @@ -127,9 +138,10 @@ export default function ChatBotPageContent({ /> ) ) : ( -
- {t('EmptyState')} -
+ )} diff --git a/apps/web/app/(app)/[organization]/[connectionId]/chatbot/components/empty.tsx b/apps/web/app/(app)/[organization]/[connectionId]/chatbot/components/empty.tsx index e69de29b..4b2d3fde 100644 --- a/apps/web/app/(app)/[organization]/[connectionId]/chatbot/components/empty.tsx +++ b/apps/web/app/(app)/[organization]/[connectionId]/chatbot/components/empty.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { useState } from 'react'; +import { useAtom } from 'jotai'; +import { useTranslations } from 'next-intl'; +import { Sparkles } from 'lucide-react'; + +import { + PromptInput, + PromptInputBody, + PromptInputSubmit, + PromptInputTextarea, + PromptInputFooter, + type PromptInputMessage, +} from '@/components/ai-elements/prompt-input'; +import { Suggestions, Suggestion } from '@/components/ai-elements/suggestion'; +import { activeDatabaseAtom } from '@/shared/stores/app.store'; +import { useDatabases } from '@/hooks/use-databases'; +import { DatabaseSelect } from '../../../components/sql-console-sidebar/database-select'; + +type ChatWelcomeProps = { + onSend: (text: string) => void; + disabled?: boolean; +}; + +const SUGGESTION_KEYS = ['TopUsers', 'ErrorLogs', 'OrderTrends', 'TableSummary'] as const; + +export default function ChatWelcome({ onSend, disabled = false }: ChatWelcomeProps) { + const t = useTranslations('Chatbot'); + const [input, setInput] = useState(''); + const [activeDatabase, setActiveDatabase] = useAtom(activeDatabaseAtom); + const { databases } = useDatabases(); + + const handleSubmit = (message: PromptInputMessage) => { + const text = message.text?.trim(); + if (!text) return; + onSend(text); + }; + + const handleSuggestionClick = (suggestion: string) => { + onSend(suggestion); + }; + + const handleDatabaseChange = (db: string) => { + setActiveDatabase(db); + }; + + return ( +
+
+
+
+ +

+ {t('Welcome.Heading')} +

+
+

+ {t('Welcome.Subheading')} +

+
+ + + {SUGGESTION_KEYS.map((key) => ( + + ))} + + +
+ + +
+
+ +
+
+ setInput(e.target.value)} + className="min-h-18 w-full resize-none border-0 bg-transparent text-sm focus-visible:outline-none focus-visible:ring-0" + /> +
+
+
+ + + +
+
+
+
+ ); +} diff --git a/apps/web/app/(app)/[organization]/[connectionId]/chatbot/thread/chatbox.tsx b/apps/web/app/(app)/[organization]/[connectionId]/chatbot/thread/chatbox.tsx index b86fa19f..6ea43d87 100644 --- a/apps/web/app/(app)/[organization]/[connectionId]/chatbot/thread/chatbox.tsx +++ b/apps/web/app/(app)/[organization]/[connectionId]/chatbot/thread/chatbox.tsx @@ -35,13 +35,14 @@ type ChatBotCompProps = { initialMessages: UIMessage[]; onConversationActivity?: () => void; onSessionCreated?: (sessionId: string) => void; + initialPrompt?: string | null; mode?: 'global' | 'copilot'; copilotEnvelope?: CopilotEnvelopeV1 | null; onExecuteAction?: CopilotActionExecutor; }; -const ChatBotComp = ({ sessionId, initialMessages, onConversationActivity, onSessionCreated, mode = 'global', copilotEnvelope = null, onExecuteAction }: ChatBotCompProps) => { +const ChatBotComp = ({ sessionId, initialMessages, onConversationActivity, onSessionCreated, initialPrompt = null, mode = 'global', copilotEnvelope = null, onExecuteAction }: ChatBotCompProps) => { const router = useRouter(); const params = useParams<{ organization: string; connectionId: string }>(); const t = useTranslations('Chatbot'); @@ -157,6 +158,14 @@ const ChatBotComp = ({ sessionId, initialMessages, onConversationActivity, onSes return () => cancelAnimationFrame(raf); }, [chatStateId, messages, restoreScrollPosition]); + const initialPromptSubmittedRef = useRef(false); + useEffect(() => { + if (initialPrompt && !initialPromptSubmittedRef.current && status === 'ready') { + initialPromptSubmittedRef.current = true; + handleSubmit({ text: initialPrompt, files: [] }); + } + }, [initialPrompt, status]); + const handleCopySql = useCallback( async (sql: string) => { try { diff --git a/apps/web/app/(app)/[organization]/[connectionId]/sql-console/components/tabs/tab-empty.tsx b/apps/web/app/(app)/[organization]/[connectionId]/sql-console/components/tabs/tab-empty.tsx index 028e541f..61e7a1f7 100644 --- a/apps/web/app/(app)/[organization]/[connectionId]/sql-console/components/tabs/tab-empty.tsx +++ b/apps/web/app/(app)/[organization]/[connectionId]/sql-console/components/tabs/tab-empty.tsx @@ -1,10 +1,20 @@ import { Button } from "@/registry/new-york-v4/ui/button"; -import { Loader2, Plus } from "lucide-react"; +import { Plus, Sparkles } from "lucide-react"; import { useTranslations } from "next-intl"; +import { useParams, useRouter } from "next/navigation"; export default function SQLTabEmpty(props: { addTab: () => void; disabled?: boolean }) { const { addTab, disabled = false } = props; const t = useTranslations('SqlConsole'); + const router = useRouter(); + const params = useParams<{ organization: string; connectionId: string }>(); + + const handleAskAI = () => { + if (params?.organization && params?.connectionId) { + router.push(`/${params.organization}/${params.connectionId}/chatbot`); + } + }; + return (
@@ -17,10 +27,17 @@ export default function SQLTabEmpty(props: { addTab: () => void; disabled?: bool
{t('Empty.ShortcutToggleCopilot')}
{t('Empty.ShortcutFormat')}
- +
+ + {t('Empty.OrDivider')} + +
); } diff --git a/apps/web/public/locales/en.json b/apps/web/public/locales/en.json index c7a2ddbc..47f14696 100644 --- a/apps/web/public/locales/en.json +++ b/apps/web/public/locales/en.json @@ -749,6 +749,16 @@ "Chatbot": { "Close": "Close", "EmptyState": "Select a session or create a new one.", + "Welcome": { + "Heading": "Ask anything about your data", + "Subheading": "AI generates SQL, runs queries, and visualizes results instantly", + "Suggestions": { + "TopUsers": "Show top 10 users by order amount", + "ErrorLogs": "Error logs in the last 24 hours", + "OrderTrends": "Daily order trends this month", + "TableSummary": "Summary of all tables with row counts" + } + }, "Typing": "Generating", "Input": { "GlobalPlaceholder": "@ mention tables to ask about the database, Shift + Enter for a new line", @@ -958,7 +968,9 @@ "ShortcutNewTab": "New tab: Cmd/Ctrl+T", "ShortcutToggleCopilot": "Open Copilot: Cmd/Ctrl+I", "ShortcutFormat": "Format SQL: Shift+Cmd/Ctrl+F", - "NewConsole": "New console" + "NewConsole": "New console", + "OrDivider": "or", + "AskAI": "Ask AI to write SQL" }, "Editor": { "NoActiveTab": "No active tab", diff --git a/apps/web/public/locales/zh.json b/apps/web/public/locales/zh.json index bfa095fe..fa435af1 100644 --- a/apps/web/public/locales/zh.json +++ b/apps/web/public/locales/zh.json @@ -751,6 +751,16 @@ "Chatbot": { "Close": "关闭", "EmptyState": "请选择一个会话或创建新的会话。", + "Welcome": { + "Heading": "向 AI 提问关于你的数据", + "Subheading": "AI 自动生成 SQL、执行查询并即时可视化结果", + "Suggestions": { + "TopUsers": "按订单金额显示前 10 名用户", + "ErrorLogs": "最近 24 小时的错误日志", + "OrderTrends": "本月每日订单趋势", + "TableSummary": "所有表的行数汇总" + } + }, "Typing": "正在生成", "Input": { "GlobalPlaceholder": "@ 选中表进行数据库问答,Shift + Enter 换行", @@ -960,7 +970,9 @@ "ShortcutNewTab": "新建标签页:Cmd/Ctrl+T", "ShortcutToggleCopilot": "切换 Copilot:Cmd/Ctrl+K", "ShortcutFormat": "格式化 SQL:Shift+Cmd/Ctrl+F", - "NewConsole": "新建控制台" + "NewConsole": "新建控制台", + "OrDivider": "或", + "AskAI": "让 AI 帮你写 SQL" }, "Editor": { "NoActiveTab": "没有活动标签", diff --git a/tests/e2e/ai-experience.spec.ts b/tests/e2e/ai-experience.spec.ts new file mode 100644 index 00000000..3fc63104 --- /dev/null +++ b/tests/e2e/ai-experience.spec.ts @@ -0,0 +1,189 @@ +import { expect, type Page, type Route } from '@playwright/test'; + +import { test } from './fixtures'; +import { createWorkbenchConnection, mockWorkbenchApis, openMockConnectionConsole } from './helpers/workbench'; + +/** + * expectAppHealthy variant that ignores PostHog console errors + * (PostHog has no valid API key in the test environment). + */ +function expectAppHealthy(appErrors: string[]) { + const relevant = appErrors.filter(e => !/posthog/i.test(e)); + expect(relevant, relevant.join('\n')).toEqual([]); +} + +const seededConnection = createWorkbenchConnection(); + +const json = async (route: Route, body: unknown, status = 200) => { + await route.fulfill({ + status, + contentType: 'application/json', + body: JSON.stringify(body), + }); +}; + +/** + * Mock chatbot-related APIs so the page renders without a real backend. + * Response format must match ApiEnvelope: { code: 0, data: { ... } } + */ +async function mockChatApis(page: Page) { + const sessions: Array<{ id: string; title: string | null; type: string; createdAt: string }> = []; + + await page.route('**/api/chat/sessions*', async route => { + const request = route.request(); + if (request.method() === 'GET') { + await json(route, { code: 0, message: 'success', data: { sessions } }); + return; + } + if (request.method() === 'POST') { + const now = new Date().toISOString(); + const session = { + id: `session-${sessions.length + 1}`, + title: null, + type: 'global', + createdAt: now, + updatedAt: now, + lastMessageAt: null, + archivedAt: null, + metadata: null, + }; + sessions.push(session); + await json(route, { code: 0, message: 'success', data: { session } }); + return; + } + await route.fallback(); + }); + + await page.route('**/api/chat/session/*', async route => { + const request = route.request(); + if (request.method() === 'GET') { + const url = new URL(request.url()); + const sessionId = url.pathname.split('/').pop(); + const session = sessions.find(s => s.id === sessionId) ?? sessions[0]; + await json(route, { + code: 0, + message: 'success', + data: { + session: session ?? { id: sessionId, title: null, type: 'global' }, + messages: [], + }, + }); + return; + } + await route.fallback(); + }); + + // Mock the main chat POST — return a simple streamed text response + await page.route('**/api/chat', async route => { + if (route.request().method() !== 'POST') { + await route.fallback(); + return; + } + + const body = route.request().postDataJSON() as any; + const chatId = body?.chatId ?? sessions[0]?.id ?? 'session-1'; + + const responseText = 'Here is the result for your query.'; + const streamParts = [`0:${JSON.stringify(responseText)}\n`]; + + await route.fulfill({ + status: 200, + contentType: 'text/plain; charset=utf-8', + headers: { 'x-chat-id': chatId }, + body: streamParts.join(''), + }); + }); +} + +/** + * Navigate to chatbot page with mocked connection in localStorage. + */ +async function openMockConnectionChatbot(page: Page, connection = seededConnection) { + await page.goto('/'); + await page.waitForURL(/\/[^/]+\/connections$/); + + const match = page.url().match(/\/([^/]+)\/connections$/); + const orgId = match?.[1]; + if (!orgId) throw new Error(`Failed to resolve org id from URL: ${page.url()}`); + + await page.evaluate( + value => window.localStorage.setItem('currentConnection', JSON.stringify(value)), + connection, + ); + + await page.goto(`/${orgId}/${connection.connection.id}/chatbot`); +} + +// --------------------------------------------------------------------------- +// Chatbot welcome page tests +// --------------------------------------------------------------------------- + +test.describe('Chatbot welcome page', () => { + test('shows welcome heading and suggested prompts when no session is selected', async ({ page, appErrors }) => { + await mockWorkbenchApis(page, { initialConnections: [seededConnection] }); + await mockChatApis(page); + await openMockConnectionChatbot(page); + + // Welcome heading should be visible + await expect(page.getByText('Ask anything about your data')).toBeVisible(); + + // Subheading should be visible + await expect(page.getByText(/AI generates SQL/i)).toBeVisible(); + + // Suggested prompt buttons should be visible + await expect(page.getByRole('button', { name: /top 10 users/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /error logs/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /order trends/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /table.*row counts/i })).toBeVisible(); + + await expectAppHealthy(appErrors); + }); + + test('clicking a suggested prompt creates a session and sends a message', async ({ page, appErrors }) => { + await mockWorkbenchApis(page, { initialConnections: [seededConnection] }); + await mockChatApis(page); + await openMockConnectionChatbot(page); + + // Wait for welcome state to be ready + await expect(page.getByRole('button', { name: /top 10 users/i })).toBeVisible(); + + // Click a suggestion + await page.getByRole('button', { name: /top 10 users/i }).click(); + + // Welcome page should disappear and chat view should appear + await expect(page.getByText('Ask anything about your data')).toBeHidden({ timeout: 15000 }); + + // The chat input area should now be visible (the PromptInput textarea within ChatBotComp) + await expect(page.locator('textarea[name="message"]')).toBeVisible(); + + await expectAppHealthy(appErrors); + }); +}); + +// --------------------------------------------------------------------------- +// SQL Console empty state AI button tests +// --------------------------------------------------------------------------- + +test.describe('SQL Console AI entry', () => { + test('shows "Ask AI" button in empty state', async ({ page, appErrors }) => { + await mockWorkbenchApis(page, { initialConnections: [seededConnection] }); + await openMockConnectionConsole(page, seededConnection); + + await expect(page.getByRole('button', { name: /ask ai/i })).toBeVisible(); + + await expectAppHealthy(appErrors); + }); + + test('"Ask AI" button navigates to chatbot page', async ({ page, appErrors }) => { + await mockWorkbenchApis(page, { initialConnections: [seededConnection] }); + await mockChatApis(page); + await openMockConnectionConsole(page, seededConnection); + + await page.getByRole('button', { name: /ask ai/i }).click(); + + await expect(page).toHaveURL(/\/chatbot$/); + await expect(page.getByText('Ask anything about your data')).toBeVisible(); + + await expectAppHealthy(appErrors); + }); +});