diff --git a/client/packages/lowcoder/package.json b/client/packages/lowcoder/package.json
index 7c2899bfcc..542327bee7 100644
--- a/client/packages/lowcoder/package.json
+++ b/client/packages/lowcoder/package.json
@@ -8,10 +8,8 @@
"dependencies": {
"@ai-sdk/openai": "^1.3.22",
"@ant-design/icons": "^5.3.0",
- "@assistant-ui/react": "^0.10.24",
- "@assistant-ui/react-ai-sdk": "^0.10.14",
- "@assistant-ui/react-markdown": "^0.10.5",
- "@assistant-ui/styles": "^0.1.13",
+ "@assistant-ui/react": "^0.14.5",
+ "@assistant-ui/react-markdown": "^0.14.0",
"@bany/curl-to-json": "^1.2.8",
"@codemirror/autocomplete": "^6.11.1",
"@codemirror/commands": "^6.3.2",
diff --git a/client/packages/lowcoder/src/base/codeEditor/codeEditor.tsx b/client/packages/lowcoder/src/base/codeEditor/codeEditor.tsx
index 02630eefef..44fedbc32a 100644
--- a/client/packages/lowcoder/src/base/codeEditor/codeEditor.tsx
+++ b/client/packages/lowcoder/src/base/codeEditor/codeEditor.tsx
@@ -15,6 +15,7 @@ import type { CodeEditorProps, StyleName } from "./codeEditorTypes";
import { useClickCompNameEffect } from "./clickCompName";
import { Layers } from "../../constants/Layers";
import { debounce } from "lodash";
+import { CodeEditorAIHelpButton } from "components/ai-helper";
type StyleConfig = {
minHeight: string;
@@ -214,6 +215,7 @@ function useCodeMirror(
) {
const { value, onChange } = props;
const viewRef = useRef();
+ const [viewVersion, setViewVersion] = useState(0);
// will not trigger view.setState when typing inputs, to avoid focus chaos
const isTypingRef = useRef(0);
@@ -250,6 +252,7 @@ function useCodeMirror(
view.setState(state);
} else {
viewRef.current = new EditorView({ state, parent: container.current });
+ setViewVersion((version) => version + 1);
}
}
}, [container, value, extensions]);
@@ -262,7 +265,7 @@ function useCodeMirror(
};
}, []);
- return { view: viewRef.current, isFocus };
+ return { view: viewRef.current, isFocus, viewVersion };
}
function clickCompNameCss(enableClickCompName?: boolean) {
@@ -338,6 +341,20 @@ const CodeEditorPanelContainer = styled.div<{
const CodeEditorWrapper = styled.div`
height: 100%;
+ position: relative;
+
+ .code-editor-ai-help-button {
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 120ms ease;
+ }
+
+ &:hover {
+ .code-editor-ai-help-button {
+ opacity: 1;
+ pointer-events: auto;
+ }
+ }
`;
function canShowCard(props: CodeEditorProps) {
@@ -358,6 +375,21 @@ function CodeEditorCommon(
view && onClick(e, view) : undefined}>
{!disabled && view && props.widgetPopup?.(view)}
{children}
+ {!disabled && props.enableAIHelp && view && (
+
+ )}
ReactNode;
cardTips?: ReactNode;
enableMetaCompletion?: boolean;
+ enableAIHelp?: boolean;
+ aiHelp?: CodeEditorAIHelp;
}
export interface CodeEditorProps extends CodeEditorControlParams {
diff --git a/client/packages/lowcoder/src/components/ThemeSettingsSelector.tsx b/client/packages/lowcoder/src/components/ThemeSettingsSelector.tsx
index f2004d43ab..67cbb9c20b 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/components/ai-helper/AIHelperModal.tsx b/client/packages/lowcoder/src/components/ai-helper/AIHelperModal.tsx
new file mode 100644
index 0000000000..64bbee42f9
--- /dev/null
+++ b/client/packages/lowcoder/src/components/ai-helper/AIHelperModal.tsx
@@ -0,0 +1,225 @@
+import { useContext, useEffect, useRef } from "react";
+import Button from "antd/es/button";
+import Empty from "antd/es/empty";
+import Select from "antd/es/select";
+import { AssistantModalPrimitive } from "@assistant-ui/react";
+import { SparklesIcon, XIcon } from "lucide-react";
+import { useSelector } from "react-redux";
+import styled from "styled-components";
+
+import { EditorContext } from "comps/editorState";
+import { getDataSourceStructures } from "redux/selectors/datasourceSelectors";
+import { getSelectedAIQueryName } from "util/localStorageUtil";
+
+import { AIHelperRuntime } from "./AIHelperRuntime";
+import { useAIHelper } from "./context/AIHelperController";
+
+const Anchor = styled.div`
+ position: fixed;
+ right: 16px;
+ bottom: 16px;
+ width: 1px;
+ height: 1px;
+`;
+
+const Content = styled(AssistantModalPrimitive.Content)`
+ width: 430px;
+ height: min(640px, calc(100vh - 128px));
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ border: 1px solid #e1e3eb;
+ border-radius: 8px;
+ background: #ffffff;
+ box-shadow: 0 12px 40px rgba(15, 23, 42, 0.18);
+ z-index: 2147483000;
+
+ .aui-thread-root {
+ min-height: 0;
+ flex: 1 1 auto;
+ background: #fafbfc;
+ }
+
+ .aui-thread-viewport {
+ padding: 12px 12px 0;
+ }
+
+ .aui-thread-welcome-root {
+ padding: 16px 8px;
+ }
+
+ .aui-thread-welcome-suggestions {
+ display: none;
+ }
+`;
+
+const Header = styled.div`
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 10px 12px;
+ border-bottom: 1px solid #e1e3eb;
+`;
+
+const Title = styled.div`
+ min-width: 0;
+`;
+
+const TitleLine = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: #111827;
+ font-size: 13px;
+ font-weight: 600;
+`;
+
+const TargetLabel = styled.div`
+ max-width: 300px;
+ margin-top: 2px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: #6b7280;
+ font-size: 11px;
+`;
+
+const IconButton = styled.button`
+ width: 28px;
+ height: 28px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ color: #6b7280;
+ cursor: pointer;
+
+ &:hover {
+ background: #f3f4f6;
+ color: #111827;
+ }
+`;
+
+const QueryBar = styled.div`
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ border-bottom: 1px solid #f1f5f9;
+ background: #fcfcfd;
+ color: #6b7280;
+ font-size: 12px;
+`;
+
+const EmptyState = styled.div`
+ flex: 1 1 auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+`;
+
+export function AIHelperModal() {
+ const helper = useAIHelper();
+ const editorState = useContext(EditorContext);
+ const datasourceStructures = useSelector(getDataSourceStructures);
+ const datasourceStructuresRef = useRef(datasourceStructures);
+
+ useEffect(() => {
+ datasourceStructuresRef.current = datasourceStructures;
+ }, [datasourceStructures]);
+
+ useEffect(() => {
+ if (!helper?.open) {
+ return;
+ }
+ const selectedQueryName = getSelectedAIQueryName();
+ if (selectedQueryName !== helper.helperQueryName) {
+ helper.setHelperQueryName(selectedQueryName);
+ }
+ }, [helper?.open]);
+
+ if (!helper) return null;
+
+ const queryOptions = (() => {
+ if (!editorState) return [];
+ try {
+ return editorState.getQueriesComp().getView().map((query: any) => {
+ const name = query.children.name.getView();
+ const type = query.children.compType.getView();
+ return {
+ label: type ? `${name} (${type})` : name,
+ value: name,
+ };
+ });
+ } catch {
+ return [];
+ }
+ })();
+
+ const target = helper.target;
+
+ return (
+
+
+
+
+
+
+
+
+
+ AI Helper
+
+ {target?.label && (
+ {target.label}
+ )}
+
+
+
+
+
+
+
+ AI query:
+
+
+ {!helper.helperQueryName || !target ? (
+
+
+
+
+
+ ) : (
+ datasourceStructuresRef.current}
+ target={target}
+ />
+ )}
+
+
+ );
+}
diff --git a/client/packages/lowcoder/src/components/ai-helper/AIHelperRuntime.tsx b/client/packages/lowcoder/src/components/ai-helper/AIHelperRuntime.tsx
new file mode 100644
index 0000000000..86845ebcde
--- /dev/null
+++ b/client/packages/lowcoder/src/components/ai-helper/AIHelperRuntime.tsx
@@ -0,0 +1,140 @@
+import {
+ AssistantRuntimeProvider,
+ type AppendMessage,
+ type ThreadMessageLike,
+ useAssistantToolUI,
+ useExternalStoreRuntime,
+} from "@assistant-ui/react";
+import { useCallback, useMemo, useState } from "react";
+
+import { Thread } from "components/assistant-ui/thread";
+import type { ChatMessage } from "comps/comps/chatComp/types/chatTypes";
+import {
+ createAssistantErrorMessage,
+ createUserMessage,
+ getTextFromAppendMessage,
+ toChatMessage,
+} from "comps/comps/chatComp/utils/assistantMessages";
+
+import { ApplyActions } from "./components/ApplyActions";
+import { AIHelperQueryHandler } from "./handlers/AIHelperQueryHandler";
+import { useAIHelper } from "./context/AIHelperController";
+import {
+ AI_HELPER_APPLY_TOOL,
+ type AIHelperApplyAction,
+ type AIHelperTarget,
+} from "./types";
+
+interface ApplyToolArgs {
+ value: string;
+ mode: AIHelperApplyAction["mode"];
+ label?: string;
+ language?: string;
+}
+
+function AIHelperApplyToolUI() {
+ const helper = useAIHelper();
+
+ const render = useCallback(
+ ({ args }: { args: ApplyToolArgs }) => {
+ const action: AIHelperApplyAction = {
+ id: AI_HELPER_APPLY_TOOL,
+ label: args.label ?? "Apply",
+ value: args.value,
+ mode: args.mode,
+ language: args.language,
+ };
+
+ return (
+ helper?.applyResult(action)}
+ />
+ );
+ },
+ [helper]
+ );
+
+ const tool = useMemo(
+ () => ({
+ toolName: AI_HELPER_APPLY_TOOL,
+ render,
+ }),
+ [render]
+ );
+
+ useAssistantToolUI(tool);
+
+ return null;
+}
+
+export function AIHelperRuntime({
+ helperQueryName,
+ dispatch,
+ getDatasourceStructures,
+ target,
+}: {
+ helperQueryName: string;
+ dispatch: any;
+ getDatasourceStructures: () => Record | undefined;
+ target: AIHelperTarget;
+}) {
+ const [messages, setMessages] = useState([]);
+ const [isRunning, setIsRunning] = useState(false);
+
+ const handler = useMemo(
+ () =>
+ new AIHelperQueryHandler({
+ helperQueryName,
+ dispatch,
+ getDatasourceStructures,
+ target,
+ }),
+ [
+ helperQueryName,
+ dispatch,
+ getDatasourceStructures,
+ target,
+ ]
+ );
+
+ const onNew = useCallback(
+ async (message: AppendMessage) => {
+ const text = getTextFromAppendMessage(message);
+ if (!text) throw new Error("Cannot send an empty message");
+
+ const userMessage = createUserMessage(text);
+ const conversationHistory = [...messages, userMessage];
+ setMessages(conversationHistory);
+ setIsRunning(true);
+
+ try {
+ const assistantMessage = await handler.sendMessage(conversationHistory);
+ setMessages((prev) => [...prev, assistantMessage]);
+ } catch (error: any) {
+ setMessages((prev) => [
+ ...prev,
+ createAssistantErrorMessage(error?.message || "AI Helper failed"),
+ ]);
+ } finally {
+ setIsRunning(false);
+ }
+ },
+ [handler, messages]
+ );
+
+ const runtime = useExternalStoreRuntime({
+ messages,
+ setMessages: (next) => setMessages(next.map(toChatMessage)),
+ convertMessage: (message: ChatMessage): ThreadMessageLike => message,
+ isRunning,
+ onNew,
+ });
+
+ return (
+
+
+
+
+ );
+}
diff --git a/client/packages/lowcoder/src/components/ai-helper/CodeEditorAIHelpButton.tsx b/client/packages/lowcoder/src/components/ai-helper/CodeEditorAIHelpButton.tsx
new file mode 100644
index 0000000000..b36327090e
--- /dev/null
+++ b/client/packages/lowcoder/src/components/ai-helper/CodeEditorAIHelpButton.tsx
@@ -0,0 +1,163 @@
+import { useMemo, type MouseEvent } from "react";
+import { SparklesIcon } from "lucide-react";
+import styled from "styled-components";
+import type { EditorView } from "@codemirror/view";
+import Tooltip from "antd/es/tooltip";
+
+import { useAIHelper } from "./context/AIHelperController";
+import type {
+ AIHelperApplyAction,
+ AIHelperTarget,
+ AIHelperTargetKind,
+} from "./types";
+
+export interface CodeEditorAIHelpButtonProps {
+ view?: EditorView;
+ label?: string;
+ language?: AIHelperTarget["language"];
+ targetKind?: AIHelperTargetKind;
+ datasourceId?: string;
+ queryType?: string;
+ queryName?: string;
+ componentName?: string;
+ fieldName?: string;
+ fieldDescription?: string;
+ targetId?: string;
+}
+
+const Button = styled.button.attrs({ className: "code-editor-ai-help-button" })`
+ position: absolute;
+ top: 4px;
+ right: 24px;
+ z-index: 5;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ height: 22px;
+ padding: 0 8px;
+ border: 1px solid #dbe4ff;
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.94);
+ color: #4965f2;
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 500;
+
+ &:hover {
+ border-color: #b7c4ff;
+ background: #f2f5ff;
+ }
+`;
+
+function applyToView(view: EditorView, action: AIHelperApplyAction) {
+ const doc = view.state.doc;
+ const docLen = doc.length;
+
+ if (action.mode === "append") {
+ view.dispatch({
+ changes: { from: docLen, to: docLen, insert: action.value },
+ selection: { anchor: docLen + action.value.length },
+ });
+ view.focus();
+ return;
+ }
+
+ if (action.mode === "insertAtCursor") {
+ const cursor = view.state.selection.main.head;
+ view.dispatch({
+ changes: { from: cursor, to: cursor, insert: action.value },
+ selection: { anchor: cursor + action.value.length },
+ });
+ view.focus();
+ return;
+ }
+
+ const selection = view.state.selection.main;
+ const from = selection.empty ? 0 : selection.from;
+ const to = selection.empty ? docLen : selection.to;
+ view.dispatch({
+ changes: { from, to, insert: action.value },
+ selection: { anchor: from + action.value.length },
+ });
+ view.focus();
+}
+
+function defaultTargetKind(
+ language: AIHelperTarget["language"] | undefined
+): AIHelperTargetKind {
+ if (language === "sql") return "sql";
+ if (language === "javascript") return "javascript";
+ if (language === "json") return "json";
+ return "component-field";
+}
+
+export function CodeEditorAIHelpButton({
+ view,
+ label,
+ language,
+ targetKind,
+ datasourceId,
+ queryType,
+ queryName,
+ componentName,
+ fieldName,
+ fieldDescription,
+ targetId,
+}: CodeEditorAIHelpButtonProps) {
+ const helper = useAIHelper();
+ const id = useMemo(
+ () =>
+ targetId ??
+ [
+ queryName,
+ componentName,
+ fieldName,
+ label,
+ language,
+ ].filter(Boolean).join("|"),
+ [targetId, queryName, componentName, fieldName, label, language]
+ );
+
+ if (!helper || !view) return null;
+
+ const onClick = (event: MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const doc = view.state.doc.toString();
+ const selection = view.state.selection.main;
+ const target: AIHelperTarget = {
+ id: id || "field",
+ kind: targetKind ?? defaultTargetKind(language),
+ label,
+ language,
+ currentValue: doc,
+ selection: doc.slice(selection.from, selection.to),
+ cursor: selection.head,
+ datasourceId,
+ queryType,
+ queryName,
+ componentName,
+ fieldName,
+ fieldDescription,
+ };
+
+ helper.openHelper({
+ target,
+ onApply: (action) => applyToView(view, action),
+ });
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/client/packages/lowcoder/src/components/ai-helper/components/ApplyActions.tsx b/client/packages/lowcoder/src/components/ai-helper/components/ApplyActions.tsx
new file mode 100644
index 0000000000..20f111040a
--- /dev/null
+++ b/client/packages/lowcoder/src/components/ai-helper/components/ApplyActions.tsx
@@ -0,0 +1,66 @@
+import Button from "antd/es/button";
+import { CodeIcon } from "lucide-react";
+import styled from "styled-components";
+
+import type { AIHelperApplyAction } from "../types";
+
+const Wrapper = styled.div`
+ flex: 0 0 auto;
+ border-top: 1px solid #e1e3eb;
+ background: #ffffff;
+ padding: 10px 12px;
+`;
+
+const Title = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 8px;
+ color: #4b5563;
+ font-size: 12px;
+ font-weight: 600;
+`;
+
+const Row = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+`;
+
+const Label = styled.div`
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: #111827;
+ font-size: 13px;
+`;
+
+export function ApplyActions({
+ actions,
+ onApply,
+}: {
+ actions: AIHelperApplyAction[];
+ onApply: (action: AIHelperApplyAction) => void;
+}) {
+ if (!actions.length) return null;
+
+ return (
+
+
+
+ Generated value
+
+ {actions.map((action) => (
+
+
+
+
+ ))}
+
+ );
+}
+
diff --git a/client/packages/lowcoder/src/components/ai-helper/context/AIHelperController.tsx b/client/packages/lowcoder/src/components/ai-helper/context/AIHelperController.tsx
new file mode 100644
index 0000000000..c83231915e
--- /dev/null
+++ b/client/packages/lowcoder/src/components/ai-helper/context/AIHelperController.tsx
@@ -0,0 +1,102 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+
+import type { AIHelperApplyAction, AIHelperTarget } from "../types";
+import {
+ getSelectedAIQueryName,
+ saveSelectedAIQueryName,
+} from "util/localStorageUtil";
+
+export type AIHelperApplyCallback = (
+ action: AIHelperApplyAction,
+ target: AIHelperTarget
+) => void;
+
+export interface AIHelperOpenOptions {
+ target: AIHelperTarget;
+ onApply?: AIHelperApplyCallback;
+}
+
+interface AIHelperState {
+ open: boolean;
+ target?: AIHelperTarget;
+ helperQueryName: string;
+}
+
+interface AIHelperContextValue extends AIHelperState {
+ openHelper: (opts: AIHelperOpenOptions) => void;
+ closeHelper: () => void;
+ setOpen: (open: boolean) => void;
+ setHelperQueryName: (name: string) => void;
+ applyResult: (action: AIHelperApplyAction) => void;
+}
+
+const AIHelperContext = createContext(null);
+
+export function AIHelperProvider({ children }: { children: ReactNode }) {
+ const [state, setState] = useState(() => ({
+ open: false,
+ helperQueryName: getSelectedAIQueryName(),
+ }));
+ const applyRef = useRef(null);
+
+ const openHelper = useCallback((opts: AIHelperOpenOptions) => {
+ applyRef.current = opts.onApply ?? null;
+ setState((s) => ({
+ ...s,
+ open: true,
+ target: opts.target,
+ }));
+ }, []);
+
+ const closeHelper = useCallback(() => {
+ setState((s) => ({ ...s, open: false }));
+ }, []);
+
+ const setOpen = useCallback((open: boolean) => {
+ setState((s) => ({ ...s, open }));
+ if (!open) applyRef.current = null;
+ }, []);
+
+ const setHelperQueryName = useCallback((name: string) => {
+ saveSelectedAIQueryName(name);
+ setState((s) => ({ ...s, helperQueryName: name }));
+ }, []);
+
+ const applyResult = useCallback(
+ (action: AIHelperApplyAction) => {
+ if (!state.target) return;
+ applyRef.current?.(action, state.target);
+ },
+ [state.target]
+ );
+
+ const value = useMemo(
+ () => ({
+ ...state,
+ openHelper,
+ closeHelper,
+ setOpen,
+ setHelperQueryName,
+ applyResult,
+ }),
+ [state, openHelper, closeHelper, setOpen, setHelperQueryName, applyResult]
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAIHelper() {
+ return useContext(AIHelperContext);
+}
diff --git a/client/packages/lowcoder/src/components/ai-helper/context/buildAIHelperContext.ts b/client/packages/lowcoder/src/components/ai-helper/context/buildAIHelperContext.ts
new file mode 100644
index 0000000000..ea44375ef2
--- /dev/null
+++ b/client/packages/lowcoder/src/components/ai-helper/context/buildAIHelperContext.ts
@@ -0,0 +1,115 @@
+import type { DatasourceStructure } from "api/datasourceApi";
+
+import { AI_HELPER_APPLY_TOOL, type AIHelperTarget } from "../types";
+
+export type AIHelperTargetContext = AIHelperTarget;
+
+export function flattenDatasourceSchema(
+ structure: DatasourceStructure[] | undefined
+): Record {
+ const out: Record = {};
+ if (!structure) return out;
+
+ for (const table of structure) {
+ if (!table?.name) continue;
+ out[table.name] = "table";
+ for (const col of table.columns ?? []) {
+ if (!col?.name) continue;
+ out[`${table.name}.${col.name}`] = col.type ?? "unknown";
+ }
+ }
+
+ return out;
+}
+
+export function buildAIHelperTargetContext(args: {
+ datasourceStructures?: Record;
+ target: AIHelperTarget;
+}): AIHelperTargetContext {
+ const { datasourceStructures, target } = args;
+
+ const datasourceSchema =
+ target.datasourceId && datasourceStructures
+ ? flattenDatasourceSchema(datasourceStructures[target.datasourceId])
+ : undefined;
+
+ return {
+ ...target,
+ datasourceType: target.datasourceType ?? target.queryType,
+ datasourceSchema:
+ datasourceSchema && Object.keys(datasourceSchema).length > 0
+ ? datasourceSchema
+ : undefined,
+ };
+}
+
+function describeTarget(target: AIHelperTarget): string {
+ switch (target.kind) {
+ case "sql":
+ return `The target is a ${target.queryType || "SQL"} query editor. Help write, explain, or improve SQL for this datasource.`;
+ case "javascript":
+ return "The target is a JavaScript query/editor. Use modern JavaScript that can run in Lowcoder's query environment.";
+ case "echarts-option":
+ return "The target is an Apache ECharts option JSON field. Generate valid ECharts option JSON, not Automator actions.";
+ case "json":
+ return "The target is a JSON field. Generate valid JSON when the user asks for a value.";
+ default:
+ return target.fieldDescription || "The target is a complex Lowcoder component input.";
+ }
+}
+
+export function buildAIHelperSystemMessage(target: AIHelperTargetContext) {
+ return `You are Lowcoder AI Helper, an embedded field assistant.
+
+You help the builder understand or generate code/data for one focused Lowcoder input.
+You are NOT the Automator and must not create, move, or modify Lowcoder canvas components.
+
+Target:
+${describeTarget(target)}
+
+Target context:
+${JSON.stringify(target, null, 2)}
+
+Response rules:
+- Explain briefly when explanation is useful.
+- Prefer concrete code/data that can be pasted into the current target.
+- If you produce a replacement value for the target, call the ${AI_HELPER_APPLY_TOOL} tool with the exact value.
+- Use mode "replace" unless the user explicitly asks to insert or append.
+- Do not include markdown fences inside tool values.
+- Respect the current language/field kind and keep generated values syntactically valid.`;
+}
+
+export function buildAIHelperTools() {
+ return [
+ {
+ type: "function",
+ function: {
+ name: AI_HELPER_APPLY_TOOL,
+ description: "Offer a generated value that Lowcoder can apply to the focused editor field.",
+ parameters: {
+ type: "object",
+ properties: {
+ label: {
+ type: "string",
+ description: "Short button label, e.g. Replace SQL or Apply option JSON.",
+ },
+ value: {
+ type: "string",
+ description: "The exact code/data to place in the focused field.",
+ },
+ mode: {
+ type: "string",
+ enum: ["replace", "insertAtCursor", "append"],
+ description: "How to apply the value. Use replace unless the user asks otherwise.",
+ },
+ language: {
+ type: "string",
+ description: "Optional language hint such as sql, javascript, or json.",
+ },
+ },
+ required: ["value", "mode"],
+ },
+ },
+ },
+ ];
+}
diff --git a/client/packages/lowcoder/src/components/ai-helper/handlers/AIHelperQueryHandler.ts b/client/packages/lowcoder/src/components/ai-helper/handlers/AIHelperQueryHandler.ts
new file mode 100644
index 0000000000..cf9a048d5d
--- /dev/null
+++ b/client/packages/lowcoder/src/components/ai-helper/handlers/AIHelperQueryHandler.ts
@@ -0,0 +1,90 @@
+import { executeQueryAction, routeByNameAction } from "lowcoder-core";
+import { getPromiseAfterDispatch } from "util/promiseUtils";
+
+import type { ChatMessage } from "comps/comps/chatComp/types/chatTypes";
+import {
+ getTextFromThreadContent,
+ toAssistantMessage,
+} from "comps/comps/chatComp/utils/assistantMessages";
+
+import {
+ buildAIHelperTargetContext,
+ buildAIHelperSystemMessage,
+ buildAIHelperTools,
+ type AIHelperTargetContext,
+} from "../context/buildAIHelperContext";
+import type { AIHelperTarget } from "../types";
+
+interface AIHelperQueryHandlerConfig {
+ helperQueryName: string;
+ dispatch: any;
+ getDatasourceStructures: () => Record | undefined;
+ target: AIHelperTarget;
+}
+
+function buildHelperPayload(args: {
+ conversationHistory: ChatMessage[];
+ target: AIHelperTargetContext;
+}) {
+ const { conversationHistory, target } = args;
+ const messagesWithoutSystem = conversationHistory.map((msg) => ({
+ role: msg.role,
+ content: getTextFromThreadContent(msg.content),
+ }));
+ const system = buildAIHelperSystemMessage(target);
+ const tools = buildAIHelperTools();
+ const messages = [
+ { role: "system" as const, content: system },
+ ...messagesWithoutSystem,
+ ];
+
+ return {
+ mode: "helper" as const,
+ messages,
+ tools,
+ target,
+ };
+}
+
+export class AIHelperQueryHandler {
+ constructor(private readonly config: AIHelperQueryHandlerConfig) {}
+
+ async sendMessage(conversationHistory: ChatMessage[]): Promise {
+ const {
+ helperQueryName,
+ dispatch,
+ getDatasourceStructures,
+ target,
+ } = this.config;
+
+ if (!helperQueryName) {
+ throw new Error("Select an AI query before sending a message");
+ }
+ if (!dispatch) {
+ throw new Error("AI Helper dispatch is unavailable");
+ }
+
+ const targetContext = buildAIHelperTargetContext({
+ datasourceStructures: getDatasourceStructures(),
+ target,
+ });
+ const ai = buildHelperPayload({
+ conversationHistory,
+ target: targetContext,
+ });
+
+ const result: any = await getPromiseAfterDispatch(
+ dispatch,
+ routeByNameAction(
+ helperQueryName,
+ executeQueryAction({
+ args: {
+ ai: { value: ai },
+ },
+ })
+ )
+ );
+
+ return toAssistantMessage(result);
+ }
+}
diff --git a/client/packages/lowcoder/src/components/ai-helper/index.ts b/client/packages/lowcoder/src/components/ai-helper/index.ts
new file mode 100644
index 0000000000..6e42ab2535
--- /dev/null
+++ b/client/packages/lowcoder/src/components/ai-helper/index.ts
@@ -0,0 +1,11 @@
+export { AIHelperModal } from "./AIHelperModal";
+export { AIHelperProvider, useAIHelper } from "./context/AIHelperController";
+export { CodeEditorAIHelpButton } from "./CodeEditorAIHelpButton";
+export type { CodeEditorAIHelpButtonProps } from "./CodeEditorAIHelpButton";
+export type {
+ AIHelperApplyAction,
+ AIHelperApplyMode,
+ AIHelperTarget,
+ AIHelperTargetKind,
+} from "./types";
+
diff --git a/client/packages/lowcoder/src/components/ai-helper/types.ts b/client/packages/lowcoder/src/components/ai-helper/types.ts
new file mode 100644
index 0000000000..c5f0e7208c
--- /dev/null
+++ b/client/packages/lowcoder/src/components/ai-helper/types.ts
@@ -0,0 +1,36 @@
+export const AI_HELPER_APPLY_TOOL = "apply_ai_helper_result";
+
+export type AIHelperTargetKind =
+ | "sql"
+ | "javascript"
+ | "echarts-option"
+ | "json"
+ | "component-field";
+
+export type AIHelperApplyMode = "replace" | "insertAtCursor" | "append";
+
+export interface AIHelperTarget {
+ id: string;
+ kind: AIHelperTargetKind;
+ label?: string;
+ language?: "sql" | "javascript" | "css" | "html" | "json";
+ currentValue?: string;
+ selection?: string;
+ cursor?: number;
+ datasourceId?: string;
+ datasourceType?: string;
+ datasourceSchema?: Record;
+ queryType?: string;
+ queryName?: string;
+ componentName?: string;
+ fieldName?: string;
+ fieldDescription?: string;
+}
+
+export interface AIHelperApplyAction {
+ id: string;
+ label: string;
+ value: string;
+ mode: AIHelperApplyMode;
+ language?: string;
+}
diff --git a/client/packages/lowcoder/src/components/assistant-ui/assistant-message-loader.tsx b/client/packages/lowcoder/src/components/assistant-ui/assistant-message-loader.tsx
new file mode 100644
index 0000000000..3115237e7a
--- /dev/null
+++ b/client/packages/lowcoder/src/components/assistant-ui/assistant-message-loader.tsx
@@ -0,0 +1,26 @@
+import { LoadingOutlined } from "@ant-design/icons";
+import { Spin } from "antd";
+import type { FC } from "react";
+import styled from "styled-components";
+
+const LoaderRoot = styled.div`
+ align-items: center;
+ color: #6b7280;
+ display: flex;
+ font-size: 14px;
+ gap: 12px;
+ line-height: 20px;
+ min-height: 28px;
+`;
+
+const LoaderIcon = styled(LoadingOutlined)`
+ color: #1677ff;
+ font-size: 18px;
+`;
+
+export const AssistantMessageLoader: FC = () => (
+
+ } size="small" />
+ Working on it...
+
+);
diff --git a/client/packages/lowcoder/src/components/assistant-ui/markdown-text.styles.ts b/client/packages/lowcoder/src/components/assistant-ui/markdown-text.styles.ts
new file mode 100644
index 0000000000..498c8561bf
--- /dev/null
+++ b/client/packages/lowcoder/src/components/assistant-ui/markdown-text.styles.ts
@@ -0,0 +1,116 @@
+import { MarkdownTextPrimitive } from "@assistant-ui/react-markdown";
+import styled from "styled-components";
+
+export const StyledMarkdownTextPrimitive = styled(MarkdownTextPrimitive)`
+ color: inherit;
+ font-size: inherit;
+ line-height: inherit;
+
+ .aui-md-h1,
+ .aui-md-h2,
+ .aui-md-h3,
+ .aui-md-h4,
+ .aui-md-h5,
+ .aui-md-h6 {
+ color: #111827;
+ font-weight: 600;
+ line-height: 1.35;
+ margin: 14px 0 8px;
+ }
+
+ .aui-md-h1 {
+ font-size: 18px;
+ }
+
+ .aui-md-h2,
+ .aui-md-h3 {
+ font-size: 16px;
+ }
+
+ .aui-md-h4,
+ .aui-md-h5,
+ .aui-md-h6,
+ .aui-md-p {
+ font-size: 14px;
+ }
+
+ .aui-md-p {
+ margin: 0 0 10px;
+ }
+
+ .aui-md-a {
+ color: #1677ff;
+ }
+
+ .aui-md-blockquote {
+ border-left: 3px solid #d9d9d9;
+ color: #4b5563;
+ margin: 12px 0;
+ padding: 2px 0 2px 12px;
+ }
+
+ .aui-md-ul,
+ .aui-md-ol {
+ margin: 8px 0 10px;
+ padding-left: 22px;
+ }
+
+ .aui-md-hr {
+ border: 0;
+ border-top: 1px solid #e5e7eb;
+ margin: 16px 0;
+ }
+
+ .aui-md-table {
+ border-collapse: collapse;
+ margin: 12px 0;
+ width: 100%;
+ }
+
+ .aui-md-th,
+ .aui-md-td {
+ border: 1px solid #e5e7eb;
+ padding: 6px 8px;
+ text-align: left;
+ }
+
+ .aui-md-th {
+ background: #f3f4f6;
+ font-weight: 600;
+ }
+
+ .aui-md-pre {
+ background: #111827;
+ border-radius: 0 0 8px 8px;
+ color: #f9fafb;
+ margin: 0 0 12px;
+ overflow-x: auto;
+ padding: 12px;
+ }
+
+ .aui-md-inline-code {
+ background: #f3f4f6;
+ border-radius: 4px;
+ color: #111827;
+ padding: 1px 4px;
+ }
+
+ .aui-code-header-root {
+ align-items: center;
+ background: #f3f4f6;
+ border: 1px solid #e5e7eb;
+ border-bottom: 0;
+ border-radius: 8px 8px 0 0;
+ display: flex;
+ font-size: 12px;
+ justify-content: space-between;
+ margin-top: 10px;
+ padding: 6px 10px;
+ }
+
+ .aui-code-header-language {
+ color: #4b5563;
+ font-weight: 500;
+ text-transform: lowercase;
+ }
+`;
diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/markdown-text.tsx b/client/packages/lowcoder/src/components/assistant-ui/markdown-text.tsx
similarity index 96%
rename from client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/markdown-text.tsx
rename to client/packages/lowcoder/src/components/assistant-ui/markdown-text.tsx
index bbf2e5648a..68c156e06c 100644
--- a/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/markdown-text.tsx
+++ b/client/packages/lowcoder/src/components/assistant-ui/markdown-text.tsx
@@ -2,20 +2,20 @@ import "@assistant-ui/react-markdown/styles/dot.css";
import {
CodeHeaderProps,
- MarkdownTextPrimitive,
unstable_memoizeMarkdownComponents as memoizeMarkdownComponents,
useIsMarkdownCodeBlock,
} from "@assistant-ui/react-markdown";
import remarkGfm from "remark-gfm";
import { FC, memo, useState } from "react";
import { CheckIcon, CopyIcon } from "lucide-react";
+import { StyledMarkdownTextPrimitive } from "./markdown-text.styles";
import { TooltipIconButton } from "./tooltip-icon-button";
-import { cn } from "../../utils/cn";
+import { cn } from "./utils/cn";
const MarkdownTextImpl = () => {
return (
- `
+ border: ${({ $variant }) => ($variant === "outline" ? "1px solid #e5e7eb" : "0")};
+ border-radius: ${({ $variant }) => ($variant === "ghost" ? "0" : "8px")};
+ background: ${({ $variant }) => ($variant === "muted" ? "#f3f4f6" : "transparent")};
+ margin-bottom: 16px;
+ padding: ${({ $variant }) => ($variant === "ghost" ? "0" : "8px 12px")};
+ width: 100%;
+`;
+
+const StyledReasoningTrigger = styled(CollapsibleTrigger)`
+ align-items: center;
+ background: transparent;
+ border: 0;
+ color: #6b7280;
+ cursor: pointer;
+ display: flex;
+ font-size: 14px;
+ gap: 8px;
+ line-height: 20px;
+ max-width: 75%;
+ padding: 4px 0;
+ text-align: left;
+ transition: color 0.2s ease;
+
+ &:hover {
+ color: #1f2937;
+ }
+
+ .aui-reasoning-trigger-icon,
+ .aui-reasoning-trigger-chevron {
+ flex: 0 0 auto;
+ height: 16px;
+ width: 16px;
+ }
+
+ .aui-reasoning-trigger-label-wrapper {
+ display: inline-block;
+ line-height: 1;
+ position: relative;
+ }
+
+ .aui-reasoning-trigger-shimmer {
+ background: linear-gradient(90deg, transparent, rgba(22, 119, 255, 0.35), transparent);
+ background-size: 200% 100%;
+ inset: 0;
+ pointer-events: none;
+ position: absolute;
+ -webkit-background-clip: text;
+ color: transparent;
+ animation: ${shimmer} 1.6s linear infinite;
+ }
+
+ .aui-reasoning-trigger-chevron {
+ margin-top: 2px;
+ transform: rotate(0deg);
+ transition: transform var(--animation-duration, 200ms) ease-out;
+ }
+
+ &[data-state="closed"] .aui-reasoning-trigger-chevron {
+ transform: rotate(-90deg);
+ }
+`;
+
+const StyledReasoningContent = styled(CollapsibleContent)`
+ color: #6b7280;
+ font-size: 14px;
+ line-height: 22px;
+ outline: none;
+ overflow: hidden;
+ position: relative;
+`;
+
+const StyledReasoningText = styled.div`
+ max-height: 256px;
+ overflow-y: auto;
+ padding: 8px 0 8px 24px;
+ position: relative;
+ z-index: 0;
+
+ > * + * {
+ margin-top: 16px;
+ }
+`;
+
+const StyledReasoningFade = styled.div`
+ background: linear-gradient(to top, #f9fafb, rgba(249, 250, 251, 0));
+ bottom: 0;
+ height: 32px;
+ left: 0;
+ pointer-events: none;
+ position: absolute;
+ right: 0;
+ z-index: 1;
+`;
+
+export type ReasoningRootProps = Omit<
+ React.ComponentPropsWithoutRef,
+ "open" | "onOpenChange"
+> &
+ {
+ variant?: ReasoningVariant;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ defaultOpen?: boolean;
+ };
+
+function ReasoningRoot({
+ className,
+ variant,
+ open: controlledOpen,
+ onOpenChange: controlledOnOpenChange,
+ defaultOpen = false,
+ children,
+ ...props
+}: ReasoningRootProps) {
+ const collapsibleRef = useRef(null);
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
+ const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION);
+
+ const isControlled = controlledOpen !== undefined;
+ const isOpen = isControlled ? controlledOpen : uncontrolledOpen;
+
+ const handleOpenChange = useCallback(
+ (open: boolean) => {
+ if (!open) {
+ lockScroll();
+ }
+ if (!isControlled) {
+ setUncontrolledOpen(open);
+ }
+ controlledOnOpenChange?.(open);
+ },
+ [lockScroll, isControlled, controlledOnOpenChange],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+function ReasoningFade({ className, ...props }: React.ComponentPropsWithoutRef<"div">) {
+ return (
+
+ );
+}
+
+function ReasoningTrigger({
+ active,
+ duration,
+ className,
+ ...props
+}: React.ComponentPropsWithoutRef & {
+ active?: boolean;
+ duration?: number;
+}) {
+ const durationText = duration ? ` (${duration}s)` : "";
+
+ return (
+
+
+
+ Reasoning{durationText}
+ {active ? (
+
+ Reasoning{durationText}
+
+ ) : null}
+
+
+
+ );
+}
+
+function ReasoningContent({
+ className,
+ children,
+ ...props
+}: React.ComponentPropsWithoutRef) {
+ return (
+
+ {children}
+
+
+ );
+}
+
+function ReasoningText({ className, ...props }: React.ComponentPropsWithoutRef<"div">) {
+ return (
+
+ );
+}
+
+const ReasoningImpl: ReasoningMessagePartComponent = () => ;
+
+const ReasoningGroupImpl: ReasoningGroupComponent = ({
+ children,
+ startIndex,
+ endIndex,
+}) => {
+ const isReasoningStreaming = useAuiState((s) => {
+ if (s.message.status?.type !== "running") return false;
+ const lastIndex = s.message.parts.length - 1;
+ if (lastIndex < 0) return false;
+ const lastType = s.message.parts[lastIndex]?.type;
+ if (lastType !== "reasoning") return false;
+ return lastIndex >= startIndex && lastIndex <= endIndex;
+ });
+
+ return (
+
+
+
+ {children}
+
+
+ );
+};
+
+const Reasoning = memo(
+ ReasoningImpl,
+) as unknown as ReasoningMessagePartComponent & {
+ Root: typeof ReasoningRoot;
+ Trigger: typeof ReasoningTrigger;
+ Content: typeof ReasoningContent;
+ Text: typeof ReasoningText;
+ Fade: typeof ReasoningFade;
+};
+
+Reasoning.displayName = "Reasoning";
+Reasoning.Root = ReasoningRoot;
+Reasoning.Trigger = ReasoningTrigger;
+Reasoning.Content = ReasoningContent;
+Reasoning.Text = ReasoningText;
+Reasoning.Fade = ReasoningFade;
+
+/**
+ * @deprecated This wrapper targets the legacy `components.ReasoningGroup`
+ * prop on ``. Use ``
+ * with a `groupBy` returning `"group-reasoning"` and compose `ReasoningRoot`
+ * / `ReasoningTrigger` / `ReasoningContent` / `ReasoningText` directly.
+ * See `thread.tsx` for an example.
+ */
+const ReasoningGroup = memo(ReasoningGroupImpl);
+ReasoningGroup.displayName = "ReasoningGroup";
+
+export {
+ Reasoning,
+ ReasoningGroup,
+ ReasoningRoot,
+ ReasoningTrigger,
+ ReasoningContent,
+ ReasoningText,
+ ReasoningFade,
+};
diff --git a/client/packages/lowcoder/src/components/assistant-ui/thread-composer.tsx b/client/packages/lowcoder/src/components/assistant-ui/thread-composer.tsx
new file mode 100644
index 0000000000..18405fdde6
--- /dev/null
+++ b/client/packages/lowcoder/src/components/assistant-ui/thread-composer.tsx
@@ -0,0 +1,103 @@
+import {
+ AuiIf,
+ ComposerPrimitive,
+ MessagePrimitive,
+} from "@assistant-ui/react";
+import { ArrowUpIcon, SquareIcon } from "lucide-react";
+import type { FC } from "react";
+import { trans } from "i18n";
+
+import {
+ ComposerAddAttachment,
+ ComposerAttachments,
+} from "./ui/attachment";
+import { Button } from "./ui/button";
+import { TooltipIconButton } from "./tooltip-icon-button";
+
+export const Composer: FC<{
+ placeholder?: string;
+ showAttachments?: boolean;
+}> = ({
+ placeholder = trans("chat.composerPlaceholder"),
+ showAttachments = true,
+}) => {
+ return (
+
+
+
+ {showAttachments && }
+
+
+
+
+
+ );
+};
+
+export const EditComposer: FC = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const ComposerAction: FC<{ showAttachments?: boolean }> = ({
+ showAttachments = true,
+}) => {
+ return (
+
+ {showAttachments ?
:
}
+
!s.thread.isRunning}>
+
+
+
+
+
+
+
s.thread.isRunning}>
+
+
+
+
+
+ );
+};
diff --git a/client/packages/lowcoder/src/components/assistant-ui/thread-list.styles.ts b/client/packages/lowcoder/src/components/assistant-ui/thread-list.styles.ts
new file mode 100644
index 0000000000..223b202a24
--- /dev/null
+++ b/client/packages/lowcoder/src/components/assistant-ui/thread-list.styles.ts
@@ -0,0 +1,155 @@
+import {
+ ThreadListItemMorePrimitive,
+ ThreadListItemPrimitive,
+ ThreadListPrimitive,
+} from "@assistant-ui/react";
+import styled from "styled-components";
+
+import { Button } from "./ui/button";
+
+export const StyledThreadListRoot = styled(ThreadListPrimitive.Root)`
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-height: 0;
+ overflow: hidden;
+`;
+
+export const SkeletonStack = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+`;
+
+export const SkeletonRow = styled.div`
+ align-items: center;
+ display: flex;
+ height: 36px;
+ padding: 0 12px;
+`;
+
+export const SkeletonBar = styled.div`
+ background: #eef0f3;
+ border-radius: 4px;
+ height: 16px;
+ width: 100%;
+`;
+
+export const StyledNewThreadButton = styled(Button)`
+ justify-content: flex-start;
+ width: 100%;
+`;
+
+export const StyledThreadListItem = styled(ThreadListItemPrimitive.Root)`
+ align-items: center;
+ border-radius: 8px;
+ display: flex;
+ gap: 6px;
+ height: 36px;
+ min-width: 0;
+ transition: background-color 0.2s ease;
+
+ &:hover,
+ &:focus-within,
+ &[data-active],
+ &[data-active="true"] {
+ background: #f3f4f6;
+ }
+
+ .aui-thread-list-item-more {
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ }
+
+ &:hover .aui-thread-list-item-more,
+ &:focus-within .aui-thread-list-item-more,
+ &[data-active] .aui-thread-list-item-more,
+ &[data-active="true"] .aui-thread-list-item-more {
+ opacity: 1;
+ }
+`;
+
+export const StyledThreadListTrigger = styled(ThreadListItemPrimitive.Trigger)`
+ align-items: center;
+ background: transparent;
+ border: 0;
+ color: #1f2937;
+ cursor: pointer;
+ display: flex;
+ flex: 1;
+ font-size: 14px;
+ height: 100%;
+ min-width: 0;
+ overflow: hidden;
+ padding: 0 12px;
+ text-align: left;
+`;
+
+export const ThreadTitle = styled.span`
+ display: block;
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+export const StyledThreadRenameForm = styled.form`
+ align-items: center;
+ display: flex;
+ flex: 1;
+ height: 100%;
+ min-width: 0;
+ padding: 0 6px;
+`;
+
+export const StyledThreadRenameInput = styled.input`
+ background: #ffffff;
+ border: 1px solid #1677ff;
+ border-radius: 6px;
+ color: #1f2937;
+ flex: 1;
+ font-size: 14px;
+ height: 28px;
+ min-width: 0;
+ outline: none;
+ padding: 0 8px;
+
+ &:focus {
+ box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.14);
+ }
+`;
+
+export const StyledMenuContent = styled(ThreadListItemMorePrimitive.Content)`
+ background: #ffffff;
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+ box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
+ min-width: 144px;
+ padding: 4px;
+ z-index: 1000;
+`;
+
+export const StyledMenuItem = styled(ThreadListItemMorePrimitive.Item)<{
+ $danger?: boolean;
+}>`
+ align-items: center;
+ border-radius: 6px;
+ color: ${(props) => (props.$danger ? "#cf1322" : "#1f2937")};
+ cursor: pointer;
+ display: flex;
+ font-size: 14px;
+ gap: 8px;
+ outline: none;
+ padding: 7px 8px;
+
+ &:hover,
+ &:focus {
+ background: ${(props) => (props.$danger ? "#fff1f0" : "#f3f4f6")};
+ }
+
+ svg {
+ height: 16px;
+ width: 16px;
+ }
+`;
diff --git a/client/packages/lowcoder/src/components/assistant-ui/thread-list.tsx b/client/packages/lowcoder/src/components/assistant-ui/thread-list.tsx
new file mode 100644
index 0000000000..4f0eb7873d
--- /dev/null
+++ b/client/packages/lowcoder/src/components/assistant-ui/thread-list.tsx
@@ -0,0 +1,201 @@
+import { Button } from "./ui/button";
+import {
+ AuiIf,
+ ThreadListItemMorePrimitive,
+ ThreadListItemPrimitive,
+ ThreadListPrimitive,
+ useAui,
+ useAuiState,
+} from "@assistant-ui/react";
+import {
+ MoreHorizontalIcon,
+ PencilIcon,
+ PlusIcon,
+ TrashIcon,
+} from "lucide-react";
+import { trans } from "i18n";
+import type { FC, KeyboardEvent } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ SkeletonBar,
+ SkeletonRow,
+ SkeletonStack,
+ StyledMenuContent,
+ StyledMenuItem,
+ StyledThreadRenameForm,
+ StyledThreadRenameInput,
+ StyledNewThreadButton,
+ StyledThreadListItem,
+ StyledThreadListRoot,
+ StyledThreadListTrigger,
+ ThreadTitle,
+} from "./thread-list.styles";
+
+const ThreadListSkeleton: FC = () => {
+ return (
+
+ {Array.from({ length: 5 }, (_, i) => (
+
+
+
+ ))}
+
+ );
+};
+
+export const ThreadList: FC = () => {
+ return (
+
+
+ s.threads.isLoading}>
+
+
+ !s.threads.isLoading}>
+
+ {() => }
+
+
+
+ );
+};
+
+const ThreadListNew: FC = () => {
+ return (
+
+
+
+ {trans("chat.newThread")}
+
+
+ );
+};
+
+const ThreadListItem: FC = () => {
+ const aui = useAui();
+ const title =
+ useAuiState((s) => s.threadListItem.title) || trans("chat.newChatTitle");
+ const [isEditing, setIsEditing] = useState(false);
+ const [draftTitle, setDraftTitle] = useState(title);
+ const inputRef = useRef(null);
+ const isSavingRef = useRef(false);
+ const skipNextBlurSaveRef = useRef(false);
+
+ useEffect(() => {
+ if (!isEditing) setDraftTitle(title);
+ }, [isEditing, title]);
+
+ useEffect(() => {
+ if (!isEditing) return;
+
+ inputRef.current?.focus();
+ inputRef.current?.select();
+ }, [isEditing]);
+
+ const startEditing = useCallback(() => {
+ skipNextBlurSaveRef.current = false;
+ setDraftTitle(title);
+ setIsEditing(true);
+ }, [title]);
+
+ const cancelEditing = useCallback(() => {
+ skipNextBlurSaveRef.current = true;
+ setDraftTitle(title);
+ setIsEditing(false);
+ }, [title]);
+
+ const saveTitle = useCallback(async () => {
+ if (isSavingRef.current) return;
+ isSavingRef.current = true;
+
+ const nextTitle = draftTitle.trim();
+
+ try {
+ if (nextTitle && nextTitle !== title) {
+ await aui.threadListItem().rename(nextTitle);
+ }
+ setIsEditing(false);
+ } finally {
+ isSavingRef.current = false;
+ }
+ }, [aui, draftTitle, title]);
+
+ const handleRenameKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ void saveTitle();
+ return;
+ }
+
+ if (event.key === "Escape") {
+ event.preventDefault();
+ cancelEditing();
+ }
+ };
+
+ return (
+
+ {isEditing ? (
+ {
+ event.preventDefault();
+ void saveTitle();
+ }}
+ >
+ {
+ if (skipNextBlurSaveRef.current) {
+ skipNextBlurSaveRef.current = false;
+ return;
+ }
+
+ void saveTitle();
+ }}
+ onChange={(event) => setDraftTitle(event.target.value)}
+ onKeyDown={handleRenameKeyDown}
+ />
+
+ ) : (
+
+
+
+
+
+ )}
+
+
+ );
+};
+
+const ThreadListItemMore: FC<{ onRename: () => void }> = ({ onRename }) => {
+ return (
+
+
+
+
+
+
+
+ {trans("rename")}
+
+
+
+
+ Delete
+
+
+
+
+ );
+};
diff --git a/client/packages/lowcoder/src/components/assistant-ui/thread-message.tsx b/client/packages/lowcoder/src/components/assistant-ui/thread-message.tsx
new file mode 100644
index 0000000000..1d86416cd5
--- /dev/null
+++ b/client/packages/lowcoder/src/components/assistant-ui/thread-message.tsx
@@ -0,0 +1,264 @@
+import {
+ ActionBarMorePrimitive,
+ ActionBarPrimitive,
+ AuiIf,
+ BranchPickerPrimitive,
+ ErrorPrimitive,
+ getMcpAppFromToolPart,
+ MessagePrimitive,
+ useAuiState,
+} from "@assistant-ui/react";
+import {
+ CheckIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+ CopyIcon,
+ DownloadIcon,
+ MoreHorizontalIcon,
+ PencilIcon,
+} from "lucide-react";
+import type { FC } from "react";
+
+import { AssistantMessageLoader } from "./assistant-message-loader";
+import { MarkdownText } from "./markdown-text";
+import { EditComposer } from "./thread-composer";
+import {
+ Reasoning,
+ ReasoningContent,
+ ReasoningRoot,
+ ReasoningText,
+ ReasoningTrigger,
+} from "./reasoning";
+import {
+ ToolGroupContent,
+ ToolGroupRoot,
+ ToolGroupTrigger,
+} from "./tool-group";
+import { ToolFallback } from "./tool-fallback";
+import { TooltipIconButton } from "./tooltip-icon-button";
+import { UserMessageAttachments } from "./ui/attachment";
+
+export const ThreadMessage: FC<{ showAttachments?: boolean }> = ({
+ showAttachments = true,
+}) => {
+ const role = useAuiState((s) => s.message.role);
+ const isEditing = useAuiState((s) => s.message.composer.isEditing);
+
+ if (isEditing) return ;
+ if (role === "user") return ;
+ return ;
+};
+
+const MessageError: FC = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+const AssistantMessage: FC = () => {
+ const isEmptyRunningMessage = useAuiState(
+ (s) =>
+ s.message.parts.length === 0 &&
+ (s.message.status?.type ?? "complete") === "running"
+ );
+
+ return (
+
+
+ {isEmptyRunningMessage &&
}
+
{
+ if (part.type === "reasoning")
+ return ["group-chainOfThought", "group-reasoning"];
+ if (part.type === "tool-call") {
+ if (getMcpAppFromToolPart(part)) return null;
+ return ["group-chainOfThought", "group-tool"];
+ }
+ return null;
+ }}
+ >
+ {({ part, children }) => {
+ switch (part.type) {
+ case "group-chainOfThought":
+ return {children}
;
+ case "group-reasoning": {
+ const running = part.status.type === "running";
+ return (
+
+
+
+ {children}
+
+
+ );
+ }
+ case "group-tool":
+ return (
+
+
+ {children}
+
+ );
+ case "text":
+ if (part.status?.type === "running" && part.text === "") {
+ return ;
+ }
+ return ;
+ case "reasoning":
+ return ;
+ case "tool-call":
+ return part.toolUI ?? ;
+ default:
+ return null;
+ }
+ }}
+
+
+
+
+
+
+ );
+};
+
+const AssistantActionBar: FC = () => {
+ return (
+
+
+
+ s.message.isCopied}>
+
+
+ !s.message.isCopied}>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Export as Markdown
+
+
+
+
+
+ );
+};
+
+const UserMessage: FC<{ showAttachments?: boolean }> = ({
+ showAttachments = true,
+}) => {
+ return (
+
+ {showAttachments && }
+
+
+
+
+
+ );
+};
+
+const UserActionBar: FC = () => {
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+const BranchPicker: FC = ({
+ className,
+ ...rest
+}) => {
+ return (
+
+
+
+
+
+
+
+ /
+
+
+
+
+
+
+
+ );
+};
diff --git a/client/packages/lowcoder/src/components/assistant-ui/thread-welcome.tsx b/client/packages/lowcoder/src/components/assistant-ui/thread-welcome.tsx
new file mode 100644
index 0000000000..538edc48c2
--- /dev/null
+++ b/client/packages/lowcoder/src/components/assistant-ui/thread-welcome.tsx
@@ -0,0 +1,49 @@
+import { ThreadPrimitive } from "@assistant-ui/react";
+import type { FC } from "react";
+import { trans } from "i18n";
+
+export const ThreadWelcome: FC = () => {
+ return (
+
+
+
+
+ {trans("chat.welcomeMessage")}
+
+
+
+
+
+ );
+};
+
+const ThreadSuggestions: FC = () => {
+ return (
+
+
+
+
+ {trans("chat.suggestionWeather")}
+
+
+
+
+
+
+ {trans("chat.suggestionAssistant")}
+
+
+
+
+ );
+};
diff --git a/client/packages/lowcoder/src/components/assistant-ui/thread.styles.ts b/client/packages/lowcoder/src/components/assistant-ui/thread.styles.ts
new file mode 100644
index 0000000000..b8faaac3c1
--- /dev/null
+++ b/client/packages/lowcoder/src/components/assistant-ui/thread.styles.ts
@@ -0,0 +1,359 @@
+import { ThreadPrimitive } from "@assistant-ui/react";
+import styled from "styled-components";
+
+export const StyledThreadRoot = styled(ThreadPrimitive.Root)`
+ background: #f9fafb;
+ color: #1f2937;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ overflow: hidden;
+
+ .aui-thread-viewport {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ min-height: 0;
+ overflow-x: hidden;
+ overflow-y: auto;
+ scroll-behavior: smooth;
+ }
+
+ .aui-thread-layout {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ margin: 0 auto;
+ max-width: var(--thread-max-width);
+ min-height: 0;
+ padding: 16px;
+ width: 100%;
+ }
+
+ .aui-message-group {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ margin-bottom: 24px;
+ }
+
+ .aui-message-group:empty {
+ display: none;
+ }
+
+ .aui-thread-welcome-root {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ justify-content: center;
+ margin: auto 0;
+ }
+
+ .aui-thread-welcome-center {
+ align-items: center;
+ display: flex;
+ flex: 1;
+ justify-content: center;
+ width: 100%;
+ }
+
+ .aui-thread-welcome-message {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding: 0 16px;
+ width: 100%;
+ }
+
+ .aui-thread-welcome-message-inner {
+ color: #111827;
+ font-size: 20px;
+ font-weight: 600;
+ line-height: 28px;
+ margin: 0;
+ }
+
+ .aui-thread-welcome-suggestions {
+ display: grid;
+ gap: 8px;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ padding-bottom: 16px;
+ width: 100%;
+ }
+
+ .aui-thread-welcome-suggestion {
+ align-items: flex-start;
+ background: #ffffff;
+ border: 1px solid #d9d9d9;
+ border-radius: 16px;
+ color: #1f2937;
+ cursor: pointer;
+ display: flex;
+ flex-wrap: wrap;
+ font-size: 14px;
+ gap: 4px;
+ min-height: 44px;
+ padding: 10px 14px;
+ text-align: left;
+ transition:
+ background-color 0.2s ease,
+ border-color 0.2s ease;
+ width: 100%;
+ }
+
+ .aui-thread-welcome-suggestion:hover {
+ background: #f3f4f6;
+ border-color: #bfbfbf;
+ }
+
+ .aui-thread-welcome-suggestion-text-1 {
+ font-weight: 500;
+ }
+
+ .aui-thread-viewport-footer {
+ background: linear-gradient(180deg, rgba(249, 250, 251, 0), #f9fafb 24px);
+ bottom: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ margin-top: auto;
+ overflow: visible;
+ padding-bottom: 16px;
+ padding-top: 24px;
+ position: sticky;
+ z-index: 2;
+ }
+
+ .aui-thread-scroll-to-bottom {
+ align-self: center;
+ box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12);
+ }
+
+ .aui-composer-root {
+ display: flex;
+ flex-direction: column;
+ position: relative;
+ width: 100%;
+ }
+
+ .aui-composer-shell {
+ background: #ffffff;
+ border: 1px solid #d9d9d9;
+ border-radius: var(--composer-radius);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: var(--composer-padding);
+ transition:
+ border-color 0.2s ease,
+ box-shadow 0.2s ease;
+ width: 100%;
+ }
+
+ .aui-composer-shell:focus-within {
+ border-color: #1677ff;
+ box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.12);
+ }
+
+ .aui-composer-input {
+ background: transparent;
+ border: 0;
+ color: #1f2937;
+ font-size: 14px;
+ line-height: 22px;
+ max-height: 128px;
+ min-height: 40px;
+ outline: 0;
+ padding: 4px 6px;
+ resize: none;
+ width: 100%;
+ }
+
+ .aui-composer-input::placeholder {
+ color: #8c8c8c;
+ }
+
+ .aui-composer-action-wrapper {
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
+ min-height: 32px;
+ }
+
+ .aui-composer-add-attachment,
+ .aui-composer-send,
+ .aui-composer-cancel {
+ border-radius: 50%;
+ }
+
+ .aui-assistant-message-root {
+ margin: 0 auto;
+ max-width: var(--thread-max-width);
+ position: relative;
+ width: 100%;
+ }
+
+ .aui-assistant-message-content {
+ color: #1f2937;
+ font-size: 14px;
+ line-height: 24px;
+ padding: 0 8px;
+ word-break: break-word;
+ }
+
+ .aui-assistant-message-footer {
+ align-items: center;
+ display: flex;
+ margin-left: 8px;
+ min-height: 28px;
+ }
+
+ .aui-assistant-action-bar-root {
+ align-items: center;
+ color: #6b7280;
+ display: flex;
+ gap: 4px;
+ }
+
+ .aui-action-bar-more-content {
+ background: #ffffff;
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+ box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
+ min-width: 144px;
+ padding: 4px;
+ z-index: 1000;
+ }
+
+ .aui-action-bar-more-item {
+ align-items: center;
+ border-radius: 6px;
+ color: #1f2937;
+ cursor: pointer;
+ display: flex;
+ font-size: 14px;
+ gap: 8px;
+ outline: none;
+ padding: 7px 8px;
+ }
+
+ .aui-action-bar-more-item:hover,
+ .aui-action-bar-more-item:focus {
+ background: #f3f4f6;
+ }
+
+ .aui-user-message-root {
+ animation: none;
+ display: grid;
+ grid-auto-rows: auto;
+ grid-template-columns: minmax(72px, 1fr) auto;
+ gap: 8px 0;
+ margin: 0 auto;
+ max-width: var(--thread-max-width);
+ padding: 0 8px;
+ width: 100%;
+ }
+
+ .aui-user-message-content-wrapper {
+ grid-column-start: 2;
+ min-width: 0;
+ position: relative;
+ }
+
+ .aui-user-message-content {
+ background: #e5e7eb;
+ border-radius: 16px;
+ color: #111827;
+ font-size: 14px;
+ line-height: 22px;
+ padding: 10px 16px;
+ word-break: break-word;
+ }
+
+ .aui-user-action-bar-wrapper {
+ padding-right: 8px;
+ position: absolute;
+ right: 100%;
+ top: 50%;
+ transform: translateY(-50%);
+ }
+
+ .aui-user-action-bar-root {
+ align-items: flex-end;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .aui-user-branch-picker {
+ grid-column: 1 / -1;
+ grid-row-start: 3;
+ justify-content: flex-end;
+ }
+
+ .aui-edit-composer-wrapper {
+ display: flex;
+ flex-direction: column;
+ padding: 0 8px;
+ }
+
+ .aui-edit-composer-root {
+ align-self: flex-end;
+ background: #e5e7eb;
+ border-radius: 16px;
+ display: flex;
+ flex-direction: column;
+ max-width: 85%;
+ width: 100%;
+ }
+
+ .aui-edit-composer-input {
+ background: transparent;
+ border: 0;
+ color: #111827;
+ font-size: 14px;
+ min-height: 56px;
+ outline: 0;
+ padding: 16px;
+ resize: none;
+ width: 100%;
+ }
+
+ .aui-edit-composer-footer {
+ align-items: center;
+ align-self: flex-end;
+ display: flex;
+ gap: 8px;
+ margin: 0 12px 12px;
+ }
+
+ .aui-branch-picker-root {
+ align-items: center;
+ color: #6b7280;
+ display: inline-flex;
+ font-size: 12px;
+ gap: 4px;
+ margin-left: -8px;
+ margin-right: 8px;
+ }
+
+ .aui-branch-picker-state {
+ font-weight: 500;
+ min-width: 36px;
+ text-align: center;
+ }
+
+ .aui-tooltip-icon-button svg,
+ .aui-button svg {
+ height: 16px;
+ width: 16px;
+ }
+
+ .aui-assistant-message-root:has([data-status="running"]) {
+ display: none;
+ }
+
+ .aui-assistant-message-content [data-status="running"] {
+ display: none;
+ }
+`;
diff --git a/client/packages/lowcoder/src/components/assistant-ui/thread.tsx b/client/packages/lowcoder/src/components/assistant-ui/thread.tsx
new file mode 100644
index 0000000000..69032704ef
--- /dev/null
+++ b/client/packages/lowcoder/src/components/assistant-ui/thread.tsx
@@ -0,0 +1,73 @@
+import { AuiIf, ThreadPrimitive } from "@assistant-ui/react";
+import { ArrowDownIcon } from "lucide-react";
+import type { FC } from "react";
+import { trans } from "i18n";
+
+import { Composer } from "./thread-composer";
+import { StyledThreadRoot } from "./thread.styles";
+import { ThreadMessage } from "./thread-message";
+import { ThreadWelcome } from "./thread-welcome";
+import { TooltipIconButton } from "./tooltip-icon-button";
+
+interface ThreadProps {
+ placeholder?: string;
+ showAttachments?: boolean;
+ autoHeight?: boolean;
+}
+
+export const Thread: FC = ({
+ placeholder = trans("chat.composerPlaceholder"),
+ showAttachments = true,
+ autoHeight = false,
+}) => {
+ return (
+
+
+
+
s.thread.isEmpty}>
+
+
+
+
+
+ {() => }
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const ThreadScrollToBottom: FC = () => {
+ return (
+
+
+
+
+
+ );
+};
diff --git a/client/packages/lowcoder/src/components/assistant-ui/tool-fallback.tsx b/client/packages/lowcoder/src/components/assistant-ui/tool-fallback.tsx
new file mode 100644
index 0000000000..8c99664f5c
--- /dev/null
+++ b/client/packages/lowcoder/src/components/assistant-ui/tool-fallback.tsx
@@ -0,0 +1,422 @@
+"use client";
+
+import { memo, useCallback, useRef, useState } from "react";
+import {
+ AlertCircleIcon,
+ CheckIcon,
+ LoaderIcon,
+ XCircleIcon,
+} from "lucide-react";
+import {
+ useScrollLock,
+ type ToolCallMessagePartStatus,
+ type ToolCallMessagePartComponent,
+} from "@assistant-ui/react";
+import styled, { keyframes } from "styled-components";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "./ui/collapsible";
+
+const ANIMATION_DURATION = 200;
+
+const spin = keyframes`
+ to {
+ transform: rotate(360deg);
+ }
+`;
+
+const shimmer = keyframes`
+ 0% {
+ background-position: 200% 0;
+ }
+
+ 100% {
+ background-position: -200% 0;
+ }
+`;
+
+const StyledToolFallbackRoot = styled(Collapsible)<{ $cancelled?: boolean }>`
+ background: ${({ $cancelled }) => ($cancelled ? "#f9fafb" : "#ffffff")};
+ border: 1px solid ${({ $cancelled }) => ($cancelled ? "#d1d5db" : "#e5e7eb")};
+ border-radius: 8px;
+ padding: 12px 0;
+ width: 100%;
+`;
+
+const StyledToolFallbackTrigger = styled(CollapsibleTrigger)`
+ align-items: center;
+ background: transparent;
+ border: 0;
+ color: #4b5563;
+ cursor: pointer;
+ display: flex;
+ font-size: 14px;
+ gap: 8px;
+ line-height: 20px;
+ padding: 0 16px;
+ text-align: left;
+ transition: color 0.2s ease;
+ width: 100%;
+
+ &:hover {
+ color: #111827;
+ }
+
+ .aui-tool-fallback-trigger-icon,
+ .aui-tool-fallback-trigger-chevron {
+ flex: 0 0 auto;
+ height: 16px;
+ width: 16px;
+ }
+
+ .aui-tool-fallback-trigger-icon-running {
+ animation: ${spin} 1s linear infinite;
+ }
+
+ .aui-tool-fallback-trigger-icon-cancelled,
+ .aui-tool-fallback-trigger-label-cancelled {
+ color: #8c8c8c;
+ }
+
+ .aui-tool-fallback-trigger-label-cancelled {
+ text-decoration: line-through;
+ }
+
+ .aui-tool-fallback-trigger-label-wrapper {
+ display: inline-block;
+ flex: 1;
+ line-height: 1;
+ min-width: 0;
+ position: relative;
+ text-align: start;
+ }
+
+ .aui-tool-fallback-trigger-shimmer {
+ background: linear-gradient(90deg, transparent, rgba(22, 119, 255, 0.35), transparent);
+ background-size: 200% 100%;
+ color: transparent;
+ inset: 0;
+ pointer-events: none;
+ position: absolute;
+ -webkit-background-clip: text;
+ animation: ${shimmer} 1.6s linear infinite;
+ }
+
+ .aui-tool-fallback-trigger-chevron {
+ transform: rotate(0deg);
+ transition: transform var(--animation-duration, 200ms) ease-out;
+ }
+
+ &[data-state="closed"] .aui-tool-fallback-trigger-chevron {
+ transform: rotate(-90deg);
+ }
+`;
+
+const StyledToolFallbackContent = styled(CollapsibleContent)`
+ color: #1f2937;
+ font-size: 14px;
+ outline: none;
+ overflow: hidden;
+ position: relative;
+`;
+
+const ToolFallbackContentInner = styled.div`
+ border-top: 1px solid #e5e7eb;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 12px;
+ padding-top: 8px;
+`;
+
+const ToolFallbackSection = styled.div`
+ padding: 0 16px;
+`;
+
+const ToolFallbackDashedSection = styled(ToolFallbackSection)`
+ border-top: 1px dashed #e5e7eb;
+ padding-top: 8px;
+`;
+
+const ToolFallbackHeader = styled.p`
+ color: #4b5563;
+ font-weight: 600;
+ margin: 0 0 4px;
+`;
+
+const ToolFallbackText = styled.p`
+ color: #6b7280;
+ margin: 0;
+`;
+
+const ToolFallbackPre = styled.pre`
+ margin: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+`;
+
+export type ToolFallbackRootProps = Omit<
+ React.ComponentPropsWithoutRef,
+ "open" | "onOpenChange"
+> & {
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ defaultOpen?: boolean;
+ cancelled?: boolean;
+};
+
+function ToolFallbackRoot({
+ className,
+ open: controlledOpen,
+ onOpenChange: controlledOnOpenChange,
+ defaultOpen = false,
+ cancelled = false,
+ children,
+ ...props
+}: ToolFallbackRootProps) {
+ const collapsibleRef = useRef(null);
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
+ const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION);
+
+ const isControlled = controlledOpen !== undefined;
+ const isOpen = isControlled ? controlledOpen : uncontrolledOpen;
+
+ const handleOpenChange = useCallback(
+ (open: boolean) => {
+ if (!open) {
+ lockScroll();
+ }
+ if (!isControlled) {
+ setUncontrolledOpen(open);
+ }
+ controlledOnOpenChange?.(open);
+ },
+ [lockScroll, isControlled, controlledOnOpenChange],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+type ToolStatus = ToolCallMessagePartStatus["type"];
+
+const statusIconMap: Record = {
+ running: LoaderIcon,
+ complete: CheckIcon,
+ incomplete: XCircleIcon,
+ "requires-action": AlertCircleIcon,
+};
+
+function ToolFallbackTrigger({
+ toolName,
+ status,
+ className,
+ ...props
+}: React.ComponentPropsWithoutRef & {
+ toolName: string;
+ status?: ToolCallMessagePartStatus;
+}) {
+ const statusType = status?.type ?? "complete";
+ const isRunning = statusType === "running";
+ const isCancelled =
+ status?.type === "incomplete" && status.reason === "cancelled";
+
+ const Icon = statusIconMap[statusType];
+ const label = isCancelled ? "Cancelled tool" : "Used tool";
+
+ return (
+
+
+
+
+ {label}: {toolName}
+
+ {isRunning && (
+
+ {label}: {toolName}
+
+ )}
+
+
+ );
+}
+
+function ToolFallbackContent({
+ className,
+ children,
+ ...props
+}: React.ComponentPropsWithoutRef) {
+ return (
+
+ {children}
+
+ );
+}
+
+function ToolFallbackArgs({
+ argsText,
+ className,
+ ...props
+}: React.ComponentPropsWithoutRef<"div"> & {
+ argsText?: string;
+}) {
+ if (!argsText) return null;
+
+ return (
+
+
+ {argsText}
+
+
+ );
+}
+
+function ToolFallbackResult({
+ result,
+ className,
+ ...props
+}: React.ComponentPropsWithoutRef<"div"> & {
+ result?: unknown;
+}) {
+ if (result === undefined) return null;
+
+ return (
+
+ Result:
+
+ {typeof result === "string" ? result : JSON.stringify(result, null, 2)}
+
+
+ );
+}
+
+function ToolFallbackError({
+ status,
+ className,
+ ...props
+}: React.ComponentPropsWithoutRef<"div"> & {
+ status?: ToolCallMessagePartStatus;
+}) {
+ if (status?.type !== "incomplete") return null;
+
+ const error = status.error;
+ const errorText = error
+ ? typeof error === "string"
+ ? error
+ : JSON.stringify(error)
+ : null;
+
+ if (!errorText) return null;
+
+ const isCancelled = status.reason === "cancelled";
+ const headerText = isCancelled ? "Cancelled reason:" : "Error:";
+
+ return (
+
+
+ {headerText}
+
+
+ {errorText}
+
+
+ );
+}
+
+const ToolFallbackImpl: ToolCallMessagePartComponent = ({
+ toolName,
+ status,
+}) => {
+ const isCancelled =
+ status?.type === "incomplete" && status.reason === "cancelled";
+
+ return (
+
+
+
+ );
+};
+
+const ToolFallback = memo(
+ ToolFallbackImpl,
+) as unknown as ToolCallMessagePartComponent & {
+ Root: typeof ToolFallbackRoot;
+ Trigger: typeof ToolFallbackTrigger;
+ Content: typeof ToolFallbackContent;
+ Args: typeof ToolFallbackArgs;
+ Result: typeof ToolFallbackResult;
+ Error: typeof ToolFallbackError;
+};
+
+ToolFallback.displayName = "ToolFallback";
+ToolFallback.Root = ToolFallbackRoot;
+ToolFallback.Trigger = ToolFallbackTrigger;
+ToolFallback.Content = ToolFallbackContent;
+ToolFallback.Args = ToolFallbackArgs;
+ToolFallback.Result = ToolFallbackResult;
+ToolFallback.Error = ToolFallbackError;
+
+export {
+ ToolFallback,
+ ToolFallbackRoot,
+ ToolFallbackTrigger,
+ ToolFallbackContent,
+ ToolFallbackArgs,
+ ToolFallbackResult,
+ ToolFallbackError,
+};
diff --git a/client/packages/lowcoder/src/components/assistant-ui/tool-group.tsx b/client/packages/lowcoder/src/components/assistant-ui/tool-group.tsx
new file mode 100644
index 0000000000..99c4f8b4f1
--- /dev/null
+++ b/client/packages/lowcoder/src/components/assistant-ui/tool-group.tsx
@@ -0,0 +1,288 @@
+"use client";
+
+import {
+ memo,
+ useCallback,
+ useRef,
+ useState,
+ type FC,
+ type PropsWithChildren,
+} from "react";
+import { ChevronDownIcon, LoaderIcon } from "lucide-react";
+import { useScrollLock } from "@assistant-ui/react";
+import styled, { keyframes } from "styled-components";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "./ui/collapsible";
+
+const ANIMATION_DURATION = 200;
+
+type ToolGroupVariant = "outline" | "ghost" | "muted";
+
+const spin = keyframes`
+ to {
+ transform: rotate(360deg);
+ }
+`;
+
+const shimmer = keyframes`
+ 0% {
+ background-position: 200% 0;
+ }
+
+ 100% {
+ background-position: -200% 0;
+ }
+`;
+
+const StyledToolGroupRoot = styled(Collapsible)<{ $variant: ToolGroupVariant }>`
+ background: ${({ $variant }) => ($variant === "muted" ? "#f3f4f6" : "transparent")};
+ border: ${({ $variant }) => ($variant === "ghost" ? "0" : "1px solid #e5e7eb")};
+ border-radius: ${({ $variant }) => ($variant === "ghost" ? "0" : "8px")};
+ padding: ${({ $variant }) => ($variant === "ghost" ? "0" : "12px 0")};
+ width: 100%;
+`;
+
+const StyledToolGroupTrigger = styled(CollapsibleTrigger)`
+ align-items: center;
+ background: transparent;
+ border: 0;
+ color: #4b5563;
+ cursor: pointer;
+ display: flex;
+ font-size: 14px;
+ gap: 8px;
+ line-height: 20px;
+ padding: 0 16px;
+ text-align: left;
+ transition: color 0.2s ease;
+ width: 100%;
+
+ &:hover {
+ color: #111827;
+ }
+
+ .aui-tool-group-trigger-loader,
+ .aui-tool-group-trigger-chevron {
+ flex: 0 0 auto;
+ height: 16px;
+ width: 16px;
+ }
+
+ .aui-tool-group-trigger-loader {
+ animation: ${spin} 1s linear infinite;
+ }
+
+ .aui-tool-group-trigger-label-wrapper {
+ display: inline-block;
+ flex: 1;
+ font-weight: 500;
+ line-height: 1;
+ min-width: 0;
+ position: relative;
+ text-align: start;
+ }
+
+ .aui-tool-group-trigger-shimmer {
+ background: linear-gradient(90deg, transparent, rgba(22, 119, 255, 0.35), transparent);
+ background-size: 200% 100%;
+ color: transparent;
+ inset: 0;
+ pointer-events: none;
+ position: absolute;
+ -webkit-background-clip: text;
+ animation: ${shimmer} 1.6s linear infinite;
+ }
+
+ .aui-tool-group-trigger-chevron {
+ transform: rotate(0deg);
+ transition: transform var(--animation-duration, 200ms) ease-out;
+ }
+
+ &[data-state="closed"] .aui-tool-group-trigger-chevron {
+ transform: rotate(-90deg);
+ }
+`;
+
+const StyledToolGroupContent = styled(CollapsibleContent)`
+ color: #1f2937;
+ font-size: 14px;
+ outline: none;
+ overflow: hidden;
+ position: relative;
+`;
+
+const ToolGroupContentInner = styled.div`
+ border-top: 1px solid #e5e7eb;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 12px;
+ padding: 12px 16px 0;
+`;
+
+export type ToolGroupRootProps = Omit<
+ React.ComponentPropsWithoutRef,
+ "open" | "onOpenChange"
+> &
+ {
+ variant?: ToolGroupVariant;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ defaultOpen?: boolean;
+ };
+
+function ToolGroupRoot({
+ className,
+ variant,
+ open: controlledOpen,
+ onOpenChange: controlledOnOpenChange,
+ defaultOpen = false,
+ children,
+ ...props
+}: ToolGroupRootProps) {
+ const collapsibleRef = useRef(null);
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
+ const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION);
+
+ const isControlled = controlledOpen !== undefined;
+ const isOpen = isControlled ? controlledOpen : uncontrolledOpen;
+
+ const handleOpenChange = useCallback(
+ (open: boolean) => {
+ if (!open) {
+ lockScroll();
+ }
+ if (!isControlled) {
+ setUncontrolledOpen(open);
+ }
+ controlledOnOpenChange?.(open);
+ },
+ [lockScroll, isControlled, controlledOnOpenChange],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+function ToolGroupTrigger({
+ count,
+ active = false,
+ className,
+ ...props
+}: React.ComponentPropsWithoutRef & {
+ count: number;
+ active?: boolean;
+}) {
+ const label = `${count} tool ${count === 1 ? "call" : "calls"}`;
+
+ return (
+
+ {active && (
+
+ )}
+
+ {label}
+ {active && (
+
+ {label}
+
+ )}
+
+
+
+ );
+}
+
+function ToolGroupContent({
+ className,
+ children,
+ ...props
+}: React.ComponentPropsWithoutRef) {
+ return (
+
+ {children}
+
+ );
+}
+
+type ToolGroupComponent = FC<
+ PropsWithChildren<{ startIndex: number; endIndex: number }>
+> & {
+ Root: typeof ToolGroupRoot;
+ Trigger: typeof ToolGroupTrigger;
+ Content: typeof ToolGroupContent;
+};
+
+const ToolGroupImpl: FC<
+ PropsWithChildren<{ startIndex: number; endIndex: number }>
+> = ({ children, startIndex, endIndex }) => {
+ const toolCount = endIndex - startIndex + 1;
+
+ return (
+
+
+ {children}
+
+ );
+};
+
+/**
+ * @deprecated This wrapper targets the legacy `components.ToolGroup` prop
+ * on ``. Use `` with
+ * a `groupBy` returning `"group-tool"` and compose `ToolGroupRoot` /
+ * `ToolGroupTrigger` / `ToolGroupContent` directly. See `thread.tsx`.
+ */
+const ToolGroup = memo(ToolGroupImpl) as unknown as ToolGroupComponent;
+
+ToolGroup.displayName = "ToolGroup";
+ToolGroup.Root = ToolGroupRoot;
+ToolGroup.Trigger = ToolGroupTrigger;
+ToolGroup.Content = ToolGroupContent;
+
+export {
+ ToolGroup,
+ ToolGroupRoot,
+ ToolGroupTrigger,
+ ToolGroupContent,
+};
diff --git a/client/packages/lowcoder/src/components/assistant-ui/tooltip-icon-button.tsx b/client/packages/lowcoder/src/components/assistant-ui/tooltip-icon-button.tsx
new file mode 100644
index 0000000000..b9f57b23b0
--- /dev/null
+++ b/client/packages/lowcoder/src/components/assistant-ui/tooltip-icon-button.tsx
@@ -0,0 +1,46 @@
+import { forwardRef } from "react";
+import { Tooltip } from "antd";
+
+import { Button, type ButtonProps } from "./ui/button";
+import { cn } from "./utils/cn";
+
+export type TooltipIconButtonProps = ButtonProps & {
+ tooltip: string;
+ side?: "top" | "bottom" | "left" | "right";
+};
+
+export const TooltipIconButton = forwardRef<
+ HTMLButtonElement,
+ TooltipIconButtonProps
+>(
+ (
+ {
+ children,
+ tooltip,
+ side = "bottom",
+ className,
+ variant = "ghost",
+ size = "icon-sm",
+ "aria-label": ariaLabel,
+ ...rest
+ },
+ ref,
+ ) => {
+ return (
+
+
+
+ );
+ },
+);
+
+TooltipIconButton.displayName = "TooltipIconButton";
diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/attachment.tsx b/client/packages/lowcoder/src/components/assistant-ui/ui/attachment.tsx
similarity index 89%
rename from client/packages/lowcoder/src/comps/comps/chatComp/components/ui/attachment.tsx
rename to client/packages/lowcoder/src/components/assistant-ui/ui/attachment.tsx
index ea823edca1..f0af0f0917 100644
--- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/attachment.tsx
+++ b/client/packages/lowcoder/src/components/assistant-ui/ui/attachment.tsx
@@ -16,7 +16,7 @@ import {
TooltipTrigger,
} from "./tooltip";
import { Avatar, AvatarImage, AvatarFallback } from "./avatar";
-import { TooltipIconButton } from "../assistant-ui/tooltip-icon-button";
+import { TooltipIconButton } from "../tooltip-icon-button";
// ============================================================================
// STYLED COMPONENTS
@@ -45,16 +45,17 @@ const StyledAvatar = styled(Avatar)`
font-size: 14px;
`;
-const AttachmentContainer = styled.div`
+const AttachmentContainer = styled.div<{ $isImage?: boolean }>`
display: flex;
- height: 48px;
- width: 160px;
+ height: ${({ $isImage }) => ($isImage ? "56px" : "48px")};
+ width: ${({ $isImage }) => ($isImage ? "56px" : "160px")};
align-items: center;
justify-content: center;
gap: 8px;
- border-radius: 8px;
+ border-radius: ${({ $isImage }) => ($isImage ? "12px" : "8px")};
border: 1px solid #e2e8f0;
- padding: 4px;
+ overflow: hidden;
+ padding: ${({ $isImage }) => ($isImage ? "0" : "4px")};
`;
const AttachmentTextContainer = styled.div`
@@ -290,8 +291,7 @@ const AttachmentUI: FC = () => {
case "file":
return "File";
default:
- const _exhaustiveCheck: never = type;
- throw new Error(`Unknown attachment type: ${_exhaustiveCheck}`);
+ return "File";
}
});
@@ -300,14 +300,16 @@ const AttachmentUI: FC = () => {
-
+
-
-
-
-
- {typeLabel}
-
+ {typeLabel !== "Image" && (
+
+
+
+
+ {typeLabel}
+
+ )}
@@ -340,7 +342,9 @@ const AttachmentRemove: FC = () => {
export const UserMessageAttachments: FC = () => {
return (
-
+
+ {() => }
+
);
};
@@ -348,9 +352,9 @@ export const UserMessageAttachments: FC = () => {
export const ComposerAttachments: FC = () => {
return (
-
+
+ {() => }
+
);
};
@@ -366,4 +370,4 @@ export const ComposerAddAttachment: FC = () => {
);
-};
\ No newline at end of file
+};
diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/avatar.tsx b/client/packages/lowcoder/src/components/assistant-ui/ui/avatar.tsx
similarity index 100%
rename from client/packages/lowcoder/src/comps/comps/chatComp/components/ui/avatar.tsx
rename to client/packages/lowcoder/src/components/assistant-ui/ui/avatar.tsx
diff --git a/client/packages/lowcoder/src/components/assistant-ui/ui/button.tsx b/client/packages/lowcoder/src/components/assistant-ui/ui/button.tsx
new file mode 100644
index 0000000000..98472c763e
--- /dev/null
+++ b/client/packages/lowcoder/src/components/assistant-ui/ui/button.tsx
@@ -0,0 +1,147 @@
+import * as React from "react";
+import { Button as AntButton } from "antd";
+import type { ButtonProps as AntButtonProps } from "antd";
+import styled from "styled-components";
+
+import { cn } from "../utils/cn";
+
+type ButtonVariant = "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
+type ButtonSize = "default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg";
+
+export type ButtonProps = Omit & {
+ variant?: ButtonVariant;
+ size?: ButtonSize;
+ asChild?: boolean;
+ type?: React.ButtonHTMLAttributes["type"];
+};
+
+const StyledButton = styled(AntButton)<{
+ $variant: ButtonVariant;
+ $size: ButtonSize;
+}>`
+ align-items: center;
+ border-radius: 8px;
+ display: inline-flex;
+ font-size: 14px;
+ font-weight: 500;
+ gap: 8px;
+ justify-content: center;
+ line-height: 20px;
+ min-width: 0;
+ white-space: nowrap;
+
+ ${({ $size }) => {
+ if ($size === "icon-xs") return "height: 24px; width: 24px; padding: 0;";
+ if ($size === "icon-sm") return "height: 32px; width: 32px; padding: 0;";
+ if ($size === "icon" || $size === "icon-lg") {
+ return `${$size === "icon-lg" ? "height: 40px; width: 40px;" : "height: 36px; width: 36px;"} padding: 0;`;
+ }
+ if ($size === "xs") return "height: 24px; padding: 0 8px; font-size: 12px;";
+ if ($size === "sm") return "height: 32px; padding: 0 12px;";
+ if ($size === "lg") return "height: 40px; padding: 0 20px;";
+ return "height: 36px; padding: 0 16px;";
+ }}
+
+ ${({ $variant }) => {
+ if ($variant === "default") {
+ return `
+ background: #1677ff;
+ border-color: #1677ff;
+ color: #ffffff;
+
+ &:hover:not(:disabled) {
+ background: #4096ff !important;
+ border-color: #4096ff !important;
+ color: #ffffff !important;
+ }
+ `;
+ }
+
+ if ($variant === "destructive") {
+ return `
+ background: #ff4d4f;
+ border-color: #ff4d4f;
+ color: #ffffff;
+
+ &:hover:not(:disabled) {
+ background: #ff7875 !important;
+ border-color: #ff7875 !important;
+ color: #ffffff !important;
+ }
+ `;
+ }
+
+ if ($variant === "ghost" || $variant === "link") {
+ return `
+ background: transparent;
+ border-color: transparent;
+ box-shadow: none;
+ color: #4b5563;
+
+ &:hover:not(:disabled) {
+ background: rgba(0, 0, 0, 0.04) !important;
+ border-color: transparent !important;
+ color: #111827 !important;
+ }
+ `;
+ }
+
+ return `
+ background: #ffffff;
+ border-color: #d9d9d9;
+ color: #1f2937;
+
+ &:hover:not(:disabled) {
+ background: #f9fafb !important;
+ border-color: #1677ff !important;
+ color: #1677ff !important;
+ }
+ `;
+ }}
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+ }
+
+ svg {
+ height: 16px;
+ width: 16px;
+ flex: 0 0 auto;
+ }
+`;
+
+const Button = React.forwardRef(
+ (
+ {
+ asChild: _asChild,
+ children,
+ className,
+ variant = "default",
+ size = "default",
+ type = "button",
+ ...props
+ },
+ ref,
+ ) => {
+ return (
+
+ {children}
+
+ );
+ },
+);
+
+Button.displayName = "Button";
+
+export { Button };
diff --git a/client/packages/lowcoder/src/components/assistant-ui/ui/collapsible.tsx b/client/packages/lowcoder/src/components/assistant-ui/ui/collapsible.tsx
new file mode 100644
index 0000000000..d1823d8352
--- /dev/null
+++ b/client/packages/lowcoder/src/components/assistant-ui/ui/collapsible.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { forwardRef } from "react";
+import { Collapsible as CollapsiblePrimitive } from "radix-ui";
+
+const Collapsible = forwardRef<
+ HTMLDivElement,
+ React.ComponentPropsWithoutRef
+>((props, ref) => {
+ return ;
+});
+
+Collapsible.displayName = "Collapsible";
+
+const CollapsibleTrigger = forwardRef<
+ HTMLButtonElement,
+ React.ComponentPropsWithoutRef
+>((props, ref) => {
+ return (
+
+ );
+});
+
+CollapsibleTrigger.displayName = "CollapsibleTrigger";
+
+const CollapsibleContent = forwardRef<
+ HTMLDivElement,
+ React.ComponentPropsWithoutRef
+>((props, ref) => {
+ return (
+
+ );
+});
+
+CollapsibleContent.displayName = "CollapsibleContent";
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent };
diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/tooltip.tsx b/client/packages/lowcoder/src/components/assistant-ui/ui/tooltip.tsx
similarity index 95%
rename from client/packages/lowcoder/src/comps/comps/chatComp/components/ui/tooltip.tsx
rename to client/packages/lowcoder/src/components/assistant-ui/ui/tooltip.tsx
index ede610e327..9d6350bda1 100644
--- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/tooltip.tsx
+++ b/client/packages/lowcoder/src/components/assistant-ui/ui/tooltip.tsx
@@ -3,7 +3,7 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
-import { cn } from "../../utils/cn";
+import { cn } from "../utils/cn";
const TooltipProvider = TooltipPrimitive.Provider;
@@ -26,4 +26,4 @@ const TooltipContent = React.forwardRef<
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
-export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
\ No newline at end of file
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/utils/cn.ts b/client/packages/lowcoder/src/components/assistant-ui/utils/cn.ts
similarity index 100%
rename from client/packages/lowcoder/src/comps/comps/chatComp/utils/cn.ts
rename to client/packages/lowcoder/src/components/assistant-ui/utils/cn.ts
diff --git a/client/packages/lowcoder/src/comps/comps/appSettingsComp.tsx b/client/packages/lowcoder/src/comps/comps/appSettingsComp.tsx
index 64122dabab..b38260eedf 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 39de2e7393..a32a3ea6b2 100644
--- a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx
+++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx
@@ -14,9 +14,10 @@ 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 { ChatMessage } from "./types/chatTypes";
+import { getTextFromThreadContent } from "./utils/assistantMessages";
import { trans } from "i18n";
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { styleControl } from "comps/controls/styleControl";
@@ -31,8 +32,8 @@ import {
} from "comps/controls/styleControlConstants";
import { AnimationStyle } from "comps/controls/styleControlConstants";
-import "@assistant-ui/styles/index.css";
-import "@assistant-ui/styles/markdown.css";
+// Assistant UI layout is styled locally with AntD and styled-components.
+// Markdown-specific styles are imported by components/assistant-ui/markdown-text.tsx.
// ============================================================================
// CHAT-SPECIFIC EVENTS
@@ -95,12 +96,12 @@ export function addSystemPromptToHistory(
systemPrompt: string
): Array<{ role: string; content: string; timestamp: number; attachments?: any[] }> {
// Format conversation history for use in queries
- const formattedHistory = conversationHistory.map(msg => {
- const baseMessage = {
- role: msg.role,
- content: msg.text,
- timestamp: msg.timestamp
- };
+ const formattedHistory = conversationHistory.map(msg => {
+ const baseMessage = {
+ role: msg.role,
+ content: getTextFromThreadContent(msg.content),
+ timestamp: msg.createdAt.getTime()
+ };
// Include attachment metadata if present (for API calls and external integrations)
if (msg.attachments && msg.attachments.length > 0) {
@@ -249,16 +250,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,
@@ -308,4 +299,4 @@ export const ChatComp = withExposingConfigs(ChatCompWithAutoHeight, [
// conversationHistory is now a proper array (not JSON string) - supports setConversationHistory(), clearConversationHistory(), resetConversationHistory()
new NameConfig("conversationHistory", "Full conversation history array with system prompt (use directly in API calls, no JSON.parse needed)"),
new NameConfig("databaseName", "Database name for SQL queries (ChatDB_)"),
-]);
\ No newline at end of file
+]);
diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx
index 689e0dc289..6cf6128e9c 100644
--- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx
+++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx
@@ -4,31 +4,38 @@ import React, { useState, useEffect, useRef } from "react";
import {
useExternalStoreRuntime,
ThreadMessageLike,
- AppendMessage,
AssistantRuntimeProvider,
- ExternalStoreThreadListAdapter,
+} from "@assistant-ui/react";
+import type {
+ AppendMessage,
CompleteAttachment,
- TextContentPart,
- ThreadUserContentPart
+ ExternalStoreThreadData,
+ ExternalStoreThreadListAdapter,
} from "@assistant-ui/react";
-import { Thread } from "./assistant-ui/thread";
-import { ThreadList } from "./assistant-ui/thread-list";
+import { Thread } from "components/assistant-ui/thread";
+import { ThreadList } from "components/assistant-ui/thread-list";
import {
useChatContext,
- RegularThreadData,
- ArchivedThreadData
+ RegularThreadData,
} from "./context/ChatContext";
import { MessageHandler, ChatMessage, ChatCoreProps } from "../types/chatTypes";
import { trans } from "i18n";
import { universalAttachmentAdapter } from "../utils/attachmentAdapter";
+import {
+ createAssistantErrorMessage,
+ createUserMessage,
+ generateThreadTitle,
+ getTextFromAppendMessage,
+ getTextFromThreadContent,
+ shouldGenerateThreadTitle,
+ toChatMessage,
+} from "../utils/assistantMessages";
import { StyledChatContainer } from "./ChatContainerStyles";
// ============================================================================
// CHAT CONTAINER
// ============================================================================
-const generateId = () => Math.random().toString(36).substr(2, 9);
-
function ChatContainerView(props: ChatCoreProps) {
const { state, actions } = useChatContext();
const [isRunning, setIsRunning] = useState(false);
@@ -52,32 +59,33 @@ function ChatContainerView(props: ChatCoreProps) {
onEventRef.current?.("componentLoad");
}, []);
- const convertMessage = (message: ChatMessage): ThreadMessageLike => {
- const content: ThreadUserContentPart[] = [{ type: "text", text: message.text }];
-
- if (message.attachments && message.attachments.length > 0) {
- for (const attachment of message.attachments) {
- if (attachment.content) {
- content.push(...attachment.content);
- }
- }
+ const convertMessage = (message: ChatMessage): ThreadMessageLike => message;
+
+ const maybeGenerateThreadTitle = async (userMessage: ChatMessage) => {
+ const currentThread = state.threadList.find(
+ (thread) => thread.threadId === state.currentThreadId
+ );
+ const defaultTitle = trans("chat.newChatTitle");
+
+ if (
+ !shouldGenerateThreadTitle(
+ currentThread?.title,
+ defaultTitle,
+ currentMessages.length
+ )
+ ) {
+ return;
}
-
- return {
- role: message.role,
- content,
- id: message.id,
- createdAt: new Date(message.timestamp),
- ...(message.attachments && message.attachments.length > 0 && { attachments: message.attachments }),
- };
+
+ const title = generateThreadTitle(userMessage);
+ if (!title || title === currentThread?.title) return;
+
+ await actions.updateThread(state.currentThreadId, { title });
+ props.onEvent?.("threadUpdated");
};
const onNew = async (message: AppendMessage) => {
- const textPart = (message.content as ThreadUserContentPart[]).find(
- (part): part is TextContentPart => part.type === "text"
- );
-
- const text = textPart?.text?.trim() ?? "";
+ const text = getTextFromAppendMessage(message);
const completeAttachments = (message.attachments ?? []).filter(
(att): att is CompleteAttachment => att.status.type === "complete"
);
@@ -86,47 +94,29 @@ function ChatContainerView(props: ChatCoreProps) {
throw new Error("Cannot send an empty message");
}
- const userMessage: ChatMessage = {
- id: generateId(),
- role: "user",
- text,
- timestamp: Date.now(),
- attachments: completeAttachments,
- };
+ const userMessage = createUserMessage(text, completeAttachments);
await actions.addMessage(state.currentThreadId, userMessage);
setIsRunning(true);
try {
- const response = await props.messageHandler.sendMessage(userMessage);
- props.onMessageUpdate?.(userMessage.text);
-
- const assistantMessage: ChatMessage = {
- id: generateId(),
- role: "assistant",
- text: response.content,
- timestamp: Date.now(),
- };
+ const assistantMessage = await props.messageHandler.sendMessage(userMessage);
+ props.onMessageUpdate?.(getTextFromThreadContent(userMessage.content));
await actions.addMessage(state.currentThreadId, assistantMessage);
+ await maybeGenerateThreadTitle(userMessage);
} catch (error) {
- await actions.addMessage(state.currentThreadId, {
- id: generateId(),
- role: "assistant",
- text: trans("chat.errorUnknown"),
- timestamp: Date.now(),
- });
+ await actions.addMessage(
+ state.currentThreadId,
+ createAssistantErrorMessage(trans("chat.errorUnknown"))
+ );
} finally {
setIsRunning(false);
}
};
const onEdit = async (message: AppendMessage) => {
- const textPart = (message.content as ThreadUserContentPart[]).find(
- (part): part is TextContentPart => part.type === "text"
- );
-
- const text = textPart?.text?.trim() ?? "";
+ const text = getTextFromAppendMessage(message);
const completeAttachments = (message.attachments ?? []).filter(
(att): att is CompleteAttachment => att.status.type === "complete"
);
@@ -138,48 +128,39 @@ function ChatContainerView(props: ChatCoreProps) {
const index = currentMessages.findIndex((m) => m.id === message.parentId) + 1;
const newMessages = [...currentMessages.slice(0, index)];
- const editedMessage: ChatMessage = {
- id: generateId(),
- role: "user",
- text,
- timestamp: Date.now(),
- attachments: completeAttachments,
- };
+ const editedMessage = createUserMessage(text, completeAttachments);
newMessages.push(editedMessage);
await actions.updateMessages(state.currentThreadId, newMessages);
setIsRunning(true);
try {
- const response = await props.messageHandler.sendMessage(editedMessage);
- props.onMessageUpdate?.(editedMessage.text);
-
- const assistantMessage: ChatMessage = {
- id: generateId(),
- role: "assistant",
- text: response.content,
- timestamp: Date.now(),
- };
+ const assistantMessage = await props.messageHandler.sendMessage(editedMessage);
+ props.onMessageUpdate?.(getTextFromThreadContent(editedMessage.content));
newMessages.push(assistantMessage);
await actions.updateMessages(state.currentThreadId, newMessages);
} catch (error) {
- newMessages.push({
- id: generateId(),
- role: "assistant",
- text: trans("chat.errorUnknown"),
- timestamp: Date.now(),
- });
+ newMessages.push(createAssistantErrorMessage(trans("chat.errorUnknown")));
await actions.updateMessages(state.currentThreadId, newMessages);
} finally {
setIsRunning(false);
}
};
+ const toExternalThreadData = (
+ thread: RegularThreadData,
+ ): ExternalStoreThreadData<"regular"> => ({
+ id: thread.threadId,
+ status: "regular",
+ title: thread.title,
+ });
+
const threadListAdapter: ExternalStoreThreadListAdapter = {
threadId: state.currentThreadId,
- threads: state.threadList.filter((t): t is RegularThreadData => t.status === "regular"),
- archivedThreads: state.threadList.filter((t): t is ArchivedThreadData => t.status === "archived"),
+ threads: state.threadList
+ .filter((t): t is RegularThreadData => t.status === "regular")
+ .map(toExternalThreadData),
onSwitchToNewThread: async () => {
const threadId = await actions.createThread(trans("chat.newChatTitle"));
@@ -196,11 +177,6 @@ function ChatContainerView(props: ChatCoreProps) {
props.onEvent?.("threadUpdated");
},
- onArchive: async (threadId) => {
- await actions.updateThread(threadId, { status: "archived" });
- props.onEvent?.("threadUpdated");
- },
-
onDelete: async (threadId) => {
await actions.deleteThread(threadId);
props.onEvent?.("threadDeleted");
@@ -209,7 +185,11 @@ function ChatContainerView(props: ChatCoreProps) {
const runtime = useExternalStoreRuntime({
messages: currentMessages,
- setMessages: (messages) => actions.updateMessages(state.currentThreadId, messages),
+ setMessages: (messages) =>
+ actions.updateMessages(
+ state.currentThreadId,
+ messages.map(toChatMessage)
+ ),
convertMessage,
isRunning,
onNew,
@@ -239,7 +219,7 @@ function ChatContainerView(props: ChatCoreProps) {
$animationStyle={props.animationStyle}
>
-
+
);
diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainerStyles.ts b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainerStyles.ts
index 1f2d4580db..37275f232e 100644
--- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainerStyles.ts
+++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainerStyles.ts
@@ -18,8 +18,11 @@ export interface StyledChatContainerProps {
export const StyledChatContainer = styled.div`
display: flex;
+ align-items: stretch;
height: ${(props) => (props.$autoHeight ? "auto" : "100%")};
min-height: ${(props) => (props.$autoHeight ? "300px" : "unset")};
+ min-width: 0;
+ overflow: hidden;
/* Main container styles */
background: ${(props) => props.style?.background || "transparent"};
@@ -40,9 +43,12 @@ export const StyledChatContainer = styled.div`
/* Sidebar Styles */
.aui-thread-list-root {
+ align-self: stretch;
width: ${(props) => props.$sidebarWidth || "250px"};
background-color: ${(props) => props.$sidebarStyle?.sidebarBackground || "#fff"};
padding: 10px;
+ min-height: 0;
+ overflow-y: auto;
}
.aui-thread-list-item-title {
@@ -51,15 +57,36 @@ export const StyledChatContainer = styled.div`
/* Messages Window Styles */
.aui-thread-root {
- flex: 1;
+ flex: 1 1 auto;
+ align-self: stretch;
+ min-width: 0;
+ min-height: 0;
background-color: ${(props) => props.$messagesStyle?.messagesBackground || "#f9fafb"};
- height: auto;
+ height: ${(props) => (props.$autoHeight ? "auto" : "100%")};
+ overflow: hidden;
+ }
+
+ .aui-thread-viewport {
+ flex: 1 1 auto;
+ min-height: 0;
}
/* User Message Styles */
.aui-user-message-content {
- background-color: ${(props) => props.$messagesStyle?.userMessageBackground || "#3b82f6"};
- color: ${(props) => props.$messagesStyle?.userMessageText || "#ffffff"};
+ background-color: ${(props) => props.$messagesStyle?.userMessageBackground || "#e5e7eb"};
+ color: ${(props) => props.$messagesStyle?.userMessageText || "#111827"};
+ }
+
+ .aui-user-message-content:empty {
+ display: none;
+ }
+
+ .aui-user-message-content img {
+ display: block;
+ height: auto;
+ max-height: 320px;
+ max-width: 100%;
+ object-fit: contain;
}
/* Assistant Message Styles */
@@ -69,11 +96,9 @@ export const StyledChatContainer = styled.div`
}
/* Input Field Styles */
- form.aui-composer-root {
- background-color: ${(props) => props.$inputStyle?.inputBackground || "#ffffff"};
- color: ${(props) => props.$inputStyle?.inputText || "inherit"};
- border-color: ${(props) => props.$inputStyle?.inputBorder || "#d1d5db"};
- }
+
+
+
/* Send Button Styles */
.aui-composer-send {
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 f4823011e6..e7e70f8b17 100644
--- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx
+++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx
@@ -1,46 +1,46 @@
// 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).
// ============================================================================
-export function ChatPanel({
- tableName,
- modelHost,
- systemPrompt = trans("chat.defaultSystemPrompt"),
- streaming = true,
- onMessageUpdate
-}: ChatPanelProps) {
+export function ChatPanel({
+ tableName,
+ chatQuery,
+ onMessageUpdate,
+}: ChatPanelProps) {
+ 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]
- );
-
- // Cleanup on unmount - delete chat data from storage
- useEffect(() => {
- return () => {
- storage.cleanup();
- };
- }, [storage]);
+ const messageHandler = useMemo(
+ () =>
+ new AIAssistantQueryHandler({
+ chatQuery,
+ dispatch: editorState?.rootComp?.dispatch,
+ getEditorState: () => editorStateRef.current,
+ }),
+ [chatQuery, editorState?.rootComp?.dispatch]
+ );
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 9f0766cea4..2dd09e3510 100644
--- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanelContainer.tsx
+++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanelContainer.tsx
@@ -4,31 +4,117 @@ import React, { useState, useEffect, useRef, useContext } from "react";
import {
useExternalStoreRuntime,
ThreadMessageLike,
- AppendMessage,
AssistantRuntimeProvider,
+} from "@assistant-ui/react";
+import type {
+ AppendMessage,
+ ExternalStoreThreadData,
ExternalStoreThreadListAdapter,
- TextContentPart,
- ThreadUserContentPart
} from "@assistant-ui/react";
-import { Thread } from "./assistant-ui/thread";
-import { ThreadList } from "./assistant-ui/thread-list";
+import { Thread } from "components/assistant-ui/thread";
+import { ThreadList } from "components/assistant-ui/thread-list";
import {
ChatProvider,
useChatContext,
- RegularThreadData,
- ArchivedThreadData
+ RegularThreadData,
} 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";
+import {
+ createAssistantErrorMessage,
+ createUserMessage,
+ generateThreadTitle,
+ getAutomatorActionsFromMessage,
+ getTextFromAppendMessage,
+ getTextFromThreadContent,
+ shouldGenerateThreadTitle,
+ toChatMessage,
+} from "../utils/assistantMessages";
-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";
+import { deleteQueryAction } from "../../preLoadComp/actions/queryManagement";
+
+// ============================================================================
+// 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,
+ delete_query: deleteQueryAction,
+ 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 "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
@@ -41,6 +127,8 @@ const StyledChatContainer = styled.div<{
display: flex;
height: ${(props) => (props.autoHeight ? "auto" : "100%")};
min-height: ${(props) => (props.autoHeight ? "300px" : "unset")};
+ min-width: 0;
+ overflow: hidden;
p {
margin: 0;
@@ -50,12 +138,21 @@ const StyledChatContainer = styled.div<{
width: ${(props) => props.sidebarWidth || "250px"};
background-color: #fff;
padding: 10px;
+ min-height: 0;
+ overflow-y: auto;
}
.aui-thread-root {
- flex: 1;
+ flex: 1 1 auto;
+ min-width: 0;
+ min-height: 0;
background-color: #f9fafb;
- height: auto;
+ height: 100%;
+ overflow: hidden;
+ }
+
+ .aui-thread-viewport {
+ min-height: 0;
}
.aui-thread-list-item {
@@ -73,11 +170,9 @@ const StyledChatContainer = styled.div<{
// CHAT PANEL CONTAINER - DIRECT RENDERING
// ============================================================================
-const generateId = () => Math.random().toString(36).substr(2, 9);
-
export interface ChatPanelContainerProps {
storage: any;
- messageHandler: MessageHandler;
+ messageHandler: AIAssistantMessageHandler;
placeholder?: string;
onMessageUpdate?: (message: string) => void;
}
@@ -98,205 +193,100 @@ 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;
+ }
+ 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(resolve => setTimeout(resolve, 1000));
+ await new Promise((r) => setTimeout(r, 500));
}
+
+ console.log(`[Automator] done: ${executed}/${actions.length} succeeded`);
};
- const convertMessage = (message: ChatMessage): ThreadMessageLike => {
- const content: ThreadUserContentPart[] = [{ type: "text", text: message.text }];
-
- return {
- role: message.role,
- content,
- id: message.id,
- createdAt: new Date(message.timestamp),
- };
+ const convertMessage = (message: ChatMessage): ThreadMessageLike => message;
+
+ const maybeGenerateThreadTitle = async (userMessage: ChatMessage) => {
+ const currentThread = state.threadList.find(
+ (thread) => thread.threadId === state.currentThreadId
+ );
+ const defaultTitle = trans("chat.newChatTitle");
+
+ if (
+ !shouldGenerateThreadTitle(
+ currentThread?.title,
+ defaultTitle,
+ currentMessages.length
+ )
+ ) {
+ return;
+ }
+
+ const title = generateThreadTitle(userMessage);
+ if (!title || title === currentThread?.title) return;
+
+ await actions.updateThread(state.currentThreadId, { title });
};
const onNew = async (message: AppendMessage) => {
- const textPart = (message.content as ThreadUserContentPart[]).find(
- (part): part is TextContentPart => part.type === "text"
- );
-
- const text = textPart?.text?.trim() ?? "";
+ const text = getTextFromAppendMessage(message);
if (!text) {
throw new Error("Cannot send an empty message");
}
- const userMessage: ChatMessage = {
- id: generateId(),
- role: "user",
- text,
- timestamp: Date.now(),
- };
+ const userMessage = createUserMessage(text);
+
+ const conversationHistory = [...currentMessages, userMessage];
await actions.addMessage(state.currentThreadId, userMessage);
setIsRunning(true);
try {
- const response = await messageHandler.sendMessage(userMessage, state.currentThreadId);
- onMessageUpdate?.(userMessage.text);
-
- if (response?.actions?.length) {
- performAction(response.actions);
+ const assistantMessage = await messageHandler.sendMessage(
+ userMessage,
+ state.currentThreadId,
+ conversationHistory
+ );
+ onMessageUpdate?.(getTextFromThreadContent(userMessage.content));
+
+ const automatorActions = getAutomatorActionsFromMessage(assistantMessage);
+ if (automatorActions.length) {
+ await performAction(automatorActions);
}
- await actions.addMessage(state.currentThreadId, {
- id: generateId(),
- role: "assistant",
- text: response.content,
- timestamp: Date.now(),
- });
+ await actions.addMessage(
+ state.currentThreadId,
+ assistantMessage
+ );
+ await maybeGenerateThreadTitle(userMessage);
} catch (error) {
- await actions.addMessage(state.currentThreadId, {
- id: generateId(),
- role: "assistant",
- text: trans("chat.errorUnknown"),
- timestamp: Date.now(),
- });
+ await actions.addMessage(
+ state.currentThreadId,
+ createAssistantErrorMessage(trans("chat.errorUnknown"))
+ );
} finally {
setIsRunning(false);
}
};
const onEdit = async (message: AppendMessage) => {
- const textPart = (message.content as ThreadUserContentPart[]).find(
- (part): part is TextContentPart => part.type === "text"
- );
-
- const text = textPart?.text?.trim() ?? "";
+ const text = getTextFromAppendMessage(message);
if (!text) {
throw new Error("Cannot send an empty message");
@@ -305,44 +295,47 @@ function ChatPanelView({ messageHandler, placeholder, onMessageUpdate }: Omit m.id === message.parentId) + 1;
const newMessages = [...currentMessages.slice(0, index)];
- newMessages.push({
- id: generateId(),
- role: "user",
- text,
- timestamp: Date.now(),
- });
+ newMessages.push(createUserMessage(text));
await actions.updateMessages(state.currentThreadId, newMessages);
setIsRunning(true);
try {
- const response = await messageHandler.sendMessage(newMessages[newMessages.length - 1]);
+ const assistantMessage = await messageHandler.sendMessage(
+ newMessages[newMessages.length - 1],
+ state.currentThreadId,
+ newMessages
+ );
onMessageUpdate?.(text);
-
- newMessages.push({
- id: generateId(),
- role: "assistant",
- text: response.content,
- timestamp: Date.now(),
- });
+
+ const automatorActions = getAutomatorActionsFromMessage(assistantMessage);
+ if (automatorActions.length) {
+ await performAction(automatorActions);
+ }
+
+ newMessages.push(assistantMessage);
await actions.updateMessages(state.currentThreadId, newMessages);
} catch (error) {
- newMessages.push({
- id: generateId(),
- role: "assistant",
- text: trans("chat.errorUnknown"),
- timestamp: Date.now(),
- });
+ newMessages.push(createAssistantErrorMessage(trans("chat.errorUnknown")));
await actions.updateMessages(state.currentThreadId, newMessages);
} finally {
setIsRunning(false);
}
};
+ const toExternalThreadData = (
+ thread: RegularThreadData,
+ ): ExternalStoreThreadData<"regular"> => ({
+ id: thread.threadId,
+ status: "regular",
+ title: thread.title,
+ });
+
const threadListAdapter: ExternalStoreThreadListAdapter = {
threadId: state.currentThreadId,
- threads: state.threadList.filter((t): t is RegularThreadData => t.status === "regular"),
- archivedThreads: state.threadList.filter((t): t is ArchivedThreadData => t.status === "archived"),
+ threads: state.threadList
+ .filter((t): t is RegularThreadData => t.status === "regular")
+ .map(toExternalThreadData),
onSwitchToNewThread: async () => {
const threadId = await actions.createThread(trans("chat.newChatTitle"));
@@ -357,10 +350,6 @@ function ChatPanelView({ messageHandler, placeholder, onMessageUpdate }: Omit {
- await actions.updateThread(threadId, { status: "archived" });
- },
-
onDelete: async (threadId) => {
await actions.deleteThread(threadId);
},
@@ -368,7 +357,11 @@ function ChatPanelView({ messageHandler, placeholder, onMessageUpdate }: Omit actions.updateMessages(state.currentThreadId, messages),
+ setMessages: (messages) =>
+ actions.updateMessages(
+ state.currentThreadId,
+ messages.map(toChatMessage)
+ ),
convertMessage,
isRunning,
onNew,
diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/thread-list.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/thread-list.tsx
deleted file mode 100644
index 46bf98eed4..0000000000
--- a/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/thread-list.tsx
+++ /dev/null
@@ -1,146 +0,0 @@
-import type { FC } from "react";
-import { useState } from "react";
-import {
- ThreadListItemPrimitive,
- ThreadListPrimitive,
- useThreadListItem,
-} from "@assistant-ui/react";
-import { PencilIcon, PlusIcon, Trash2Icon } from "lucide-react";
-import { TooltipIconButton } from "./tooltip-icon-button";
-import { useThreadListItemRuntime } from "@assistant-ui/react";
-import { Button, Flex, Input } from "antd";
-import { trans } from "i18n";
-
-import styled from "styled-components";
-
-const StyledPrimaryButton = styled(Button)`
- // padding: 20px;
- // margin-bottom: 20px;
-`;
-
-
-export const ThreadList: FC = () => {
- return (
-
-
-
-
-
-
- );
-};
-
-const ThreadListNew: FC = () => {
- return (
-
- }>
- {trans("chat.newThread")}
-
-
- );
-};
-
-const ThreadListItems: FC = () => {
- return ;
-};
-
-const ThreadListItem: FC = () => {
- const [editing, setEditing] = useState(false);
-
- return (
-
-
- {editing ? (
- setEditing(false)}
- />
- ) : (
-
- )}
-
- setEditing(true)}
- editing={editing}
- />
-
-
- );
-};
-
-const ThreadListItemTitle: FC = () => {
- return (
-
-
-
- );
-};
-
-const ThreadListItemDelete: FC = () => {
- return (
-
-
-
-
-
- );
-};
-
-
-
-const ThreadListItemEditInput: FC<{ onFinish: () => void }> = ({ onFinish }) => {
- const threadItem = useThreadListItem();
- const threadRuntime = useThreadListItemRuntime();
-
- const currentTitle = threadItem?.title || trans("chat.newChatTitle");
-
- const handleRename = async (newTitle: string) => {
- if (!newTitle.trim() || newTitle === currentTitle){
- onFinish();
- return;
- }
-
- try {
- await threadRuntime.rename(newTitle);
- onFinish();
- } catch (error) {
- console.error("Failed to rename thread:", error);
- }
- };
-
- return (
- handleRename(e.target.value)}
- onPressEnter={(e) => handleRename((e.target as HTMLInputElement).value)}
- onKeyDown={(e) => {
- if (e.key === 'Escape') onFinish();
- }}
- autoFocus
- style={{ fontSize: '14px', padding: '2px 8px' }}
- />
- );
-};
-
-
-const ThreadListItemRename: FC<{ onStartEdit: () => void; editing: boolean }> = ({
- onStartEdit,
- editing
-}) => {
- if (editing) return null;
-
- return (
-
-
-
- );
-};
-
diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/thread.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/thread.tsx
deleted file mode 100644
index a45e5fe147..0000000000
--- a/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/thread.tsx
+++ /dev/null
@@ -1,336 +0,0 @@
-import {
- ActionBarPrimitive,
- BranchPickerPrimitive,
- ComposerPrimitive,
- MessagePrimitive,
- ThreadPrimitive,
- } from "@assistant-ui/react";
- import { useMemo, type FC } from "react";
- import { trans } from "i18n";
- import {
- ArrowDownIcon,
- CheckIcon,
- ChevronLeftIcon,
- ChevronRightIcon,
- CopyIcon,
- PencilIcon,
- SendHorizontalIcon,
- } from "lucide-react";
- import { cn } from "../../utils/cn";
-
- import { Button } from "../ui/button";
- import { MarkdownText } from "./markdown-text";
- import { TooltipIconButton } from "./tooltip-icon-button";
- import { Spin, Flex } from "antd";
- import { LoadingOutlined } from "@ant-design/icons";
- import styled from "styled-components";
-import { ComposerAddAttachment, ComposerAttachments, UserMessageAttachments } from "../ui/attachment";
- const SimpleANTDLoader = () => {
- const antIcon = ;
-
- return (
-
-
-
- Working on it...
-
-
- );
- };
-
- const StyledThreadRoot = styled(ThreadPrimitive.Root)`
- /* Hide entire assistant message container when it contains running status */
- .aui-assistant-message-root:has([data-status="running"]) {
- display: none;
- }
-
- /* Fallback for older browsers that don't support :has() */
- .aui-assistant-message-content [data-status="running"] {
- display: none;
- }
-`;
-
-
- interface ThreadProps {
- placeholder?: string;
- showAttachments?: boolean;
- }
-
- export const Thread: FC = ({
- placeholder = trans("chat.composerPlaceholder"),
- showAttachments = true
- }) => {
- // Stable component reference so React doesn't unmount/remount on every render
- const UserMessageComponent = useMemo(() => {
- const Wrapper: FC = () => ;
- Wrapper.displayName = "UserMessage";
- return Wrapper;
- }, [showAttachments]);
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- };
-
- const ThreadScrollToBottom: FC = () => {
- return (
-
-
-
-
-
- );
- };
-
- const ThreadWelcome: FC = () => {
- return (
-
-
-
-
- {trans("chat.welcomeMessage")}
-
-
-
-
-
- );
- };
-
- const ThreadWelcomeSuggestions: FC = () => {
- return (
-
-
-
- {trans("chat.suggestionWeather")}
-
-
-
-
- {trans("chat.suggestionAssistant")}
-
-
-
- );
- };
-
- const Composer: FC<{ placeholder?: string; showAttachments?: boolean }> = ({
- placeholder = trans("chat.composerPlaceholder"),
- showAttachments = true
- }) => {
- return (
-
- {showAttachments && (
- <>
-
-
- >
- )}
-
-
-
- );
- };
-
- const ComposerAction: FC = () => {
- return (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- );
- };
-
- const UserMessage: FC<{ showAttachments?: boolean }> = ({ showAttachments = true }) => {
- return (
-
-
- {showAttachments && }
-
-
-
-
-
-
-
- );
- };
-
- const UserActionBar: FC = () => {
- return (
-
-
-
-
-
-
-
- );
- };
-
- const EditComposer: FC = () => {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- );
- };
-
- const AssistantMessage: FC = () => {
- return (
-
-
-
-
-
-
-
-
-
- );
- };
-
- const AssistantActionBar: FC = () => {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- );
- };
-
- const BranchPicker: FC = ({
- className,
- ...rest
- }) => {
- return (
-
-
-
-
-
-
-
- /
-
-
-
-
-
-
-
- );
- };
-
- const CircleStopIcon = () => {
- return (
-
- );
- };
\ No newline at end of file
diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/tooltip-icon-button.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/tooltip-icon-button.tsx
deleted file mode 100644
index d2434babff..0000000000
--- a/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/tooltip-icon-button.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { ComponentPropsWithoutRef, forwardRef } from "react";
-
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "../ui/tooltip";
-import { Button } from "../ui/button";
-import { cn } from "../../utils/cn";
-
-export type TooltipIconButtonProps = ComponentPropsWithoutRef & {
- tooltip: string;
- side?: "top" | "bottom" | "left" | "right";
-};
-
-export const TooltipIconButton = forwardRef<
- HTMLButtonElement,
- TooltipIconButtonProps
->(({ children, tooltip, side = "bottom", className, ...rest }, ref) => {
- return (
-
-
-
-
-
- {tooltip}
-
-
- );
-});
-
-TooltipIconButton.displayName = "TooltipIconButton";
\ No newline at end of file
diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/button.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/button.tsx
deleted file mode 100644
index 945783c696..0000000000
--- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/button.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import * as React from "react";
-import { Slot } from "@radix-ui/react-slot";
-import { cva, type VariantProps } from "class-variance-authority";
-import { cn } from "../../utils/cn";
-
-const buttonVariants = cva("aui-button", {
- variants: {
- variant: {
- default: "aui-button-primary",
- outline: "aui-button-outline",
- ghost: "aui-button-ghost",
- },
- size: {
- default: "aui-button-medium",
- icon: "aui-button-icon",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "default",
- },
-});
-
-const Button = React.forwardRef<
- HTMLButtonElement,
- React.ComponentProps<"button"> &
- VariantProps & {
- asChild?: boolean;
- }
->(({ className, variant, size, asChild = false, ...props }, ref) => {
- const Comp = asChild ? Slot : "button";
-
- return (
-
- );
-});
-
-Button.displayName = "Button";
-
-export { Button, buttonVariants };
\ No newline at end of file
diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/handlers/messageHandlers.ts b/client/packages/lowcoder/src/comps/comps/chatComp/handlers/messageHandlers.ts
index 3ea69fafd9..8978d0d2f5 100644
--- a/client/packages/lowcoder/src/comps/comps/chatComp/handlers/messageHandlers.ts
+++ b/client/packages/lowcoder/src/comps/comps/chatComp/handlers/messageHandlers.ts
@@ -1,88 +1,64 @@
// client/packages/lowcoder/src/comps/comps/chatComp/handlers/messageHandlers.ts
-import { MessageHandler, MessageResponse, N8NHandlerConfig, QueryHandlerConfig, ChatMessage } from "../types/chatTypes";
-import { CompAction, routeByNameAction, executeQueryAction } from "lowcoder-core";
-import { getPromiseAfterDispatch } from "util/promiseUtils";
+import { AIAssistantMessageHandler, MessageHandler, QueryHandlerConfig, ChatMessage } from "../types/chatTypes";
+import { routeByNameAction, executeQueryAction } from "lowcoder-core";
+import { getPromiseAfterDispatch } from "util/promiseUtils";
+import { buildAutomatorPayload } from "../../preLoadComp/actions/automator";
+import {
+ getTextFromThreadContent,
+ toAssistantMessage,
+} from "../utils/assistantMessages";
+
+function buildAutomatorQueryArgs(
+ payload: ReturnType
+) {
+ const ai = {
+ mode: "automator" as const,
+ ...payload,
+ };
+
+ return {
+ ai: {
+ value: ai,
+ },
+ };
+}
// ============================================================================
-// N8N HANDLER (for Bottom Panel)
-// ============================================================================
-
-export class N8NHandler implements MessageHandler {
- constructor(private config: N8NHandlerConfig) {}
-
- async sendMessage(message: ChatMessage, sessionId?: string): Promise {
- const { modelHost, systemPrompt, streaming } = this.config;
-
- if (!modelHost) {
- throw new Error("Model host is required for N8N calls");
- }
-
- 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'}`);
- }
- }
-}
-
-// ============================================================================
-// QUERY HANDLER (for Canvas Components)
+// QUERY HANDLER
// ============================================================================
export class QueryHandler implements MessageHandler {
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
- if (!chatQuery || !dispatch) {
- await new Promise((res) => setTimeout(res, 500));
- return { content: "(mock) You typed: " + message.text };
- }
+ async sendMessage(message: ChatMessage): Promise {
+ const { chatQuery, dispatch} = this.config;
+
+ if (!chatQuery) {
+ throw new Error("Select a query before sending a message");
+ }
+
+ if (!dispatch) {
+ throw new Error("Query dispatch is unavailable");
+ }
try {
+ 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
- },
+ // Pass the full message object so attachments are available in queries
+ args: {
+ message: { value: message },
+ prompt: { value: getTextFromThreadContent(message.content) },
+ },
})
)
);
-
- return result.message
+ console.log("Query result:", result);
+ return toAssistantMessage(result);
} catch (e: any) {
throw new Error(e?.message || "Query execution failed");
}
@@ -90,37 +66,89 @@ export class QueryHandler implements MessageHandler {
}
// ============================================================================
-// MOCK HANDLER (for testing/fallbacks)
-// ============================================================================
-
-export class MockHandler implements MessageHandler {
- constructor(private delay: number = 1000) {}
+// AI ASSISTANT QUERY HANDLER (bottom panel)
+// ----------------------------------------------------------------------------
+// This handler owns the Lowcoder side of the Automator flow:
+// 1. snapshot the current editor state,
+// 2. build the system prompt, tools, catalogs, and live context,
+// 3. pass that payload to the selected user query,
+// 4. accept an Assistant UI `ThreadMessageLike` assistant message.
+//
+// Provider-specific parsing belongs in the selected query/backend bridge.
+// ============================================================================
+
+export class AIAssistantQueryHandler implements AIAssistantMessageHandler {
+ constructor(private config: QueryHandlerConfig) {}
+
+ async sendMessage(
+ _message: ChatMessage,
+ _sessionId: string | undefined,
+ conversationHistory: ChatMessage[]
+ ): Promise {
+ const { chatQuery, dispatch, getEditorState } = this.config;
+ const history = conversationHistory;
+
+ // Conversation history in the OpenAI {role, content} shape.
+ const rawHistory = history.map((msg) => ({
+ role: msg.role,
+ content: getTextFromThreadContent(msg.content),
+ }));
+
+ if (!chatQuery) {
+ throw new Error("Select an Automator query before sending a message");
+ }
+
+ if (!dispatch) {
+ throw new Error("Automator dispatch is unavailable");
+ }
+
+ if (!getEditorState) {
+ throw new Error("Automator editor state is unavailable");
+ }
+
+ const editorState = getEditorState();
+ const payload = buildAutomatorPayload({
+ history: rawHistory,
+ editorState,
+ });
+
+ try {
+ console.log("[Automator] running query:", chatQuery, {
+ contextComponents: payload.context.components.length,
+ contextQueries: payload.context.queries.length,
+ messageCount: payload.messages.length,
+ });
- async sendMessage(message: ChatMessage): Promise {
- await new Promise(resolve => setTimeout(resolve, this.delay));
- return { content: `Mock response: ${message.text}` };
+ const result: any = await getPromiseAfterDispatch(
+ dispatch,
+ routeByNameAction(
+ chatQuery,
+ executeQueryAction({
+ args: buildAutomatorQueryArgs(payload),
+ })
+ )
+ );
+
+ return toAssistantMessage(result);
+ } catch (e: any) {
+ throw new Error(e?.message || "AI assistant query execution failed");
+ }
}
}
-// ============================================================================
-// HANDLER FACTORY (creates the right handler based on type)
-// ============================================================================
-
-export function createMessageHandler(
- type: "n8n" | "query" | "mock",
- config: N8NHandlerConfig | QueryHandlerConfig
-): MessageHandler {
- switch (type) {
- case "n8n":
- return new N8NHandler(config as N8NHandlerConfig);
-
- case "query":
- return new QueryHandler(config as QueryHandlerConfig);
-
- case "mock":
- return new MockHandler();
-
- default:
- throw new Error(`Unknown message handler type: ${type}`);
- }
-}
\ No newline at end of file
+// ============================================================================
+// HANDLER FACTORY (creates the right handler based on type)
+// ============================================================================
+
+export function createMessageHandler(
+ type: "query",
+ config: QueryHandlerConfig
+): MessageHandler {
+ switch (type) {
+ case "query":
+ return new QueryHandler(config);
+
+ default:
+ throw new Error(`Unknown message handler type: ${type}`);
+ }
+}
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 d24e0ce84f..0a8035a30c 100644
--- a/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts
+++ b/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts
@@ -1,12 +1,17 @@
-import { CompleteAttachment } from "@assistant-ui/react";
-
-export interface ChatMessage {
- id: string;
- role: "user" | "assistant";
- text: string;
- timestamp: number;
- attachments?: CompleteAttachment[];
- }
+import type { ThreadMessageLike } from "@assistant-ui/react";
+
+export type ChatMessageContent = Exclude;
+
+export type ChatMessage = Omit<
+ ThreadMessageLike,
+ "id" | "role" | "content" | "createdAt" | "attachments"
+> & {
+ id: string;
+ role: "user" | "assistant";
+ content: ChatMessageContent;
+ createdAt: Date;
+ attachments?: ThreadMessageLike["attachments"];
+ };
export interface ChatThread {
threadId: string;
@@ -39,31 +44,28 @@ export interface ChatMessage {
// MESSAGE HANDLER INTERFACE (new clean abstraction)
// ============================================================================
- export interface MessageHandler {
- sendMessage(message: ChatMessage, sessionId?: string): Promise;
- // Future: sendMessageStream?(message: ChatMessage): AsyncGenerator;
- }
-
- export interface MessageResponse {
- content: string;
- metadata?: any;
- actions?: any[];
- }
+ export interface MessageHandler {
+ sendMessage(message: ChatMessage, sessionId?: string): Promise;
+ // Future: sendMessageStream?(message: ChatMessage): AsyncGenerator;
+ }
+
+ export interface AIAssistantMessageHandler {
+ sendMessage(message: ChatMessage, sessionId: string | undefined, conversationHistory: ChatMessage[]): Promise;
+ }
// ============================================================================
// 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.
+ */
+ getEditorState?: () => any;
+ }
// ============================================================================
// COMPONENT PROPS (what each component actually needs)
@@ -93,8 +95,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/chatComp/utils/assistantMessages.ts b/client/packages/lowcoder/src/comps/comps/chatComp/utils/assistantMessages.ts
new file mode 100644
index 0000000000..c5c6c9353e
--- /dev/null
+++ b/client/packages/lowcoder/src/comps/comps/chatComp/utils/assistantMessages.ts
@@ -0,0 +1,118 @@
+import type {
+ AppendMessage,
+ CompleteAttachment,
+ TextMessagePart,
+ ThreadAssistantMessagePart,
+ ThreadMessageLike,
+ ThreadUserMessagePart,
+} from "@assistant-ui/react";
+
+import type { ChatMessage, ChatMessageContent } from "../types/chatTypes";
+
+export const generateMessageId = () => Math.random().toString(36).substr(2, 9);
+
+export const getTextFromThreadContent = (
+ content: ThreadMessageLike["content"]
+) => {
+ if (typeof content === "string") return content;
+
+ return content
+ .filter((part) => part.type === "text")
+ .map((part) => part.text)
+ .join("\n")
+ .trim();
+};
+
+export const generateThreadTitle = (message: ChatMessage) => {
+ const text = getTextFromThreadContent(message.content)
+ .replace(/\s+/g, " ")
+ .trim();
+
+ if (!text) return "";
+ if (text.length <= 50) return text;
+
+ const clipped = text.slice(0, 50).replace(/\s+\S*$/, "").trim();
+ return `${clipped || text.slice(0, 50).trim()}...`;
+};
+
+export const shouldGenerateThreadTitle = (
+ existingTitle: string | undefined,
+ defaultTitle: string,
+ existingMessageCount: number
+) => {
+ return (
+ existingMessageCount === 0 &&
+ (!existingTitle || existingTitle.trim() === defaultTitle.trim())
+ );
+};
+
+export const getTextFromAppendMessage = (message: AppendMessage) => {
+ const textPart = message.content.find(
+ (part): part is TextMessagePart => part.type === "text"
+ );
+ return textPart?.text?.trim() ?? "";
+};
+
+export const createUserMessage = (
+ text: string,
+ attachments: CompleteAttachment[] = []
+): ChatMessage => {
+ const content: ThreadUserMessagePart[] = text
+ ? [{ type: "text", text }]
+ : [];
+
+ return {
+ id: generateMessageId(),
+ role: "user",
+ content,
+ createdAt: new Date(),
+ ...(attachments.length && { attachments }),
+ };
+};
+
+export const createAssistantErrorMessage = (text: string): ChatMessage => ({
+ id: generateMessageId(),
+ role: "assistant",
+ content: [{ type: "text", text }],
+ createdAt: new Date(),
+});
+
+export const toChatMessage = (message: ThreadMessageLike): ChatMessage => {
+ if (message.role === "system") {
+ throw new Error("System messages are not stored in chat threads");
+ }
+
+ const content =
+ typeof message.content === "string"
+ ? ([{ type: "text", text: message.content }] as ChatMessageContent)
+ : (message.content as ChatMessageContent);
+
+ return {
+ ...message,
+ id: message.id ?? generateMessageId(),
+ role: message.role,
+ content,
+ createdAt: message.createdAt ?? new Date(),
+ };
+};
+
+export const toAssistantMessage = (message: ThreadMessageLike): ChatMessage => {
+ const chatMessage = toChatMessage(message);
+ if (chatMessage.role !== "assistant") {
+ throw new Error("Query must return an assistant message");
+ }
+ return chatMessage;
+};
+
+export const getAutomatorActionsFromMessage = (message: ChatMessage) => {
+ const toolPart = message.content.find(
+ (part): part is Extract =>
+ part.type === "tool-call" &&
+ part.toolName === "execute_automator_actions"
+ );
+
+ if (!toolPart) return [];
+
+ const resultActions = (toolPart.result as any)?.actions;
+ return Array.isArray(resultActions) ? resultActions : [];
+};
diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/utils/attachmentAdapter.ts b/client/packages/lowcoder/src/comps/comps/chatComp/utils/attachmentAdapter.ts
index 9ff22d4364..3e4c23e1c8 100644
--- a/client/packages/lowcoder/src/comps/comps/chatComp/utils/attachmentAdapter.ts
+++ b/client/packages/lowcoder/src/comps/comps/chatComp/utils/attachmentAdapter.ts
@@ -3,14 +3,14 @@ import type {
PendingAttachment,
CompleteAttachment,
Attachment,
- ThreadUserContentPart
+ ThreadUserMessagePart
} from "@assistant-ui/react";
import { messageInstance } from "lowcoder-design/src/components/GlobalInstances";
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
export const universalAttachmentAdapter: AttachmentAdapter = {
- accept: "*/*",
+ accept: "*",
async add({ file }): Promise {
if (file.size > MAX_FILE_SIZE) {
@@ -38,7 +38,7 @@ import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"
async send(attachment: PendingAttachment): Promise {
const isImage = attachment.contentType?.startsWith("image/");
- let content: ThreadUserContentPart[];
+ let content: ThreadUserMessagePart[];
try {
content = isImage
@@ -93,4 +93,4 @@ import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"
function getAttachmentType(mime: string): "image" | "file" {
return mime.startsWith("image/") ? "image" : "file";
}
-
\ No newline at end of file
+
diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/utils/storageFactory.ts b/client/packages/lowcoder/src/comps/comps/chatComp/utils/storageFactory.ts
index c641dbbefc..ae2a033586 100644
--- a/client/packages/lowcoder/src/comps/comps/chatComp/utils/storageFactory.ts
+++ b/client/packages/lowcoder/src/comps/comps/chatComp/utils/storageFactory.ts
@@ -1,7 +1,8 @@
// client/packages/lowcoder/src/comps/comps/chatComp/utils/storageFactory.ts
-import alasql from "alasql";
-import { ChatMessage, ChatThread, ChatStorage } from "../types/chatTypes";
+import alasql from "alasql";
+import { ChatMessage, ChatThread, ChatStorage } from "../types/chatTypes";
+import { getTextFromThreadContent } from "./assistantMessages";
// ============================================================================
// CLEAN STORAGE FACTORY (simplified from your existing implementation)
@@ -32,15 +33,22 @@ export function createChatStorage(tableName: string): ChatStorage {
// Create messages table
await alasql.promise(`
- CREATE TABLE IF NOT EXISTS ${messagesTable} (
- id STRING PRIMARY KEY,
- threadId STRING,
- role STRING,
- text STRING,
- timestamp NUMBER,
- attachments STRING
- )
- `);
+ CREATE TABLE IF NOT EXISTS ${messagesTable} (
+ id STRING PRIMARY KEY,
+ threadId STRING,
+ role STRING,
+ text STRING,
+ timestamp NUMBER,
+ attachments STRING,
+ content STRING
+ )
+ `);
+
+ try {
+ await alasql.promise(`ALTER TABLE ${messagesTable} ADD COLUMN content STRING`);
+ } catch (error) {
+ // Existing databases may already have the AUI content column.
+ }
} catch (error) {
console.error(`Failed to initialize chat database ${dbName}:`, error);
@@ -104,9 +112,18 @@ export function createChatStorage(tableName: string): ChatStorage {
// Insert or replace message
await alasql.promise(`DELETE FROM ${messagesTable} WHERE id = ?`, [message.id]);
- await alasql.promise(`
- INSERT INTO ${messagesTable} VALUES (?, ?, ?, ?, ?, ?)
- `, [message.id, threadId, message.role, message.text, message.timestamp, JSON.stringify(message.attachments || [])]);
+ await alasql.promise(`
+ INSERT INTO ${messagesTable} (id, threadId, role, text, timestamp, attachments, content)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ `, [
+ message.id,
+ threadId,
+ message.role,
+ getTextFromThreadContent(message.content),
+ message.createdAt.getTime(),
+ JSON.stringify(message.attachments || []),
+ JSON.stringify(message.content),
+ ]);
} catch (error) {
console.error("Failed to save message:", error);
throw error;
@@ -120,9 +137,18 @@ export function createChatStorage(tableName: string): ChatStorage {
// Insert all messages
for (const message of messages) {
- await alasql.promise(`
- INSERT INTO ${messagesTable} VALUES (?, ?, ?, ?, ?, ?)
- `, [message.id, threadId, message.role, message.text, message.timestamp, JSON.stringify(message.attachments || [])]);
+ await alasql.promise(`
+ INSERT INTO ${messagesTable} (id, threadId, role, text, timestamp, attachments, content)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ `, [
+ message.id,
+ threadId,
+ message.role,
+ getTextFromThreadContent(message.content),
+ message.createdAt.getTime(),
+ JSON.stringify(message.attachments || []),
+ JSON.stringify(message.content),
+ ]);
}
} catch (error) {
console.error("Failed to save messages:", error);
@@ -132,18 +158,18 @@ export function createChatStorage(tableName: string): ChatStorage {
async getMessages(threadId: string) {
try {
- const result = await alasql.promise(`
- SELECT id, role, text, timestamp, attachments FROM ${messagesTable}
- WHERE threadId = ? ORDER BY timestamp ASC
- `, [threadId]) as any[];
-
- return result.map(row => ({
- id: row.id,
- role: row.role,
- text: row.text,
- timestamp: row.timestamp,
- attachments: JSON.parse(row.attachments || '[]')
- })) as ChatMessage[];
+ const result = await alasql.promise(`
+ SELECT id, role, text, timestamp, attachments, content FROM ${messagesTable}
+ WHERE threadId = ? ORDER BY timestamp ASC
+ `, [threadId]) as any[];
+
+ return result.map(row => ({
+ id: row.id,
+ role: row.role,
+ content: JSON.parse(row.content || "null") || [{ type: "text", text: row.text || "" }],
+ createdAt: new Date(row.timestamp),
+ attachments: JSON.parse(row.attachments || '[]')
+ })) as ChatMessage[];
} catch (error) {
console.error("Failed to get messages:", error);
return [];
@@ -190,4 +216,4 @@ export function createChatStorage(tableName: string): ChatStorage {
}
}
};
-}
\ No newline at end of file
+}
diff --git a/client/packages/lowcoder/src/comps/comps/dateComp/dateComp.tsx b/client/packages/lowcoder/src/comps/comps/dateComp/dateComp.tsx
index c6aae7ad24..e26bf4ab69 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 5a7e188c8c..9fcbc09b77 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/jsonComp/jsonEditorComp.tsx b/client/packages/lowcoder/src/comps/comps/jsonComp/jsonEditorComp.tsx
index 4738291683..8b1d4c8400 100644
--- a/client/packages/lowcoder/src/comps/comps/jsonComp/jsonEditorComp.tsx
+++ b/client/packages/lowcoder/src/comps/comps/jsonComp/jsonEditorComp.tsx
@@ -184,7 +184,17 @@ let JsonEditorTmpComp = (function () {
return (
<>
- {children.value.propertyView({ label: trans("export.jsonEditorDesc") })}
+ {children.value.propertyView({
+ label: trans("export.jsonEditorDesc"),
+ enableAIHelp: true,
+ aiHelp: {
+ targetKind: "json",
+ label: "JSON Editor value",
+ fieldName: "value",
+ fieldDescription:
+ "JSON value edited by the JSON Editor component. Generate valid JSON that can be an object, array, string, number, boolean, or null.",
+ },
+ })}
diff --git a/client/packages/lowcoder/src/comps/comps/jsonComp/jsonExplorerComp.tsx b/client/packages/lowcoder/src/comps/comps/jsonComp/jsonExplorerComp.tsx
index d1c6b917cb..f929dd68ce 100644
--- a/client/packages/lowcoder/src/comps/comps/jsonComp/jsonExplorerComp.tsx
+++ b/client/packages/lowcoder/src/comps/comps/jsonComp/jsonExplorerComp.tsx
@@ -83,7 +83,17 @@ let JsonExplorerTmpComp = (function () {
return (
<>
- {children.value.propertyView({ label: trans("export.jsonEditorDesc") })}
+ {children.value.propertyView({
+ label: trans("export.jsonEditorDesc"),
+ enableAIHelp: true,
+ aiHelp: {
+ targetKind: "json",
+ label: "JSON Explorer value",
+ fieldName: "value",
+ fieldDescription:
+ "JSON object or array displayed by the JSON Explorer component. Generate valid JSON that is useful to inspect as a nested data structure.",
+ },
+ })}
{(useContext(EditorContext).editorModeStatus === "logic" || useContext(EditorContext).editorModeStatus === "both") && (
diff --git a/client/packages/lowcoder/src/comps/comps/jsonSchemaFormComp/jsonSchemaFormComp.tsx b/client/packages/lowcoder/src/comps/comps/jsonSchemaFormComp/jsonSchemaFormComp.tsx
index 0705a745b6..d5c956b85c 100644
--- a/client/packages/lowcoder/src/comps/comps/jsonSchemaFormComp/jsonSchemaFormComp.tsx
+++ b/client/packages/lowcoder/src/comps/comps/jsonSchemaFormComp/jsonSchemaFormComp.tsx
@@ -374,6 +374,10 @@ let FormBasicComp = (function () {
})
.setPropertyViewFn((children) => {
const formType = children.formType.getView();
+ const uiSchemaFieldDescription =
+ formType === "rjsf"
+ ? "RJSF UI schema object for configuring widgets, placeholders, field order, submit button options, and other react-jsonschema-form UI behavior. It should match the JSON Schema fields."
+ : "JSONForms UI schema object for configuring controls, scopes, layouts, categories, rules, and renderer options. It should match the JSON Schema fields.";
return (
<>
{(useContext(EditorContext).editorModeStatus === "logic" ||
@@ -431,6 +435,14 @@ let FormBasicComp = (function () {
>
),
+ enableAIHelp: true,
+ aiHelp: {
+ targetKind: "json",
+ label: "JSON Schema Form schema",
+ fieldName: "schema",
+ fieldDescription:
+ "JSON Schema object that defines the form fields, field types, required fields, validation constraints, enum options, titles, and descriptions.",
+ },
})}
{children.uiSchema.propertyView({
key: trans("jsonSchemaForm.uiSchema"),
@@ -471,10 +483,25 @@ let FormBasicComp = (function () {
>
),
+ enableAIHelp: true,
+ aiHelp: {
+ targetKind: "json",
+ label: `${formType === "rjsf" ? "RJSF" : "JSONForms"} UI schema`,
+ fieldName: "uiSchema",
+ fieldDescription: uiSchemaFieldDescription,
+ },
})}
{children.data.propertyView({
key: trans("jsonSchemaForm.defaultData"),
label: trans("jsonSchemaForm.defaultData"),
+ enableAIHelp: true,
+ aiHelp: {
+ targetKind: "json",
+ label: "JSON Schema Form default data",
+ fieldName: "data",
+ fieldDescription:
+ "Default form data object. Generate sample initial values that match the current JSON Schema shape, field names, and value types.",
+ },
})}
{children.formType.getView() === "rjsf" && (
children.errorSchema.propertyView({
diff --git a/client/packages/lowcoder/src/comps/comps/layout/mobileTabLayout.tsx b/client/packages/lowcoder/src/comps/comps/layout/mobileTabLayout.tsx
index 8ae653ffa4..bd4016c167 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 4a7e2b355f..66f23635c9 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') && (