diff --git a/.github/workflows/deploy-lowcoder-sdk-webpack-netlify.yml b/.github/workflows/deploy-lowcoder-sdk-webpack-netlify.yml new file mode 100644 index 000000000..13f64a674 --- /dev/null +++ b/.github/workflows/deploy-lowcoder-sdk-webpack-netlify.yml @@ -0,0 +1,55 @@ +# Builds client/packages/lowcoder-sdk-webpack-bundle and deploys its dist/ folder to Netlify. +# +# Deploy uses --no-build so Netlify CLI does not run the site UI "build command" (e.g. expo). +# The webpack bundle is built in the prior CI step. +# +# Repository secrets (Netlify: Site settings → General → Site details → Site ID; +# User settings → Applications → Personal access tokens): +# NETLIFY_AUTH_TOKEN — Netlify personal access token +# NETLIFY_SITE_ID — Site API ID for the Netlify site + +name: Deploy SDK Webpack Bundle to Netlify + +on: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: deploy-sdk-webpack-netlify-${{ github.ref }} + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version: 20.x + cache: yarn + cache-dependency-path: client/yarn.lock + + - name: Install dependencies + uses: borales/actions-yarn@v4.2.0 + with: + cmd: install + dir: client + + - name: Build lowcoder-sdk-webpack-bundle + uses: borales/actions-yarn@v4.2.0 + with: + cmd: workspace lowcoder-sdk-webpack-bundle build + dir: client + + - name: Deploy dist to Netlify + working-directory: client/packages/lowcoder-sdk-webpack-bundle + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SDK_SITE_ID }} + run: npx --yes netlify-cli deploy --prod --dir=dist --no-build diff --git a/client/packages/lowcoder/src/components/ThemeSettingsSelector.tsx b/client/packages/lowcoder/src/components/ThemeSettingsSelector.tsx index f2004d43a..67cbb9c20 100644 --- a/client/packages/lowcoder/src/components/ThemeSettingsSelector.tsx +++ b/client/packages/lowcoder/src/components/ThemeSettingsSelector.tsx @@ -254,7 +254,7 @@ export default function ThemeSettingsSelector(props: ColorConfigProps) { }; const gridPaddingInputBlur = (padding: string) => { - let result = 20; + let result = 0; if (padding !== '') { result = Number(padding); } diff --git a/client/packages/lowcoder/src/comps/comps/appSettingsComp.tsx b/client/packages/lowcoder/src/comps/comps/appSettingsComp.tsx index 64122daba..b38260eed 100644 --- a/client/packages/lowcoder/src/comps/comps/appSettingsComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/appSettingsComp.tsx @@ -219,8 +219,8 @@ const childrenMap = { gridColumns: RangeControl.closed(1, 48, 24), gridRowHeight: RangeControl.closed(4, 100, 8), gridRowCount: withDefault(NumberControl, DEFAULT_ROW_COUNT), - gridPaddingX: withDefault(NumberControl, 20), - gridPaddingY: withDefault(NumberControl, 20), + gridPaddingX: withDefault(NumberControl, 0), + gridPaddingY: withDefault(NumberControl, 0), gridBg: ColorControl, gridBgImage: StringControl, gridBgImageRepeat: StringControl, @@ -342,6 +342,10 @@ function AppGeneralSettingsModal(props: ChildrenInstance) { function AppCanvasSettingsModal(props: ChildrenInstance) { const isPublicApp = useSelector(isPublicApplication); + const application = useSelector(currentApplication); + const isAggregation = !!application && isAggregationApp( + AppUILayoutType[application.applicationType] + ); const { themeList, defaultTheme, @@ -397,7 +401,7 @@ function AppCanvasSettingsModal(props: ChildrenInstance) { return ( <> - {maxWidth.propertyView({ + {!isAggregation && maxWidth.propertyView({ dropdownLabel: trans("appSetting.canvasMaxWidth"), inputLabel: trans("appSetting.userDefinedMaxWidth"), inputPlaceholder: trans("appSetting.inputUserDefinedPxValue"), @@ -462,25 +466,25 @@ function AppCanvasSettingsModal(props: ChildrenInstance) { min: 350, lastNode: {trans("appSetting.maxWidthTip")}, })} - {gridColumns.propertyView({ + {!isAggregation && gridColumns.propertyView({ label: trans("appSetting.gridColumns"), placeholder: '24', })} - {gridRowHeight.propertyView({ + {!isAggregation && gridRowHeight.propertyView({ label: trans("appSetting.gridRowHeight"), placeholder: '8', })} - {gridRowCount.propertyView({ + {!isAggregation && gridRowCount.propertyView({ label: trans("appSetting.gridRowCount"), placeholder: 'Infinity', })} {gridPaddingX.propertyView({ label: trans("appSetting.gridPaddingX"), - placeholder: '20', + placeholder: '0', })} {gridPaddingY.propertyView({ label: trans("appSetting.gridPaddingY"), - placeholder: '20', + placeholder: '0', })} {gridBg.propertyView({ label: trans("style.background"), diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx index 39de2e739..bce90b54a 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx @@ -14,7 +14,7 @@ import { ChatProvider } from "./components/context/ChatContext"; import { ChatPropertyView } from "./chatPropertyView"; import { createChatStorage } from "./utils/storageFactory"; import { QueryHandler } from "./handlers/messageHandlers"; -import { useMemo, useRef, useEffect } from "react"; +import { useMemo, useRef } from "react"; import { changeChildAction } from "lowcoder-core"; import { ChatMessage } from "./types/chatTypes"; import { trans } from "i18n"; @@ -249,16 +249,6 @@ const ChatTmpComp = new UICompBuilder( } }; - // Cleanup on unmount - useEffect(() => { - return () => { - const tableName = uniqueTableName.current; - if (tableName) { - storage.cleanup(); - } - }; - }, []); - // custom styles const styles = { style: props.style, diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx index f4823011e..6c861e215 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx @@ -1,47 +1,57 @@ // client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx -import { useMemo, useEffect } from "react"; +import { useMemo, useContext, useRef, useEffect } from "react"; import { ChatPanelContainer } from "./ChatPanelContainer"; import { createChatStorage } from "../utils/storageFactory"; -import { N8NHandler } from "../handlers/messageHandlers"; +import { AIAssistantQueryHandler } from "../handlers/messageHandlers"; import { ChatPanelProps } from "../types/chatTypes"; -import { trans } from "i18n"; +import { EditorContext } from "@lowcoder-ee/comps/editorState"; import "@assistant-ui/styles/index.css"; import "@assistant-ui/styles/markdown.css"; // ============================================================================ -// CHAT PANEL - SIMPLIFIED BOTTOM PANEL (NO STYLING CONTROLS) +// CHAT PANEL - SIMPLIFIED BOTTOM PANEL (QUERY-BASED + AUTOMATOR) +// ---------------------------------------------------------------------------- +// We capture the EditorState in a ref so the message handler always reads +// the *latest* canvas snapshot at send-time (instead of being frozen at +// mount time, which would defeat the whole point of context awareness). // ============================================================================ +interface ExtendedChatPanelProps extends ChatPanelProps { + /** When false, send conversation history without the Automator system prompt. */ + enableAutomator?: boolean; +} + export function ChatPanel({ tableName, - modelHost, - systemPrompt = trans("chat.defaultSystemPrompt"), - streaming = true, - onMessageUpdate -}: ChatPanelProps) { + chatQuery, + onMessageUpdate, + enableAutomator = true, +}: ExtendedChatPanelProps) { + const editorState = useContext(EditorContext); + const editorStateRef = useRef(editorState); + + useEffect(() => { + editorStateRef.current = editorState; + }, [editorState]); + const storage = useMemo(() => createChatStorage(tableName), [tableName] ); - const messageHandler = useMemo(() => - new N8NHandler({ - modelHost, - systemPrompt, - streaming - }), - [modelHost, systemPrompt, streaming] + const messageHandler = useMemo( + () => + new AIAssistantQueryHandler({ + chatQuery, + dispatch: editorState?.rootComp?.dispatch, + getEditorState: () => editorStateRef.current, + enableAutomator, + }), + [chatQuery, editorState?.rootComp?.dispatch, enableAutomator] ); - // Cleanup on unmount - delete chat data from storage - useEffect(() => { - return () => { - storage.cleanup(); - }; - }, [storage]); - return ( ); -} \ No newline at end of file +} diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanelContainer.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanelContainer.tsx index 9f0766cea..a74e07d0d 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanelContainer.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanelContainer.tsx @@ -18,7 +18,7 @@ import { RegularThreadData, ArchivedThreadData } from "./context/ChatContext"; -import { MessageHandler, ChatMessage } from "../types/chatTypes"; +import { AIAssistantMessageHandler, ChatMessage } from "../types/chatTypes"; import styled from "styled-components"; import { trans } from "i18n"; import { TooltipProvider } from "@radix-ui/react-tooltip"; @@ -26,9 +26,86 @@ import { TooltipProvider } from "@radix-ui/react-tooltip"; import "@assistant-ui/styles/index.css"; import "@assistant-ui/styles/markdown.css"; import { EditorContext } from "@lowcoder-ee/comps/editorState"; +import { ActionConfig, ActionExecuteParams } from "../../preLoadComp/types"; import { configureComponentAction } from "../../preLoadComp/actions/componentConfiguration"; -import { addComponentAction, moveComponentAction, nestComponentAction, resizeComponentAction } from "../../preLoadComp/actions/componentManagement"; -import { applyThemeAction, configureAppMetaAction, setCanvasSettingsAction } from "../../preLoadComp/actions/appConfiguration"; +import { + addComponentAction, + moveComponentAction, + nestComponentAction, + resizeComponentAction, + deleteComponentAction, + renameComponentAction, +} from "../../preLoadComp/actions/componentManagement"; +import { + applyThemeAction, + configureAppMetaAction, + setCanvasSettingsAction, + applyGlobalJSAction, + applyCSSAction, + publishAppAction, +} from "../../preLoadComp/actions/appConfiguration"; +import { applyStyleAction } from "../../preLoadComp/actions/componentStyling"; +import { addEventHandlerAction } from "../../preLoadComp/actions/componentEvents"; +import { alignComponentAction } from "../../preLoadComp/actions/componentLayout"; + +// ============================================================================ +// ACTION REGISTRY — maps LLM action names to their executor configs. +// Adding a new action is one line here + one entry in actionsCatalog.ts. +// ============================================================================ + +const ACTION_REGISTRY: Record = { + place_component: addComponentAction, + nest_component: nestComponentAction, + move_component: moveComponentAction, + resize_component: resizeComponentAction, + delete_component: deleteComponentAction, + rename_component: renameComponentAction, + set_properties: configureComponentAction, + set_style: applyStyleAction, + set_theme: applyThemeAction, + set_app_metadata: configureAppMetaAction, + set_canvas_setting: setCanvasSettingsAction, + set_global_javascript: applyGlobalJSAction, + set_global_css: applyCSSAction, + publish_app: publishAppAction, + add_event_handler: addEventHandlerAction, + align_component: alignComponentAction, +}; + +/** + * Translate an LLM action object into the ActionExecuteParams shape that + * the legacy executor functions expect. Centralises the field-mapping so + * each executor doesn't need to know about the automator format. + */ +function buildExecuteParams( + actionItem: Record, + editorState: any +): ActionExecuteParams { + const ap = actionItem.action_parameters || {}; + + let actionValue = ""; + switch (actionItem.action) { + case "rename_component": actionValue = ap.new_name || ""; break; + case "set_style": actionValue = JSON.stringify(ap); break; + case "align_component": actionValue = ap.alignment || "center"; break; + case "add_event_handler": actionValue = `${ap.event || "click"}: ${ap.action_type || "message"}`; break; + case "set_global_javascript": actionValue = ap.code || ""; break; + case "set_global_css": actionValue = ap.code || ""; break; + } + + return { + actionKey: actionItem.action, + actionValue, + actionPayload: actionItem, + selectedComponent: actionItem.component || null, + selectedEditorComponent: actionItem.component_name || null, + selectedNestComponent: null, + editorState, + selectedDynamicLayoutIndex: null, + selectedTheme: null, + selectedCustomShortcutAction: null, + }; +} // ============================================================================ // STYLED CONTAINER - SIMPLE FIXED STYLING FOR BOTTOM PANEL @@ -75,9 +152,25 @@ const StyledChatContainer = styled.div<{ const generateId = () => Math.random().toString(36).substr(2, 9); +/** + * Append a small footer to the assistant message summarising what the + * Automator just did, so the human can audit at a glance. + */ +function formatAutomatorFooter(actionsCount: number, invalidCount: number): string { + if (actionsCount === 0 && invalidCount === 0) return ""; + const parts: string[] = []; + if (actionsCount > 0) { + parts.push(`${actionsCount} action${actionsCount === 1 ? "" : "s"} executed`); + } + if (invalidCount > 0) { + parts.push(`${invalidCount} skipped (unsupported)`); + } + return `\n\n_— Automator: ${parts.join(", ")}_`; +} + export interface ChatPanelContainerProps { storage: any; - messageHandler: MessageHandler; + messageHandler: AIAssistantMessageHandler; placeholder?: string; onMessageUpdate?: (message: string) => void; } @@ -98,139 +191,30 @@ function ChatPanelView({ messageHandler, placeholder, onMessageUpdate }: Omit { if (!editorStateRef.current) { - console.error("No editorStateRef found"); + console.error("[Automator] no editorState — skipping actions"); return; } - - const comp = editorStateRef.current.getUIComp().children.comp; - if (!comp) { - console.error("No comp found"); - return; - } - // const layout = comp.children.layout.getView(); - // console.log("LAYOUT", layout); - + + console.log(`[Automator] executing ${actions.length} action(s)`); + let executed = 0; + for (const actionItem of actions) { - const { action, component, ...action_payload } = actionItem; - - switch (action) { - case "place_component": - await addComponentAction.execute({ - actionKey: action, - actionValue: "", - actionPayload: action_payload, - selectedComponent: component, - selectedEditorComponent: null, - selectedNestComponent: null, - editorState: editorStateRef.current, - selectedDynamicLayoutIndex: null, - selectedTheme: null, - selectedCustomShortcutAction: null - }); - break; - case "nest_component": - await nestComponentAction.execute({ - actionKey: action, - actionValue: "", - actionPayload: action_payload, - selectedComponent: component, - selectedEditorComponent: null, - selectedNestComponent: null, - editorState: editorStateRef.current, - selectedDynamicLayoutIndex: null, - selectedTheme: null, - selectedCustomShortcutAction: null - }); - break; - case "move_component": - await moveComponentAction.execute({ - actionKey: action, - actionValue: "", - actionPayload: action_payload, - selectedComponent: component, - selectedEditorComponent: null, - selectedNestComponent: null, - editorState: editorStateRef.current, - selectedDynamicLayoutIndex: null, - selectedTheme: null, - selectedCustomShortcutAction: null - }); - break; - case "resize_component": - await resizeComponentAction.execute({ - actionKey: action, - actionValue: "", - actionPayload: action_payload, - selectedComponent: component, - selectedEditorComponent: null, - selectedNestComponent: null, - editorState: editorStateRef.current, - selectedDynamicLayoutIndex: null, - selectedTheme: null, - selectedCustomShortcutAction: null - }); - break; - case "set_properties": - await configureComponentAction.execute({ - actionKey: action, - actionValue: component, - actionPayload: action_payload, - selectedEditorComponent: null, - selectedComponent: null, - selectedNestComponent: null, - editorState: editorStateRef.current, - selectedDynamicLayoutIndex: null, - selectedTheme: null, - selectedCustomShortcutAction: null - }); - break; - case "set_theme": - await applyThemeAction.execute({ - actionKey: action, - actionValue: component, - actionPayload: action_payload, - selectedEditorComponent: null, - selectedComponent: null, - selectedNestComponent: null, - editorState: editorStateRef.current, - selectedDynamicLayoutIndex: null, - selectedTheme: null, - selectedCustomShortcutAction: null - }); - break; - case "set_app_metadata": - await configureAppMetaAction.execute({ - actionKey: action, - actionValue: component, - actionPayload: action_payload, - selectedEditorComponent: null, - selectedComponent: null, - selectedNestComponent: null, - editorState: editorStateRef.current, - selectedDynamicLayoutIndex: null, - selectedTheme: null, - selectedCustomShortcutAction: null - }); - break; - case "set_canvas_setting": - await setCanvasSettingsAction.execute({ - actionKey: action, - actionValue: component, - actionPayload: action_payload, - selectedEditorComponent: null, - selectedComponent: null, - selectedNestComponent: null, - editorState: editorStateRef.current, - selectedDynamicLayoutIndex: null, - selectedTheme: null, - selectedCustomShortcutAction: null - }); - break; - default: - break; + const executor = ACTION_REGISTRY[actionItem.action]; + if (!executor) { + console.warn(`[Automator] unsupported action: ${actionItem.action}`); + continue; } - await new Promise(resolve => setTimeout(resolve, 1000)); + try { + const params = buildExecuteParams(actionItem, editorStateRef.current); + await executor.execute(params); + executed++; + } catch (err) { + console.error(`[Automator] action "${actionItem.action}" failed:`, err); + } + await new Promise((r) => setTimeout(r, 200)); } + + console.log(`[Automator] done: ${executed}/${actions.length} succeeded`); }; const convertMessage = (message: ChatMessage): ThreadMessageLike => { @@ -262,21 +246,31 @@ function ChatPanelView({ messageHandler, placeholder, onMessageUpdate }: Omit { - const { modelHost, systemPrompt, streaming } = this.config; + async sendMessage(message: ChatMessage): Promise { + const { chatQuery, dispatch} = this.config; - if (!modelHost) { - throw new Error("Model host is required for N8N calls"); + // If no query selected or dispatch unavailable, return mock response + if (!chatQuery || !dispatch) { + console.log("No query selected or dispatch unavailable, returning mock response"); + await new Promise((res) => setTimeout(res, 500)); + return { content: "(mock) You typed: " + message.text }; } try { - const response = await fetch(modelHost, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - sessionId, - message: message.text, - systemPrompt: systemPrompt || "You are a helpful assistant.", - streaming: streaming || false - }) - }); - - if (!response.ok) { - throw new Error(`N8N call failed: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - if (data.output) { - const { explanation, actions } = JSON.parse(data.output); - return { content: explanation, actions }; - } - // Extract content from various possible response formats - const content = data.response || data.message || data.content || data.text || String(data); - - return { content }; - } catch (error) { - throw new Error(`N8N call failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + console.log("Executing query:", chatQuery); + const result: any = await getPromiseAfterDispatch( + dispatch, + routeByNameAction( + chatQuery, + executeQueryAction({ + // Pass the full message object so attachments are available in queries + args: { + message: { value: message }, // Full ChatMessage object with attachments + prompt: { value: message.text }, // Keep backward compatibility + }, + }) + ) + ); + console.log("Query result:", result); + return result.message + } catch (e: any) { + throw new Error(e?.message || "Query execution failed"); } } } // ============================================================================ -// QUERY HANDLER (for Canvas Components) +// AI ASSISTANT QUERY HANDLER (bottom panel) +// ---------------------------------------------------------------------------- +// This is the heart of the Lowcoder Automator. On every send it: +// 1. snapshots the current editor state (components, queries, canvas), +// 2. composes a lean system prompt + actions catalog + live context, +// 3. forwards the enriched `messages` array (and a few extras) to the +// user-defined Lowcoder query (typically a JS query that calls an LLM +// via an HTTP query), +// 4. parses the model's text reply back into `{ explanation, actions }`, +// 5. returns both — the chat panel renders `explanation` and dispatches +// `actions` against the editor. // ============================================================================ -export class QueryHandler implements MessageHandler { +export class AIAssistantQueryHandler implements AIAssistantMessageHandler { constructor(private config: QueryHandlerConfig) {} - async sendMessage(message: ChatMessage, sessionId?: string): Promise { - const { chatQuery, dispatch} = this.config; - - // If no query selected or dispatch unavailable, return mock response + async sendMessage( + message: ChatMessage, + sessionId?: string, + conversationHistory?: ChatMessage[] + ): Promise { + const { chatQuery, dispatch, getEditorState, enableAutomator = true } = this.config; + const history = conversationHistory ?? [message]; + + // Conversation history in the OpenAI {role, content} shape. + const rawHistory = history.map((msg) => ({ + role: msg.role, + content: msg.text, + })); + + // Build the Automator payload. When the editor state is unavailable + // (eg. mock setup) we still get a valid (empty) snapshot so the prompt + // is consistent. + const editorState = getEditorState ? getEditorState() : null; + const payload = buildAutomatorPayload({ + history: rawHistory, + editorState, + withSystemPrompt: enableAutomator, + }); + if (!chatQuery || !dispatch) { - await new Promise((res) => setTimeout(res, 500)); - return { content: "(mock) You typed: " + message.text }; + console.log( + "[Automator] No query selected or dispatch unavailable, returning mock" + ); + await new Promise((res) => setTimeout(res, 300)); + return { + content: + "(mock) Connect a query in the AI Assistant header to enable the Automator.\n\nYou typed: " + + message.text, + }; } try { + console.log("[Automator] running query:", chatQuery, { + contextComponents: payload.context.components.length, + contextQueries: payload.context.queries.length, + messageCount: payload.messages.length, + }); + const result: any = await getPromiseAfterDispatch( dispatch, routeByNameAction( chatQuery, executeQueryAction({ - // Pass the full message object so attachments are available in queries - args: { - message: { value: message }, // Full ChatMessage object with attachments - prompt: { value: message.text }, // Keep backward compatibility + args: { + // ---- Backward-compatible fields (don't break old test queries) + message: { value: message }, + prompt: { value: message.text }, + sessionId: { value: sessionId }, + conversationHistory: { value: history }, + messages: { value: payload.messages }, + + // ---- Tool calling: the JS query should forward this to the + // HTTP body so the LLM can call `execute_automator_actions` + tools: { value: payload.tools }, + + // ---- Extra fields for power users + system: { value: payload.system }, + context: { value: payload.context }, + actionsCatalog: { value: payload.actionsCatalog }, + componentCatalog: { value: payload.componentCatalog }, + messagesWithoutSystem: { value: rawHistory }, }, }) ) ); - return result.message + // The query may return tool_calls (new path) or plain content (legacy). + // `parseResponse` tries tool_calls first, then falls back to text JSON + // extraction, so old queries that haven't been updated keep working. + const raw = result?.message ?? result ?? {}; + const content: string = + typeof raw === "string" + ? raw + : typeof raw.content === "string" + ? raw.content + : typeof raw === "object" && !raw.tool_calls + ? JSON.stringify(raw) + : ""; + const toolCalls: unknown[] | undefined = raw?.tool_calls; + + const parsed = parseResponse({ content, tool_calls: toolCalls }); + + const displayText = + parsed.isStructured && parsed.explanation + ? parsed.explanation + : content; + + console.log("[Automator] parsed", { + isStructured: parsed.isStructured, + actions: parsed.actions.length, + invalid: parsed.invalidActionCount, + }); + + return { + content: displayText, + actions: parsed.actions, + automator: { + isStructured: parsed.isStructured, + explanation: parsed.explanation, + invalidActionCount: parsed.invalidActionCount, + }, + }; } catch (e: any) { - throw new Error(e?.message || "Query execution failed"); + throw new Error(e?.message || "AI assistant query execution failed"); } } } @@ -107,15 +196,12 @@ export class MockHandler implements MessageHandler { // ============================================================================ export function createMessageHandler( - type: "n8n" | "query" | "mock", - config: N8NHandlerConfig | QueryHandlerConfig + type: "query" | "mock", + config: QueryHandlerConfig ): MessageHandler { switch (type) { - case "n8n": - return new N8NHandler(config as N8NHandlerConfig); - case "query": - return new QueryHandler(config as QueryHandlerConfig); + return new QueryHandler(config); case "mock": return new MockHandler(); diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts b/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts index d24e0ce84..4fcb734ec 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts +++ b/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts @@ -43,26 +43,47 @@ export interface ChatMessage { sendMessage(message: ChatMessage, sessionId?: string): Promise; // Future: sendMessageStream?(message: ChatMessage): AsyncGenerator; } + + export interface AIAssistantMessageHandler { + sendMessage(message: ChatMessage, sessionId?: string, conversationHistory?: ChatMessage[]): Promise; + } export interface MessageResponse { content: string; metadata?: any; actions?: any[]; + /** + * When the Automator parses a structured `{explanation, actions}` reply + * we surface the parsed payload here so the UI / downstream consumers + * can show extra context (e.g. "3 actions scheduled"). + */ + automator?: { + isStructured: boolean; + explanation: string; + invalidActionCount: number; + }; } // ============================================================================ // CONFIGURATION TYPES (simplified) // ============================================================================ - export interface N8NHandlerConfig { - modelHost: string; - systemPrompt?: string; - streaming?: boolean; - } - export interface QueryHandlerConfig { chatQuery: string; dispatch: any; + /** + * Snapshot accessor for the live editor state. The handler calls this + * lazily on every send so it always has the *current* canvas state. + * Optional — when missing the Automator falls back to a context-less + * passthrough (legacy behaviour). + */ + getEditorState?: () => any; + /** + * When false, the handler skips injecting the Automator system prompt + * and just forwards `messages` (the conversation history) as-is. Useful + * for plain ChatGPT-style queries that don't drive the canvas. + */ + enableAutomator?: boolean; } // ============================================================================ @@ -93,8 +114,6 @@ export interface ChatCoreProps { // Bottom Panel Props (simplified, no styling controls) export interface ChatPanelProps { tableName: string; - modelHost: string; - systemPrompt?: string; - streaming?: boolean; + chatQuery: string; onMessageUpdate?: (message: string) => void; } diff --git a/client/packages/lowcoder/src/comps/comps/dateComp/dateComp.tsx b/client/packages/lowcoder/src/comps/comps/dateComp/dateComp.tsx index c6aae7ad2..e26bf4ab6 100644 --- a/client/packages/lowcoder/src/comps/comps/dateComp/dateComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/dateComp/dateComp.tsx @@ -281,9 +281,6 @@ const DatePickerTmpCmp = new UICompBuilder(childrenMap, (props) => { props.onEvent ); }} - onPanelChange={() => { - handleDateChange("", props.value.onChange, noop); - }} onFocus={() => props.onEvent("focus")} onBlur={() => props.onEvent("blur")} suffixIcon={hasIcon(props.suffixIcon) && props.suffixIcon} diff --git a/client/packages/lowcoder/src/comps/comps/dateComp/dateUIView.tsx b/client/packages/lowcoder/src/comps/comps/dateComp/dateUIView.tsx index 5a7e188c8..9fcbc09b7 100644 --- a/client/packages/lowcoder/src/comps/comps/dateComp/dateUIView.tsx +++ b/client/packages/lowcoder/src/comps/comps/dateComp/dateUIView.tsx @@ -55,7 +55,7 @@ const StyledAntdSelect = styled(AntdSelect)` export interface DataUIViewProps extends DateCompViewProps { value?: DatePickerProps['value']; onChange: DatePickerProps['onChange']; - onPanelChange: () => void; + onPanelChange?: () => void; onClickDateTimeZone:(value:any)=>void; tabIndex?: number; $disabledStyle?: DisabledInputStyleType; diff --git a/client/packages/lowcoder/src/comps/comps/layout/mobileTabLayout.tsx b/client/packages/lowcoder/src/comps/comps/layout/mobileTabLayout.tsx index 8ae653ffa..bd4016c16 100644 --- a/client/packages/lowcoder/src/comps/comps/layout/mobileTabLayout.tsx +++ b/client/packages/lowcoder/src/comps/comps/layout/mobileTabLayout.tsx @@ -11,11 +11,11 @@ import { AppSelectComp } from "comps/comps/layout/appSelectComp"; import { NameAndExposingInfo } from "comps/utils/exposingTypes"; import { ConstructorToComp, ConstructorToDataType } from "lowcoder-core"; import { CanvasContainer } from "comps/comps/gridLayoutComp/canvasView"; -import { CanvasContainerID } from "constants/domLocators"; -import { PreviewContainerID } from "constants/domLocators"; +import { CanvasContainerID, PreviewContainerID } from "constants/domLocators"; import { EditorContainer, EmptyContent } from "pages/common/styledComponent"; import { Layers } from "constants/Layers"; import { ExternalEditorContext } from "util/context/ExternalEditorContext"; +import { EditorContext } from "comps/editorState"; import { default as Skeleton } from "antd/es/skeleton"; import { hiddenPropertyView } from "comps/utils/propertyUtils"; import { dropdownControl } from "@lowcoder-ee/comps/controls/dropdownControl"; @@ -47,6 +47,21 @@ const TabBarItem = React.lazy(() => ); const EventOptions = [clickEvent] as const; +/** Mobile nav editor: tab bar uses position:absolute bottom; this root is the containing block */ +const MobileNavCanvasRoot = styled(CanvasContainer)` + position: relative; +`; + +/** Strip shared EditorContainer defaults (16px padding + scrollbar-gutter: stable) for mobile nav */ +const MobileNavEditorContainer = styled(EditorContainer)` + padding: 0; + padding-right: 0; + scrollbar-gutter: auto; + overflow-x: auto; + overflow-y: auto; + background: transparent; +`; + const AppViewContainer = styled.div` position: absolute; width: 100%; @@ -221,17 +236,17 @@ const TabBarWrapper = styled.div<{ $readOnly: boolean, $canvasBg: string, $tabBarHeight: string, - $maxWidth: number, $verticalAlignment: string; }>` + box-sizing: border-box; max-width: inherit; background: ${(props) => (props.$canvasBg)}; margin: 0 auto; - position: fixed; + position: ${(props) => (props.$readOnly ? "fixed" : "absolute")}; bottom: 0; left: 0; right: 0; - width: ${(props) => props.$readOnly ? "100%" : `${props.$maxWidth - 30}px`}; + width: 100%; z-index: ${Layers.tabBar}; padding-bottom: env(safe-area-inset-bottom, 0); @@ -389,7 +404,6 @@ function convertTreeData(data: any) { function TabBarView(props: TabBarProps & { tabBarHeight: string; - maxWidth: number; verticalAlignment: string; showSeparator: boolean; navIconSize: string; @@ -404,7 +418,6 @@ function TabBarView(props: TabBarProps & { $readOnly={props.readOnly} $canvasBg={canvasBg} $tabBarHeight={props.tabBarHeight} - $maxWidth={props.maxWidth} $verticalAlignment={props.verticalAlignment} > { const bgColor = (useContext(ThemeContext)?.theme || defaultTheme).canvas; const onEvent = comp.children.onEvent.getView(); + // Pull app-level Theme / Canvas Settings (managed via the left-sidebar + // "Canvas" pane and shared with normal apps + modules). Mobile nav already + // owns its own maxWidth + grid behaviour, so we only consume the + // background + padding subset here. + const editorState = useContext(EditorContext); + const appSettings = editorState?.getAppSettings(); + const canvasBg = appSettings?.gridBg; + const canvasBgImage = appSettings?.gridBgImage; + const canvasBgImageRepeat = appSettings?.gridBgImageRepeat || "no-repeat"; + const canvasBgImageSize = appSettings?.gridBgImageSize || "cover"; + const canvasBgImagePosition = appSettings?.gridBgImagePosition || "center"; + const canvasBgImageOrigin = appSettings?.gridBgImageOrigin || "padding-box"; + const canvasPaddingX = appSettings?.gridPaddingX ?? 0; + const canvasPaddingY = appSettings?.gridPaddingY ?? 0; + + const canvasBackgroundStyle: React.CSSProperties = { + background: "#FFFFFF", + }; + if (canvasBg) { + canvasBackgroundStyle.background = canvasBg; + } + if (canvasBgImage) { + canvasBackgroundStyle.backgroundImage = `url('${canvasBgImage}')`; + canvasBackgroundStyle.backgroundRepeat = canvasBgImageRepeat; + canvasBackgroundStyle.backgroundSize = canvasBgImageSize; + canvasBackgroundStyle.backgroundPosition = canvasBgImagePosition; + canvasBackgroundStyle.backgroundOrigin = canvasBgImageOrigin; + } + const canvasContentPadding = `${canvasPaddingY}px ${canvasPaddingX}px`; + const getContainer = useCallback(() => document.querySelector(`#${PreviewContainerID}`) || document.querySelector(`#${CanvasContainerID}`) || @@ -702,7 +745,7 @@ MobileTabLayoutTmp = withViewFn(MobileTabLayoutTmp, (comp) => { currentTab.children.app.getView()) || ( ); } @@ -712,7 +755,7 @@ MobileTabLayoutTmp = withViewFn(MobileTabLayoutTmp, (comp) => { currentTab.children.action.getView()) || ( ) }, [tabIndex, tabViews, dataOptionType]); @@ -769,7 +812,6 @@ MobileTabLayoutTmp = withViewFn(MobileTabLayoutTmp, (comp) => { tabItemActiveStyle={navItemActiveStyle} tabBarHeight={tabBarHeight} navIconSize={navIconSize} - maxWidth={maxWidth} verticalAlignment={verticalAlignment} showSeparator={showSeparator} /> @@ -870,8 +912,12 @@ MobileTabLayoutTmp = withViewFn(MobileTabLayoutTmp, (comp) => { if (readOnly) { return ( - - {appView} + + {appView} {menuMode === MobileMode.Hamburger ? ( <> {hamburgerButton} @@ -885,8 +931,12 @@ MobileTabLayoutTmp = withViewFn(MobileTabLayoutTmp, (comp) => { } return ( - - {appView} + + {appView} {menuMode === MobileMode.Hamburger ? ( <> {hamburgerButton} @@ -895,7 +945,7 @@ MobileTabLayoutTmp = withViewFn(MobileTabLayoutTmp, (comp) => { ) : ( tabBarView )} - + ); }); diff --git a/client/packages/lowcoder/src/comps/comps/layout/navLayout.tsx b/client/packages/lowcoder/src/comps/comps/layout/navLayout.tsx index 4a7e2b355..66f23635c 100644 --- a/client/packages/lowcoder/src/comps/comps/layout/navLayout.tsx +++ b/client/packages/lowcoder/src/comps/comps/layout/navLayout.tsx @@ -6,6 +6,7 @@ import MainContent from "components/layout/MainContent"; import { LayoutMenuItemComp, LayoutMenuItemListComp } from "comps/comps/layout/layoutMenuItemComp"; import { menuPropertyView } from "comps/comps/navComp/components/MenuItemList"; import { registerLayoutMap } from "comps/comps/uiComp"; +import { EditorContext } from "comps/editorState"; import { MultiCompBuilder, withDefault, withViewFn } from "comps/generators"; import { withDispatchHook } from "comps/generators/withDispatchHook"; import { NameAndExposingInfo } from "comps/utils/exposingTypes"; @@ -14,7 +15,7 @@ import { TopHeaderHeight } from "constants/style"; import { Section, controlItem, sectionNames } from "lowcoder-design"; import { trans } from "i18n"; import { EditorContainer, EmptyContent } from "pages/common/styledComponent"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import styled from "styled-components"; import { isUserViewMode, useAppPathParam } from "util/hooks"; import { StringControl, jsonControl } from "comps/controls/codeControl"; @@ -381,6 +382,21 @@ NavTmpLayout = withViewFn(NavTmpLayout, (comp) => { const dataOptionType = comp.children.dataOptionType.getView(); const onEvent = comp.children.onEvent.getView(); + // Pull app-level Theme / Canvas Settings (managed via the left-sidebar + // "Canvas" pane and shared with normal apps + modules). For aggregation + // apps the grid sizing fields are intentionally hidden in the settings UI; + // we only consume the background + padding subset here. + const editorState = useContext(EditorContext); + const appSettings = editorState?.getAppSettings(); + const canvasBg = appSettings?.gridBg; + const canvasBgImage = appSettings?.gridBgImage; + const canvasBgImageRepeat = appSettings?.gridBgImageRepeat || "no-repeat"; + const canvasBgImageSize = appSettings?.gridBgImageSize || "cover"; + const canvasBgImagePosition = appSettings?.gridBgImagePosition || "center"; + const canvasBgImageOrigin = appSettings?.gridBgImageOrigin || "padding-box"; + const canvasPaddingX = appSettings?.gridPaddingX ?? 0; + const canvasPaddingY = appSettings?.gridPaddingY ?? 0; + // filter out hidden. unauthorised items filtered by server const filterItem = useCallback((item: LayoutMenuItemComp): boolean => { return !item.children.hidden.getView(); @@ -685,8 +701,25 @@ NavTmpLayout = withViewFn(NavTmpLayout, (comp) => { /> ); + // Build canvas background style (color + optional image), driven by the + // shared app-level Canvas Settings. + const canvasBackgroundStyle: React.CSSProperties = {}; + if (canvasBg) { + canvasBackgroundStyle.background = canvasBg; + } + if (canvasBgImage) { + canvasBackgroundStyle.backgroundImage = `url('${canvasBgImage}')`; + canvasBackgroundStyle.backgroundRepeat = canvasBgImageRepeat; + canvasBackgroundStyle.backgroundSize = canvasBgImageSize; + canvasBackgroundStyle.backgroundPosition = canvasBgImagePosition; + canvasBackgroundStyle.backgroundOrigin = canvasBgImageOrigin; + } + let content = ( - + {(navPosition === 'top') && (
{ navMenu } @@ -697,7 +730,15 @@ NavTmpLayout = withViewFn(NavTmpLayout, (comp) => { {navMenu} )} - {pageView} + + {pageView} + {(navPosition === 'bottom') && (