From ae736ed57203086bd1a46579ae4a23ffbc28d841 Mon Sep 17 00:00:00 2001 From: weareoutman Date: Wed, 31 Jul 2024 18:13:20 +0800 Subject: [PATCH 1/3] feat(chat-agent): support low level send request --- bricks/ai/src/chat-agent/index.tsx | 170 ++++++++++++++++++++++++----- 1 file changed, 144 insertions(+), 26 deletions(-) diff --git a/bricks/ai/src/chat-agent/index.tsx b/bricks/ai/src/chat-agent/index.tsx index 2002cf676..af2d0c37e 100644 --- a/bricks/ai/src/chat-agent/index.tsx +++ b/bricks/ai/src/chat-agent/index.tsx @@ -17,7 +17,9 @@ const { defineElement, property, event, method } = createDecorators(); export interface ChatAgentProps { agentId?: string; + robotId?: string; conversationId?: string; + alwaysUseNewConversation?: boolean; } export interface Message extends BaseMessage { @@ -38,6 +40,17 @@ export interface MessageChunk { partial?: boolean; } +export interface LowLevelMessageChunk { + choices: LowLevelChoice[]; +} + +export interface LowLevelChoice { + delta: { + role: "assistant"; + content?: string; + }; +} + export const ChatAgentComponent = forwardRef(LegacyChatAgentComponent); /** @@ -51,17 +64,49 @@ class ChatAgent extends ReactNextElement implements ChatAgentProps { @property() accessor agentId: string | undefined; + @property() + accessor robotId: string | undefined; + @property() accessor conversationId: string | undefined; + @property() + accessor alwaysUseNewConversation: boolean | undefined; + + /** + * 发送消息到默认的聊天 API + */ @method() postMessage(content: string) { return this.#ref.current?.postMessage(content); } + /** + * 发送聊天请求到指定的 URL + */ @method() - sendRequest(content: string, url: string, options: Options) { - return this.#ref.current?.sendRequest(content, url, options); + sendRequest( + leadingMessages: string | BaseMessage[], + url: string, + options: Options + ) { + return this.#ref.current?.sendRequest(leadingMessages, url, options); + } + + /** + * 发送底层聊天请求到指定的 URL。接口的请求和响应的数据结构和 OpenAI 聊天接口一致。 + */ + @method() + lowLevelSendRequest( + leadingMessages: string | BaseMessage[], + url: string, + options: Options + ) { + return this.#ref.current?.lowLevelSendRequest( + leadingMessages, + url, + options + ); } @method() @@ -104,7 +149,9 @@ class ChatAgent extends ReactNextElement implements ChatAgentProps { ; sendRequest( - content: string, + leadingMessages: string | BaseMessage[], + url: string, + options: Options + ): Promise; + lowLevelSendRequest( + leadingMessages: string | BaseMessage[], url: string, options: Options ): Promise; @@ -134,7 +186,9 @@ export interface ChatAgentRef { export function LegacyChatAgentComponent( { agentId, + robotId, conversationId: propConversationId, + alwaysUseNewConversation, onMessageChunkPush, onMessagesUpdate, onBusyChange, @@ -181,12 +235,20 @@ export function LegacyChatAgentComponent( [onMessageChunkPush] ); - const sendRequest = useCallback( - async (content: string, url: string, options: Options) => { + const legacySendRequest = useCallback( + async ( + isLowLevel: boolean, + leadingMessages: string | BaseMessage[], + url: string, + options: Options + ) => { // Use ref instead of state to handle sync sequential calls. if (busyRef.current) { return null; } + if (alwaysUseNewConversation || isLowLevel) { + setFullMessages((prev) => (prev.length === 0 ? prev : [])); + } const thisChatId = chatIdRef.current; let newConversationError: Error | undefined; const checkNewConversation = async () => { @@ -201,19 +263,35 @@ export function LegacyChatAgentComponent( const userKey = getMessageChunkKey(); const assistantKey = getMessageChunkKey(); - let currentConversationId = conversationId; + let currentConversationId = + alwaysUseNewConversation || isLowLevel ? null : conversationId; onBusyChange?.((busyRef.current = true)); try { - pushPartialMessage?.({ - key: userKey, - delta: { - content: content, - role: "user", - }, - }); - const request = createSSEStream( + if (Array.isArray(leadingMessages)) { + for (const msg of leadingMessages) { + const isAssistant = msg.role === "assistant"; + if (isAssistant || msg.role === "user") { + pushPartialMessage?.({ + key: isAssistant ? assistantKey : userKey, + delta: { + role: msg.role, + content: msg.content, + }, + }); + } + } + } else { + pushPartialMessage?.({ + key: userKey, + delta: { + content: leadingMessages, + role: "user", + }, + }); + } + const request = createSSEStream( new URL(url, `${location.origin}${getBasePath()}`).toString(), options ); @@ -246,13 +324,34 @@ export function LegacyChatAgentComponent( await checkNewConversation(); - pushPartialMessage?.({ - delta: value.delta, - key: assistantKey, - partial: true, - }); - if (value.conversationId && !currentConversationId) { - setConversationId((currentConversationId = value.conversationId)); + if (isLowLevel) { + const delta = (value as LowLevelMessageChunk).choices?.[0]?.delta; + if (delta?.content) { + pushPartialMessage({ + delta: { + role: delta.role, + content: delta.content, + }, + key: assistantKey, + partial: true, + }); + } + } else { + pushPartialMessage?.({ + delta: (value as MessageChunk).delta, + key: assistantKey, + partial: true, + }); + if ( + !alwaysUseNewConversation && + (value as MessageChunk).conversationId && + !currentConversationId + ) { + setConversationId( + (currentConversationId = (value as MessageChunk) + .conversationId!) + ); + } } } @@ -304,24 +403,36 @@ export function LegacyChatAgentComponent( return currentConversationId; }, - [conversationId, getMessageChunkKey, onBusyChange, pushPartialMessage] + [ + conversationId, + alwaysUseNewConversation, + getMessageChunkKey, + onBusyChange, + pushPartialMessage, + ] ); useImperativeHandle( ref, () => ({ - sendRequest, + lowLevelSendRequest: (...args) => legacySendRequest(true, ...args), + sendRequest: (...args) => legacySendRequest(false, ...args), postMessage(content: string) { - return sendRequest( + return legacySendRequest( + false, content, "api/gateway/easyops.api.aiops_chat.manage.LLMChatProxy@1.0.0/api/aiops_chat/v1/chat/completions", { method: "POST", body: JSON.stringify({ agentId, + robotId, input: content, stream: true, - conversationId, + conversationId: + alwaysUseNewConversation || conversationId === null + ? undefined + : conversationId, }), headers: { "giraffe-contract-name": @@ -339,7 +450,14 @@ export function LegacyChatAgentComponent( } }, }), - [agentId, conversationId, onBusyChange, sendRequest] + [ + legacySendRequest, + agentId, + robotId, + alwaysUseNewConversation, + conversationId, + onBusyChange, + ] ); useEffect(() => { From 6b12d6c4d9a5e31fe19f67a7b64de6c7e0b91d0b Mon Sep 17 00:00:00 2001 From: weareoutman Date: Wed, 31 Jul 2024 18:18:51 +0800 Subject: [PATCH 2/3] feat(): new provider: batch-update-raw-data-generated-view --- ...tch-update-raw-data-generated-view.spec.ts | 58 +++++++++++++++++++ .../batch-update-raw-data-generated-view.ts | 29 ++++++++++ 2 files changed, 87 insertions(+) create mode 100644 bricks/visual-builder/src/data-providers/batch-update-raw-data-generated-view.spec.ts create mode 100644 bricks/visual-builder/src/data-providers/batch-update-raw-data-generated-view.ts diff --git a/bricks/visual-builder/src/data-providers/batch-update-raw-data-generated-view.spec.ts b/bricks/visual-builder/src/data-providers/batch-update-raw-data-generated-view.spec.ts new file mode 100644 index 000000000..50040b5e5 --- /dev/null +++ b/bricks/visual-builder/src/data-providers/batch-update-raw-data-generated-view.spec.ts @@ -0,0 +1,58 @@ +import { describe, test, expect } from "@jest/globals"; +import { InstanceApi_createInstance } from "@next-api-sdk/cmdb-sdk"; +import { batchUpdateRawDataGeneratedView } from "./batch-update-raw-data-generated-view.js"; + +jest.mock("@next-api-sdk/cmdb-sdk"); + +describe("batchUpdateRawDataGeneratedView", () => { + test("should work", async () => { + const result = await batchUpdateRawDataGeneratedView([ + { + attrInstanceId: "i-1", + input: "input-1", + output: "output-1", + list: [], + }, + { + attrInstanceId: "i-2", + input: "input-2", + output: "output-2", + list: [], + }, + ]); + + expect(result).toMatchInlineSnapshot(` + [ + { + "status": "fulfilled", + "value": undefined, + }, + { + "status": "fulfilled", + "value": undefined, + }, + ] + `); + expect(InstanceApi_createInstance).toBeCalledTimes(2); + expect(InstanceApi_createInstance).toHaveBeenNthCalledWith( + 1, + "RAW_DATA_GENERATED_VIEW@EASYOPS", + { + input: "input-1", + output: "output-1", + list: [], + attr: ["i-1"], + } + ); + expect(InstanceApi_createInstance).toHaveBeenNthCalledWith( + 2, + "RAW_DATA_GENERATED_VIEW@EASYOPS", + { + input: "input-2", + output: "output-2", + list: [], + attr: ["i-2"], + } + ); + }); +}); diff --git a/bricks/visual-builder/src/data-providers/batch-update-raw-data-generated-view.ts b/bricks/visual-builder/src/data-providers/batch-update-raw-data-generated-view.ts new file mode 100644 index 000000000..8952b3b32 --- /dev/null +++ b/bricks/visual-builder/src/data-providers/batch-update-raw-data-generated-view.ts @@ -0,0 +1,29 @@ +import { createProviderClass } from "@next-core/utils/general"; +import { InstanceApi_createInstance } from "@next-api-sdk/cmdb-sdk"; + +export interface GeneratedView { + attrInstanceId: string; + input: string; + output: string; + list: unknown[]; + defaultVisualWeight?: number; + systemPromptVersion?: string; +} + +export async function batchUpdateRawDataGeneratedView( + generations: GeneratedView[] +): Promise { + return Promise.allSettled( + generations.map(({ attrInstanceId, ...props }) => + InstanceApi_createInstance("RAW_DATA_GENERATED_VIEW@EASYOPS", { + ...props, + attr: [attrInstanceId], + }) + ) + ); +} + +customElements.define( + "visual-builder.batch-update-raw-data-generated-view", + createProviderClass(batchUpdateRawDataGeneratedView) +); From 099f34b7e457a932dd9ed2220661323dbce26489 Mon Sep 17 00:00:00 2001 From: weareoutman Date: Wed, 31 Jul 2024 18:19:49 +0800 Subject: [PATCH 3/3] feat(): experimental: raw-data-preview --- bricks/visual-builder/src/bootstrap.ts | 2 + .../src/pre-generated-preview/index.tsx | 93 ++- .../pre-generated-preview/preview.shadow.css | 18 +- .../src/raw-data-preview/convert.ts | 200 +++++++ .../src/raw-data-preview/index.spec.tsx | 26 + .../src/raw-data-preview/index.tsx | 557 ++++++++++++++++++ .../src/raw-data-preview/preview.shadow.css | 42 ++ .../raw-data-preview/raw-data-interfaces.ts | 141 +++++ .../src/raw-data-preview/styles.shadow.css | 32 + bricks/visual-builder/test.config.js | 2 + 10 files changed, 1089 insertions(+), 24 deletions(-) create mode 100644 bricks/visual-builder/src/raw-data-preview/convert.ts create mode 100644 bricks/visual-builder/src/raw-data-preview/index.spec.tsx create mode 100644 bricks/visual-builder/src/raw-data-preview/index.tsx create mode 100644 bricks/visual-builder/src/raw-data-preview/preview.shadow.css create mode 100644 bricks/visual-builder/src/raw-data-preview/raw-data-interfaces.ts create mode 100644 bricks/visual-builder/src/raw-data-preview/styles.shadow.css diff --git a/bricks/visual-builder/src/bootstrap.ts b/bricks/visual-builder/src/bootstrap.ts index 8260d8011..4483a9e47 100644 --- a/bricks/visual-builder/src/bootstrap.ts +++ b/bricks/visual-builder/src/bootstrap.ts @@ -18,4 +18,6 @@ import "./property-editor/index.js"; import "./data-providers/check-editor-by-name.js"; import "./data-providers/get-data-dependency.js"; import "./data-providers/get-dependency-tree.js"; +import "./raw-data-preview/index.js"; import "./data-providers/parse-path.js"; +import "./data-providers/batch-update-raw-data-generated-view.js"; diff --git a/bricks/visual-builder/src/pre-generated-preview/index.tsx b/bricks/visual-builder/src/pre-generated-preview/index.tsx index 4cbcc6f47..1a87eb551 100644 --- a/bricks/visual-builder/src/pre-generated-preview/index.tsx +++ b/bricks/visual-builder/src/pre-generated-preview/index.tsx @@ -7,6 +7,7 @@ import classNames from "classnames"; import { __secret_internals, getBasePath } from "@next-core/runtime"; import type { PreviewWindow } from "@next-core/preview/types"; import { JSON_SCHEMA, safeDump } from "js-yaml"; +import { isObject } from "@next-core/utils/general"; import styleText from "./styles.shadow.css"; import previewStyleText from "./preview.shadow.css"; @@ -233,7 +234,9 @@ export function PreGeneratedPreviewComponent({ brick: "div", properties: { textContent: propertyName, - className: isLastProperty ? "last-row-cell" : undefined, + className: classNames("body-cell", { + "last-row-cell": isLastProperty, + }), style: { gridRow: `span ${candidatesByReadWriteType.size}`, }, @@ -249,12 +252,13 @@ export function PreGeneratedPreviewComponent({ brick: "div", properties: { textContent: rwType, - className: isLastRow ? "last-row-cell" : undefined, + className: classNames("body-cell", { "last-row-cell": isLastRow }), }, }); for (let i = -2; i < 3; i++) { const candidate = candidates.get(i); + const candidateCategory = candidate?.category ?? category; let dataSource: unknown; if (candidate?.mockData?.length) { @@ -262,8 +266,10 @@ export function PreGeneratedPreviewComponent({ candidate.mockData[ Math.floor(Math.random() * candidate.mockData.length) ]; - switch (candidate.category ?? category) { + switch (candidateCategory) { case "detail-item": + case "form-item": + case "card-item": dataSource = { [propertyId]: mockValue, }; @@ -275,31 +281,66 @@ export function PreGeneratedPreviewComponent({ } } - const classNames: string[] = []; - - if (i === 2) { - classNames.push("last-col-cell"); - } - if (isLastRow) { - classNames.push("last-row-cell"); + const candidateChildren = [] + .concat(candidate?.storyboard ?? []) + .filter((brick) => { + if (!isObject(brick)) { + // eslint-disable-next-line no-console + console.error("Unexpected type of storyboard:", typeof brick); + return false; + } + return true; + }); + candidateChildren.forEach(fixBrickConf); + + let container: BrickConf; + switch (candidateCategory) { + case "form-item": + container = { + brick: "eo-form", + properties: { + layout: "inline", + values: dataSource, + className: "form-container", + }, + children: candidateChildren.map((child) => ({ + ...child, + errorBoundary: true, + })), + }; + break; + case "card-item": + container = { + brick: ":forEach", + dataSource: [dataSource], + children: candidateChildren.map((child) => ({ + ...child, + errorBoundary: true, + })), + }; + break; + default: + container = { + brick: "visual-builder.pre-generated-container", + properties: { + useBrick: candidateChildren, + dataSource, + }, + errorBoundary: true, + }; } tableChildren.push({ brick: "div", - ...(classNames - ? { - properties: { - className: classNames.join(" "), - }, - } - : null), + properties: { + className: classNames("body-cell", { + "last-col-cell": i === 2, + "last-row-cell": isLastRow, + }), + }, children: [ { - brick: "visual-builder.pre-generated-container", - properties: { - useBrick: candidate?.storyboard ?? [], - dataSource, - }, + ...container, errorBoundary: true, }, ], @@ -543,3 +584,11 @@ export function PreGeneratedPreviewComponent({ ); } + +function fixBrickConf(brick: BrickConf) { + if (brick.properties?.prefix) { + // eslint-disable-next-line no-console + console.error("Unexpected readonly property of 'prefix' in:", brick); + delete brick.properties.prefix; + } +} diff --git a/bricks/visual-builder/src/pre-generated-preview/preview.shadow.css b/bricks/visual-builder/src/pre-generated-preview/preview.shadow.css index 8763f71d3..4b5d285ef 100644 --- a/bricks/visual-builder/src/pre-generated-preview/preview.shadow.css +++ b/bricks/visual-builder/src/pre-generated-preview/preview.shadow.css @@ -1,19 +1,29 @@ +body { + background-color: transparent; +} + #preview-root { padding: 0; - height: 100vh; + height: auto; + max-height: 100vh; overflow-y: auto; border: 1px solid var(--theme-gray-border-color); border-radius: 4px; + background: var(--body-background); } .head-cell { position: sticky; top: 0; - background: var(--body-background); z-index: 1; font-weight: bold; } +.head-cell, +.body-cell { + background: var(--body-background); +} + .last-col-cell { border-right-color: transparent; } @@ -21,3 +31,7 @@ .last-row-cell { border-bottom-color: transparent; } + +.form-container > ::part(message) { + display: none; +} diff --git a/bricks/visual-builder/src/raw-data-preview/convert.ts b/bricks/visual-builder/src/raw-data-preview/convert.ts new file mode 100644 index 000000000..6d5371d3f --- /dev/null +++ b/bricks/visual-builder/src/raw-data-preview/convert.ts @@ -0,0 +1,200 @@ +import type { BrickConf } from "@next-core/types"; +import { pick } from "lodash"; +import type { CSSProperties } from "react"; +import type { VisualConfig, VisualStyle } from "./raw-data-interfaces"; + +export function convertToStoryboard( + config: VisualConfig, + attr: string +): BrickConf | null { + const attrAccessor = `[${JSON.stringify(attr)}]`; + let brickItem: BrickConf; + + switch (config.display) { + case "link": + case "text": { + brickItem = getPlainBrick(config, attrAccessor); + if (config.type === "struct-list" && !config.countOnly) { + brickItem = { + brick: "eo-tag", + errorBoundary: true, + children: [brickItem], + }; + } else if (config.display === "link") { + brickItem = { + brick: "eo-link", + errorBoundary: true, + children: [brickItem], + }; + } + break; + } + case "tag": { + const colorSuffix = + config.style?.variant === "background" ? "-inverse" : ""; + const valueAccessor = getValueAccessor(config, attrAccessor); + brickItem = { + brick: "eo-tag", + errorBoundary: true, + properties: { + textContent: `<% ${valueAccessor} %>`, + size: getTagSize(config.style?.size), + color: config.style?.background + ? `${config.style.background}${colorSuffix}` + : config.style?.palette + ? `<% \`\${(${JSON.stringify(config.style.palette)})[${valueAccessor}] ?? "gray"}${colorSuffix}\` %>` + : `gray${colorSuffix}`, + outline: config.style?.variant === "outline", + }, + }; + break; + } + default: + return null; + } + + if (config.type !== "struct-list" || config.countOnly) { + return brickItem; + } + + const maxItems = Number(config.maxItems) || 3; + return { + brick: "span", + errorBoundary: true, + properties: { + style: { + display: "inline-flex", + gap: "0.5em", + }, + }, + children: [ + { + brick: ":forEach", + dataSource: `<% DATA${attrAccessor}.slice(0, ${maxItems}) %>`, + children: [brickItem], + }, + { + brick: "eo-link", + if: `<% DATA${attrAccessor}.length > ${maxItems} %>`, + properties: { + textContent: `<% \`+ \${DATA${attrAccessor}.length - ${maxItems}} 项\` %>`, + }, + }, + ], + }; +} + +function getPlainBrick(config: VisualConfig, attrAccessor: string): BrickConf { + if (config.type === "struct-list" && config.countOnly) { + return { + brick: "span", + properties: { + textContent: `<% \`\${DATA${attrAccessor}.length}\` %>`, + }, + }; + } + + const value = `<% ${getValueAccessor(config, attrAccessor)} %>`; + switch (config.formatter?.type) { + case "number": + return { + brick: "eo-formatter-number", + errorBoundary: true, + properties: { + value, + type: config.formatter.format, + ...pick(config.formatter, [ + "currency", + // "unit", + "originalUnit", + "decimals", + "thousandsSeparator", + ]), + }, + }; + case "date-time": + return { + brick: "eo-humanize-time", + errorBoundary: true, + properties: { + value, + type: config.type === "date" ? "date" : undefined, + formatter: config.formatter.format, + }, + }; + case "cost-time": + return { + brick: "eo-humanize-time", + errorBoundary: true, + properties: { + value, + isCostTime: true, + }, + }; + default: + return { + brick: "span", + errorBoundary: true, + properties: { + textContent: value, + style: getPlainStyle(config.style), + }, + }; + } +} + +function getValueAccessor(config: VisualConfig, attrAccessor: string): string { + if ( + (config.type === "struct" || config.type === "struct-list") && + config.field + ) { + return `${config.type === "struct" ? `DATA${attrAccessor}` : "ITEM"}[${JSON.stringify(config.field)}]`; + } + return `DATA${attrAccessor}`; +} + +function getTagSize(size: VisualStyle["size"]): string | undefined { + switch (size) { + case "large": + case "medium": + case "small": + return size; + case "x-large": + return "large"; + // case "x-small": + // return "xs"; + } +} + +function getPlainStyle( + configStyle: VisualStyle | undefined +): CSSProperties | undefined { + if (!configStyle) { + return; + } + const style: CSSProperties = {}; + switch (configStyle.size) { + // case "x-small": + case "small": + style.fontSize = "var(--sub-title-font-size-small)"; + break; + case "medium": + style.fontSize = "var(--normal-font-size)"; + break; + case "large": + style.fontSize = "var(--card-title-font-size)"; + break; + case "x-large": + style.fontSize = "var(--title-font-size-larger)"; + break; + } + switch (configStyle.fontWeight) { + case "bold": + case "normal": + style.fontWeight = configStyle.fontWeight; + } + if (configStyle.color) { + style.color = configStyle.color; + } + return style; +} diff --git a/bricks/visual-builder/src/raw-data-preview/index.spec.tsx b/bricks/visual-builder/src/raw-data-preview/index.spec.tsx new file mode 100644 index 000000000..62b93d1b6 --- /dev/null +++ b/bricks/visual-builder/src/raw-data-preview/index.spec.tsx @@ -0,0 +1,26 @@ +import { describe, test, expect, jest } from "@jest/globals"; +import { act } from "react-dom/test-utils"; +import "./"; +import type { RawDataPreview } from "./index.js"; + +jest.mock("@next-core/theme", () => ({})); + +describe("visual-builder.raw-data-preview", () => { + test("basic usage", async () => { + const element = document.createElement( + "visual-builder.raw-data-preview" + ) as RawDataPreview; + + expect(element.shadowRoot).toBeFalsy(); + + act(() => { + document.body.appendChild(element); + }); + expect(element.shadowRoot?.childNodes.length).toBeGreaterThan(1); + + act(() => { + document.body.removeChild(element); + }); + expect(element.shadowRoot?.childNodes.length).toBe(0); + }); +}); diff --git a/bricks/visual-builder/src/raw-data-preview/index.tsx b/bricks/visual-builder/src/raw-data-preview/index.tsx new file mode 100644 index 000000000..101ae3dd6 --- /dev/null +++ b/bricks/visual-builder/src/raw-data-preview/index.tsx @@ -0,0 +1,557 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { createDecorators, type EventEmitter } from "@next-core/element"; +import { ReactNextElement } from "@next-core/react-element"; +import "@next-core/theme"; +import type { BrickConf, ContextConf, MicroApp } from "@next-core/types"; +import classNames from "classnames"; +import { __secret_internals, getBasePath } from "@next-core/runtime"; +import type { PreviewWindow } from "@next-core/preview/types"; +import { JSON_SCHEMA, safeDump } from "js-yaml"; +import type { VisualConfig } from "./raw-data-interfaces"; +import { convertToStoryboard } from "./convert"; +import styleText from "./styles.shadow.css"; +import previewStyleText from "./preview.shadow.css"; + +const { defineElement, property, event } = createDecorators(); + +export interface RawPreviewProps { + previewUrl?: string; + generations?: AttributeGeneration[]; + busy?: boolean; + category?: PreviewCategory; + theme?: string; + uiVersion?: string; + app?: MicroApp; +} + +export interface AttributeGeneration { + generationId?: string; + objectId: string; + objectName: string; + propertyId: string; + propertyName: string; + propertyInstanceId?: string; + comment?: string; + candidates: VisualConfig[] | null; + mockData: Record[]; +} + +export interface CommentDetail { + comment: string; + propertyInstanceId?: string; +} + +interface BasePreviewMessage { + channel: "raw-data-preview"; +} + +interface CommentMessage extends BasePreviewMessage { + type: "comment"; + payload: CommentDetail; +} + +interface UpdatePropertyToggleStateMessage extends BasePreviewMessage { + type: "updatePropertyToggleState"; + payload: string[]; +} + +type PreviewMessage = CommentMessage | UpdatePropertyToggleStateMessage; + +export type PreviewCategory = + | "detail-item" + | "form-item" + | "table-column" + | "card-item" + | "metric-item" + | "value"; + +/** + * 构件 `visual-builder.raw-data-preview` + * + * @internal + */ +export +@defineElement("visual-builder.raw-data-preview", { + styleTexts: [styleText], +}) +class RawDataPreview extends ReactNextElement { + @property() + accessor previewUrl: string | undefined; + + @property({ attribute: false }) + accessor generations: AttributeGeneration[] | undefined; + + @property({ type: Boolean }) + accessor busy: boolean | undefined; + + /** + * @default "value" + */ + @property() + accessor category: PreviewCategory | undefined; + + @property() + accessor theme: string | undefined; + + @property() + accessor uiVersion: string | undefined; + + @property() + accessor app: MicroApp | undefined; + + @event({ type: "comment" }) + accessor #commentEvent: EventEmitter; + + #handleComment = (detail: CommentDetail) => { + this.#commentEvent.emit(detail); + }; + + render() { + return ( + + ); + } +} + +export interface RawDataPreviewComponentProps extends RawPreviewProps { + onComment: (detail: CommentDetail) => void; +} + +export function RawDataPreviewComponent({ + previewUrl, + generations, + busy, + category, + theme, + uiVersion, + app, + onComment, +}: RawDataPreviewComponentProps) { + const iframeRef = useRef(); + const [ready, setReady] = useState(false); + const [injected, setInjected] = useState(false); + const propertyToggleStateRef = useRef([]); + + const handleIframeLoad = useCallback(() => { + const check = () => { + const iframeWin = iframeRef.current?.contentWindow as PreviewWindow; + if (iframeWin?._preview_only_render) { + setReady(true); + } else { + setTimeout(check, 100); + } + }; + check(); + }, []); + + useEffect(() => { + if (ready) { + const iframeWin = iframeRef.current!.contentWindow as PreviewWindow; + iframeWin.postMessage( + { + channel: "raw-data-preview", + type: "busy", + payload: busy, + }, + location.origin + ); + } + }, [busy, ready]); + + useEffect(() => { + if (ready) { + const iframeWin = iframeRef.current!.contentWindow as PreviewWindow; + const onMessage = ({ data }: MessageEvent) => { + if (data?.channel === "raw-data-preview") { + switch (data.type) { + case "comment": + onComment(data.payload); + break; + case "updatePropertyToggleState": + propertyToggleStateRef.current = data.payload; + break; + } + } + }; + iframeWin.addEventListener("message", onMessage); + return () => { + iframeWin.removeEventListener("message", onMessage); + }; + } + }, [onComment, ready]); + + useEffect(() => { + if (!ready) { + return; + } + const pkg = __secret_internals.getBrickPackagesById( + "bricks/visual-builder" + ); + if (!pkg) { + throw new Error( + "Cannot find preview agent package: bricks/visual-builder" + ); + } + const inject = (iframeRef.current!.contentWindow as PreviewWindow)! + ._preview_only_inject; + + const fixedPkg = { + ...pkg, + filePath: previewUrl + ? new URL(pkg.filePath, new URL(previewUrl, location.origin)).toString() + : `${location.origin}${getBasePath()}${ + window.PUBLIC_ROOT ?? "" + }${pkg.filePath}`, + }; + + Promise.allSettled( + [ + "visual-builder.pre-generated-table-view", + "visual-builder.pre-generated-container", + ].map((brick) => inject(brick, fixedPkg, undefined, true)) + ).then(() => { + setInjected(true); + }); + }, [previewUrl, ready]); + + useEffect(() => { + if (!injected) { + return; + } + const render = (iframeRef.current?.contentWindow as PreviewWindow) + ?._preview_only_render; + if (!render) { + return; + } + + const tableChildren: BrickConf[] = [ + { + brick: "div", + properties: { + textContent: "属性", + className: "head-cell", + }, + }, + { + brick: "div", + properties: { + textContent: "", + className: "head-cell", + }, + }, + { + brick: "div", + properties: { + textContent: "视觉重量 (由低至高)", + className: "head-cell last-col-cell", + style: { + gridColumn: "span 4", + textAlign: "center", + }, + }, + }, + { + brick: "div", + properties: { + textContent: "批注", + className: "head-cell", + }, + }, + ]; + const table: BrickConf & { context?: ContextConf[] } = { + brick: "visual-builder.pre-generated-table-view", + context: [ + { + name: "propertyToggleState", + value: propertyToggleStateRef.current, + onChange: { + action: "window.postMessage", + args: [ + { + channel: "raw-data-preview", + type: "updatePropertyToggleState", + payload: "<% CTX.propertyToggleState %>", + }, + ], + }, + }, + { + name: "busy", + }, + ], + properties: { + style: { + gridTemplateColumns: "auto 32px repeat(5, 1fr)", + }, + }, + children: tableChildren, + }; + + for (let i = 0, size = generations.length; i < size; i++) { + const generation = generations[i]; + const isLastRow = i === size - 1; + + const candidatesByVisualWeight = new Map(); + for (const candidate of generation.candidates ?? []) { + candidatesByVisualWeight.set(candidate.visualWeight ?? 0, candidate); + } + + tableChildren.push( + { + brick: "div", + properties: { + // textContent: `${generation.propertyName ?? generation.propertyId}`, + className: classNames("body-cell", { + "last-row-cell": isLastRow, + }), + style: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + }, + }, + children: [ + { + brick: "span", + properties: { + textContent: `${generation.propertyName ?? generation.propertyId}`, + }, + }, + { + brick: "eo-button", + properties: { + type: "text", + icon: `<%= + { + lib: "fa", + prefix: "fas", + icon: CTX.propertyToggleState.includes(${JSON.stringify(generation.propertyId)}) ? "chevron-up" : "chevron-down", + } + %>`, + }, + events: { + click: { + action: "context.replace", + args: [ + "propertyToggleState", + `<% + CTX.propertyToggleState.includes(${JSON.stringify(generation.propertyId)}) + ? CTX.propertyToggleState.filter((id) => id !== ${JSON.stringify(generation.propertyId)}) + : CTX.propertyToggleState.concat(${JSON.stringify(generation.propertyId)}) + %>`, + ], + }, + }, + }, + ], + }, + { + brick: "div", + properties: { + className: classNames("body-cell", { + "last-row-cell": isLastRow, + }), + }, + children: generation.candidates?.length + ? [ + { + brick: "eo-icon", + properties: { + lib: "fa", + ...(generation.generationId + ? { + prefix: "far", + icon: "circle-check", + style: { + color: "var(--palette-green-6)", + }, + } + : { + prefix: "fas", + icon: "circle", + style: { + color: "var(--palette-gray-6)", + transformOrigin: "center center", + transform: "scale(0.5)", + }, + }), + }, + }, + ] + : undefined, + } + ); + + const mockList = (generation.mockData ?? []).slice(); + + mockList.sort((ma, mb) => { + const a = ma?.[generation.propertyId]; + const b = mb?.[generation.propertyId]; + const aIsArray = Array.isArray(a); + const bIsArray = Array.isArray(b); + if (aIsArray || bIsArray) { + return (bIsArray ? b.length : -1) - (aIsArray ? a.length : -1); + } + const aIsNil = a == null; + const bIsNil = b == null; + if (aIsNil || bIsNil) { + return (bIsNil ? 0 : 1) - (aIsNil ? 0 : 1); + } + + const aIsEmpty = typeof a === "string" && a.length === 0; + const bIsEmpty = typeof b === "string" && b.length === 0; + if (aIsEmpty || bIsEmpty) { + return (bIsEmpty ? 0 : 1) - (aIsEmpty ? 0 : 1); + } + return 0; + }); + + for (let i = -1; i < 3; i++) { + const candidate = candidatesByVisualWeight.get(i); + + let brick: BrickConf; + if (candidate) { + brick = convertToStoryboard(candidate, generation.propertyId); + } + + tableChildren.push({ + brick: "div", + properties: { + className: classNames("body-cell", { + "last-row-cell": isLastRow, + }), + }, + children: [ + { + brick: "div", + properties: { + style: { + display: "flex", + flexDirection: "column", + gap: "8px", + }, + }, + children: brick + ? mockList.map((dataSource, index) => ({ + brick: "visual-builder.pre-generated-container", + if: + index === 0 + ? true + : `<%= CTX.propertyToggleState.includes(${JSON.stringify(generation.propertyId)}) %>`, + properties: { + useBrick: [brick], + dataSource, + }, + errorBoundary: true, + })) + : undefined, + }, + ], + }); + } + + tableChildren.push({ + brick: "div", + properties: { + className: classNames("body-cell", { + "last-col-cell": true, + "last-row-cell": isLastRow, + }), + }, + children: generation.candidates + ? [ + { + brick: "eo-textarea", + properties: { + value: generation.comment + ? `<% ${JSON.stringify(generation.comment)} %>` + : undefined, + placeholder: + "不合预期?请补充说明 (按住 ⌘ 或 ctrl 并回车提交)", + autoSize: true, + style: { + width: "100%", + }, + disabled: "<%= CTX.busy %>", + }, + events: { + keydown: { + if: "<% EVENT.code === 'Enter' && (EVENT.metaKey || EVENT.ctrlKey) %>", + action: "window.postMessage", + args: [ + { + channel: "raw-data-preview", + type: "comment", + payload: { + comment: "<% EVENT.target.value %>", + propertyInstanceId: generation.propertyInstanceId, + }, + }, + ], + }, + }, + }, + ] + : undefined, + }); + } + + render( + "yaml", + { + yaml: safeDump( + [ + table, + { + brick: "eo-message-listener", + properties: { + sameOrigin: true, + }, + events: { + message: { + if: "<% EVENT.detail.data?.channel === 'raw-data-preview' && EVENT.detail.data.type === 'busy' %>", + action: "context.replace", + args: ["busy", "<% EVENT.detail.data.payload %>"], + }, + }, + portal: true, + errorBoundary: true, + }, + ], + { + schema: JSON_SCHEMA, + skipInvalid: true, + noRefs: true, + noCompatMode: true, + } + ), + }, + { + app, + theme, + uiVersion, + styleText: previewStyleText, + } + ); + }, [app, injected, generations, theme, uiVersion, category]); + + return ( +
+