feat: support config in embedding

This commit is contained in:
Joel 2026-04-16 18:08:30 +08:00
parent e999136e6f
commit b33270f825
8 changed files with 352 additions and 192 deletions

View File

@ -344,6 +344,7 @@ const AppPublisher = ({
onClose={() => setEmbeddingModalOpen(false)}
appBaseUrl={appBaseURL}
accessToken={accessToken}
hiddenInputs={hiddenLaunchVariables}
/>
{showAppAccessControl && <AccessControl app={appDetail!} onConfirm={handleAccessControlUpdate} onClose={() => { setShowAppAccessControl(false) }} />}
<WorkflowLaunchDialog

View File

@ -1,6 +1,6 @@
/* eslint-disable react-refresh/only-export-components */
import type { TFunction } from 'i18next'
import type { ChangeEvent, ComponentType, FormEvent, ReactNode } from 'react'
import type { ComponentType, FormEvent, ReactNode } from 'react'
import type {
OverviewOperationKey,
WorkflowHiddenStartVariable,
@ -12,9 +12,7 @@ import type { AppSSO } from '@/types/app'
import { RiArrowRightSLine, RiBookOpenLine, RiBuildingLine, RiEqualizer2Line, RiExternalLinkLine, RiGlobalLine, RiLockLine, RiPaintBrushLine, RiSettings2Line, RiVerifiedBadgeLine, RiWindowLine } from '@remixicon/react'
import CopyFeedback from '@/app/components/base/copy-feedback'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import ShareQRCode from '@/app/components/base/qrcode'
import Textarea from '@/app/components/base/textarea'
import {
AlertDialog,
AlertDialogActions,
@ -31,19 +29,11 @@ import {
DialogDescription,
DialogTitle,
} from '@/app/components/base/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/app/components/base/ui/select'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/app/components/base/ui/tooltip'
import { InputVarType } from '@/app/components/workflow/types'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import AccessControl from '../app-access-control'
@ -51,6 +41,7 @@ import CustomizeModal from './customize'
import EmbeddedModal from './embedded'
import SettingsModal from './settings'
import style from './style.module.css'
import WorkflowHiddenInputFields from './workflow-hidden-input-fields'
type AppInfo = AppDetailResponse & Partial<AppSSO>
@ -141,75 +132,6 @@ export const WorkflowLaunchDialog = ({
onValueChange: (variable: string, value: WorkflowLaunchInputValue) => void
onSubmit: (event: FormEvent<HTMLFormElement>) => void
}) => {
const renderField = (variable: WorkflowHiddenStartVariable) => {
const fieldId = `workflow-launch-hidden-input-${variable.variable}`
const fieldValue = values[variable.variable]
const label = typeof variable.label === 'string' ? variable.label : variable.variable
if (variable.type === InputVarType.select) {
return (
<Select
value={typeof fieldValue === 'string' ? fieldValue : ''}
onValueChange={value => onValueChange(variable.variable, value ?? '')}
>
<SelectTrigger className="w-full" aria-label={label}>
<SelectValue placeholder={label} />
</SelectTrigger>
<SelectContent>
{(variable.options ?? []).map(option => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
if (variable.type === InputVarType.checkbox) {
return (
<label className="flex min-h-10 w-full cursor-pointer items-center gap-3 rounded-lg bg-components-input-bg-normal px-3 py-2">
<input
id={fieldId}
type="checkbox"
checked={Boolean(fieldValue)}
onChange={(event: ChangeEvent<HTMLInputElement>) => onValueChange(variable.variable, event.target.checked)}
className="h-4 w-4 rounded border-divider-subtle"
/>
<span className="system-sm-regular text-text-secondary">{label}</span>
</label>
)
}
if (
variable.type === InputVarType.paragraph
|| variable.type === InputVarType.json
|| variable.type === InputVarType.jsonObject
) {
return (
<Textarea
id={fieldId}
value={typeof fieldValue === 'string' ? fieldValue : ''}
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onValueChange(variable.variable, event.target.value)}
placeholder={label}
maxLength={variable.max_length}
className="min-h-24"
/>
)
}
return (
<Input
id={fieldId}
type={variable.type === InputVarType.number ? 'number' : 'text'}
value={typeof fieldValue === 'string' ? fieldValue : ''}
onChange={(event: ChangeEvent<HTMLInputElement>) => onValueChange(variable.variable, event.target.value)}
placeholder={label}
maxLength={variable.max_length}
/>
)
}
if (!hiddenVariables.length && !unsupportedVariables.length)
return null
@ -226,19 +148,11 @@ export const WorkflowLaunchDialog = ({
</div>
<form onSubmit={onSubmit}>
<div className="space-y-4 px-6 pb-4">
{hiddenVariables.map(variable => (
<div key={variable.variable} className="space-y-1.5">
{variable.type !== InputVarType.checkbox && (
<label
htmlFor={`workflow-launch-hidden-input-${variable.variable}`}
className="block system-sm-medium text-text-secondary"
>
{typeof variable.label === 'string' ? variable.label : variable.variable}
</label>
)}
{renderField(variable)}
</div>
))}
<WorkflowHiddenInputFields
hiddenVariables={hiddenVariables}
values={values}
onValueChange={onValueChange}
/>
</div>
<div className="flex items-center justify-end gap-2 border-t-[0.5px] border-divider-subtle px-6 py-4">
<Button onClick={() => onOpenChange(false)}>
@ -512,6 +426,7 @@ export const AppCardDialogs = ({
onCloseAccessControl,
onSaveSiteConfig,
onConfirmAccessControl,
hiddenInputs,
}: {
isApp: boolean
appInfo: AppInfo
@ -527,6 +442,7 @@ export const AppCardDialogs = ({
onCloseAccessControl: () => void
onSaveSiteConfig?: (params: ConfigParams) => Promise<void>
onConfirmAccessControl: () => Promise<void>
hiddenInputs?: WorkflowHiddenStartVariable[]
}) => {
if (!isApp)
return null
@ -546,6 +462,7 @@ export const AppCardDialogs = ({
onClose={onCloseEmbedded}
appBaseUrl={appInfo.site?.app_base_url}
accessToken={appInfo.site?.access_token}
hiddenInputs={hiddenInputs}
/>
<CustomizeModal
isShow={showCustomizeModal}

View File

@ -2,6 +2,7 @@ import type { InputVar } from '@/app/components/workflow/types'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { IS_CE_EDITION } from '@/config'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
@ -127,6 +128,95 @@ export const buildWorkflowLaunchUrl = async ({
return targetUrl.toString()
}
export const getEmbeddedIframeSnippet = (iframeUrl: string) =>
`<iframe
src="${iframeUrl}"
style="width: 100%; height: 100%; min-height: 700px"
frameborder="0"
allow="microphone">
</iframe>`
const getScriptInputsContent = (values: Record<string, WorkflowLaunchInputValue>) => {
const entries = Object.entries(values)
if (!entries.length) {
return `{
// You can define the inputs from the Start node here
// key is the variable name
// e.g.
// name: "NAME"
}`
}
return `{
${entries.map(([key, value]) => ` ${key}: ${JSON.stringify(value)},`).join('\n')}
}`
}
export const getEmbeddedScriptSnippet = ({
url,
token,
primaryColor,
isTestEnv,
inputValues,
}: {
url: string
token: string
primaryColor: string
isTestEnv?: boolean
inputValues: Record<string, WorkflowLaunchInputValue>
}) =>
`<script>
window.difyChatbotConfig = {
token: '${token}'${isTestEnv
? `,
isDev: true`
: ''}${IS_CE_EDITION
? `,
baseUrl: '${url}${basePath}'`
: ''},
inputs: ${getScriptInputsContent(inputValues)},
systemVariables: {
// user_id: 'YOU CAN DEFINE USER ID HERE',
// conversation_id: 'YOU CAN DEFINE CONVERSATION ID HERE, IT MUST BE A VALID UUID',
},
userVariables: {
// avatar_url: 'YOU CAN DEFINE USER AVATAR URL HERE',
// name: 'YOU CAN DEFINE USER NAME HERE',
},
}
</script>
<script
src="${url}${basePath}/embed.min.js"
id="${token}"
defer>
</script>
<style>
#dify-chatbot-bubble-button {
background-color: ${primaryColor} !important;
}
#dify-chatbot-bubble-window {
width: 24rem !important;
height: 40rem !important;
}
</style>`
export const getChromePluginContent = (iframeUrl: string) => `ChatBot URL: ${iframeUrl}`
export const compressAndEncodeBase64 = async (input: string) => {
const uint8Array = new TextEncoder().encode(input)
if (typeof CompressionStream === 'undefined')
return btoa(String.fromCharCode(...uint8Array))
const compressedStream = new Response(
new Blob([uint8Array])
.stream()
.pipeThrough(new CompressionStream('gzip')),
).arrayBuffer()
const compressedUint8Array = new Uint8Array(await compressedStream)
return btoa(String.fromCharCode(...compressedUint8Array))
}
export const getAppCardDisplayState = ({
appInfo,
cardType,

View File

@ -361,6 +361,7 @@ function AppCard({
onCloseAccessControl={() => setShowAccessControl(false)}
onSaveSiteConfig={onSaveSiteConfig}
onConfirmAccessControl={handleAccessControlUpdate}
hiddenInputs={hiddenLaunchVariables}
/>
<WorkflowLaunchDialog
t={t}

View File

@ -1,10 +1,11 @@
import type { SiteInfo } from '@/models/share'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import copy from 'copy-to-clipboard'
import * as React from 'react'
import { act } from 'react'
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { InputVarType } from '@/app/components/workflow/types'
import Embedded from '../index'
vi.mock('../style.module.css', () => ({
@ -46,6 +47,7 @@ vi.mock('@/context/app-context', () => ({
}))
const mockWindowOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
const mockedCopy = vi.mocked(copy)
const originalCompressionStream = globalThis.CompressionStream
const siteInfo: SiteInfo = {
title: 'test site',
@ -70,6 +72,22 @@ const getCopyButton = () => {
}
describe('Embedded', () => {
beforeAll(() => {
class MockCompressionStream {
readable: ReadableStream<Uint8Array>
writable: WritableStream<Uint8Array>
constructor() {
const transformStream = new TransformStream<Uint8Array, Uint8Array>()
this.readable = transformStream.readable
this.writable = transformStream.writable
}
}
// @ts-expect-error test polyfill
globalThis.CompressionStream = MockCompressionStream
})
afterEach(() => {
vi.clearAllMocks()
mockWindowOpen.mockClear()
@ -77,6 +95,7 @@ describe('Embedded', () => {
afterAll(() => {
mockWindowOpen.mockRestore()
globalThis.CompressionStream = originalCompressionStream
})
it('builds theme and copies iframe snippet', async () => {
@ -84,14 +103,20 @@ describe('Embedded', () => {
render(<Embedded {...baseProps} />)
})
await waitFor(() => {
expect(screen.getByText((content, node) => node?.tagName.toLowerCase() === 'pre' && content.includes('/chatbot/token'))).toBeInTheDocument()
})
const actionButton = getCopyButton()
const innerDiv = actionButton.querySelector('div')
act(() => {
await act(async () => {
fireEvent.click(innerDiv ?? actionButton)
})
expect(mockThemeBuilder.buildTheme).toHaveBeenCalledWith(siteInfo.chat_color_theme, siteInfo.chat_color_theme_inverted)
expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token'))
await waitFor(() => {
expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token'))
})
})
it('opens chrome plugin store link when chrome option selected', async () => {
@ -116,4 +141,49 @@ describe('Embedded', () => {
'noopener,noreferrer',
)
})
it('keeps hidden inputs collapsed by default and updates iframe and script content when values change', async () => {
render(
<Embedded
{...baseProps}
hiddenInputs={[{
variable: 'secret',
label: 'Secret',
type: InputVarType.textInput,
hide: true,
required: true,
default: '',
}]}
/>,
)
expect(screen.queryByLabelText('Secret')).not.toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByText('appOverview.overview.appInfo.embedded.hiddenInputs.title').closest('button')!)
})
await waitFor(() => {
expect(screen.getByLabelText('Secret')).toBeInTheDocument()
})
fireEvent.change(screen.getByLabelText('Secret'), {
target: { value: 'top-secret' },
})
await waitFor(() => {
const codeBlock = document.querySelector('pre')
expect(codeBlock?.textContent ?? '').toContain('/chatbot/token?secret=dG9wLXNlY3JldA%3D%3D')
})
const optionButtons = document.body.querySelectorAll('[class*="option"]')
act(() => {
fireEvent.click(optionButtons[1])
})
await waitFor(() => {
const codeBlock = document.querySelector('pre')
expect(codeBlock?.textContent ?? '').toContain('secret: "top-secret"')
})
})
})

View File

@ -1,90 +1,47 @@
import type { WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from '../app-card-utils'
import type { SiteInfo } from '@/models/share'
import { cn } from '@langgenius/dify-ui/cn'
import {
RiArrowDownSLine,
RiArrowRightSLine,
RiClipboardFill,
RiClipboardLine,
} from '@remixicon/react'
import copy from 'copy-to-clipboard'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { Suspense, use, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context'
import Modal from '@/app/components/base/modal'
import Tooltip from '@/app/components/base/tooltip'
import { IS_CE_EDITION } from '@/config'
import { InputVarType } from '@/app/components/workflow/types'
import { useAppContext } from '@/context/app-context'
import { basePath } from '@/utils/var'
import {
compressAndEncodeBase64,
createWorkflowLaunchInitialValues,
getChromePluginContent,
getEmbeddedIframeSnippet,
getEmbeddedScriptSnippet,
isWorkflowLaunchInputSupported,
} from '../app-card-utils'
import WorkflowHiddenInputFields from '../workflow-hidden-input-fields'
import style from './style.module.css'
type Props = {
siteInfo?: SiteInfo
isShow: boolean
onClose: () => void
accessToken: string
appBaseUrl: string
accessToken?: string
appBaseUrl?: string
hiddenInputs?: WorkflowHiddenStartVariable[]
className?: string
}
const OPTION_MAP = {
iframe: {
getContent: (url: string, token: string) =>
`<iframe
src="${url}${basePath}/chatbot/${token}"
style="width: 100%; height: 100%; min-height: 700px"
frameborder="0"
allow="microphone">
</iframe>`,
},
scripts: {
getContent: (url: string, token: string, primaryColor: string, isTestEnv?: boolean) =>
`<script>
window.difyChatbotConfig = {
token: '${token}'${isTestEnv
? `,
isDev: true`
: ''}${IS_CE_EDITION
? `,
baseUrl: '${url}${basePath}'`
: ''},
inputs: {
// You can define the inputs from the Start node here
// key is the variable name
// e.g.
// name: "NAME"
},
systemVariables: {
// user_id: 'YOU CAN DEFINE USER ID HERE',
// conversation_id: 'YOU CAN DEFINE CONVERSATION ID HERE, IT MUST BE A VALID UUID',
},
userVariables: {
// avatar_url: 'YOU CAN DEFINE USER AVATAR URL HERE',
// name: 'YOU CAN DEFINE USER NAME HERE',
},
}
</script>
<script
src="${url}${basePath}/embed.min.js"
id="${token}"
defer>
</script>
<style>
#dify-chatbot-bubble-button {
background-color: ${primaryColor} !important;
}
#dify-chatbot-bubble-window {
width: 24rem !important;
height: 40rem !important;
}
</style>`,
},
chromePlugin: {
getContent: (url: string, token: string) => `ChatBot URL: ${url}${basePath}/chatbot/${token}`,
},
}
const OPTION_KEYS = ['iframe', 'scripts', 'chromePlugin'] as const
const prefixEmbedded = 'overview.appInfo.embedded'
type Option = keyof typeof OPTION_MAP
type Option = typeof OPTION_KEYS[number]
type OptionStatus = {
iframe: boolean
@ -92,34 +49,97 @@ type OptionStatus = {
chromePlugin: boolean
}
const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, className }: Props) => {
const EMPTY_OPTION_STATUS: OptionStatus = {
iframe: false,
scripts: false,
chromePlugin: false,
}
const AsyncEmbeddedOptionContent = ({
option,
iframeUrlPromise,
}: {
option: Option
iframeUrlPromise: Promise<string>
}) => {
const iframeUrl = use(iframeUrlPromise)
if (option === 'chromePlugin')
return getChromePluginContent(iframeUrl)
return getEmbeddedIframeSnippet(iframeUrl)
}
const EmbeddedContent = ({
siteInfo,
appBaseUrl,
accessToken,
hiddenInputs,
}: Required<Pick<Props, 'accessToken' | 'appBaseUrl'>> & Pick<Props, 'siteInfo' | 'hiddenInputs'>) => {
const { t } = useTranslation()
const [option, setOption] = useState<Option>('iframe')
const [isCopied, setIsCopied] = useState<OptionStatus>({ iframe: false, scripts: false, chromePlugin: false })
const [isCopied, setIsCopied] = useState<OptionStatus>(EMPTY_OPTION_STATUS)
const [hiddenInputsCollapsed, setHiddenInputsCollapsed] = useState(true)
const [hiddenInputValues, setHiddenInputValues] = useState<Record<string, WorkflowLaunchInputValue>>(
() => createWorkflowLaunchInitialValues((hiddenInputs ?? []).filter(isWorkflowLaunchInputSupported)),
)
const { langGeniusVersionInfo } = useAppContext()
const themeBuilder = useThemeContext()
themeBuilder.buildTheme(siteInfo?.chat_color_theme ?? null, siteInfo?.chat_color_theme_inverted ?? false)
const isTestEnv = langGeniusVersionInfo.current_env === 'TESTING' || langGeniusVersionInfo.current_env === 'DEVELOPMENT'
const onClickCopy = () => {
const supportedHiddenInputs = useMemo<WorkflowHiddenStartVariable[]>(
() => (hiddenInputs ?? []).filter(isWorkflowLaunchInputSupported),
[hiddenInputs],
)
const handleHiddenInputValueChange = (variable: string, value: WorkflowLaunchInputValue) => {
setIsCopied(EMPTY_OPTION_STATUS)
setHiddenInputValues(prev => ({
...prev,
[variable]: value,
}))
}
const getSerializedHiddenInputValue = (variable: WorkflowHiddenStartVariable) => {
const rawValue = hiddenInputValues[variable.variable]
if (variable.type === InputVarType.checkbox)
return String(Boolean(rawValue))
return String(rawValue ?? '')
}
const buildEmbeddedIframeUrl = async () => {
const iframeUrl = new URL(`${appBaseUrl}${basePath}/chatbot/${accessToken}`, window.location.origin)
await Promise.all(supportedHiddenInputs.map(async (variable) => {
iframeUrl.searchParams.set(variable.variable, await compressAndEncodeBase64(getSerializedHiddenInputValue(variable)))
}))
return iframeUrl.toString()
}
const iframeUrlPromise = useMemo(() => buildEmbeddedIframeUrl(), [accessToken, appBaseUrl, hiddenInputValues, supportedHiddenInputs])
const scriptsContent = useMemo(() => getEmbeddedScriptSnippet({
url: appBaseUrl,
token: accessToken,
primaryColor: themeBuilder.theme?.primaryColor ?? '#1C64F2',
isTestEnv,
inputValues: hiddenInputValues,
}), [accessToken, appBaseUrl, hiddenInputValues, isTestEnv, themeBuilder.theme?.primaryColor])
const onClickCopy = async () => {
if (option === 'chromePlugin') {
const splitUrl = OPTION_MAP[option].getContent(appBaseUrl, accessToken).split(': ')
const splitUrl = getChromePluginContent(await iframeUrlPromise).split(': ')
if (splitUrl.length > 1)
copy(splitUrl[1])
}
else {
copy(OPTION_MAP[option].getContent(appBaseUrl, accessToken, themeBuilder.theme?.primaryColor ?? '#1C64F2', isTestEnv))
else if (option === 'iframe') {
copy(getEmbeddedIframeSnippet(await iframeUrlPromise))
}
setIsCopied({ ...isCopied, [option]: true })
}
// when toggle option, reset then copy status
const resetCopyStatus = () => {
const cache = { ...isCopied }
Object.keys(cache).forEach((key) => {
cache[key as keyof OptionStatus] = false
})
setIsCopied(cache)
else {
copy(scriptsContent)
}
setIsCopied(prev => ({ ...prev, [option]: true }))
}
const navigateToChromeUrl = () => {
@ -127,23 +147,47 @@ const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, classNam
}
useEffect(() => {
resetCopyStatus()
}, [isShow])
themeBuilder.buildTheme(siteInfo?.chat_color_theme ?? null, siteInfo?.chat_color_theme_inverted ?? false)
}, [siteInfo?.chat_color_theme, siteInfo?.chat_color_theme_inverted, themeBuilder])
return (
<Modal
title={t(`${prefixEmbedded}.title`, { ns: 'appOverview' })}
isShow={isShow}
onClose={onClose}
className="w-[640px] max-w-2xl!"
wrapperClassName={className}
closable={true}
>
<>
<div className="mt-8 mb-4 system-sm-medium text-text-primary">
{t(`${prefixEmbedded}.explanation`, { ns: 'appOverview' })}
</div>
{supportedHiddenInputs.length > 0 && (
<div className="mb-6 rounded-xl border-[0.5px] border-components-panel-border bg-background-section">
<button
type="button"
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
onClick={() => setHiddenInputsCollapsed(prev => !prev)}
>
<div>
<div className="system-sm-medium text-text-primary">
{t(`${prefixEmbedded}.hiddenInputs.title`, { ns: 'appOverview' })}
</div>
<div className="mt-1 system-xs-regular text-text-tertiary">
{t(`${prefixEmbedded}.hiddenInputs.description`, { ns: 'appOverview' })}
</div>
</div>
{hiddenInputsCollapsed
? <RiArrowRightSLine className="h-4 w-4 shrink-0 text-text-tertiary" />
: <RiArrowDownSLine className="h-4 w-4 shrink-0 text-text-tertiary" />}
</button>
{!hiddenInputsCollapsed && (
<div className="max-h-72 space-y-4 overflow-y-auto border-t-[0.5px] border-divider-subtle px-4 py-4">
<WorkflowHiddenInputFields
hiddenVariables={supportedHiddenInputs}
values={hiddenInputValues}
onValueChange={handleHiddenInputValueChange}
fieldIdPrefix="embedded-hidden-input"
/>
</div>
)}
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-y-2">
{Object.keys(OPTION_MAP).map((v, index) => {
{OPTION_KEYS.map((v, index) => {
return (
<div
key={index}
@ -153,8 +197,8 @@ const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, classNam
option === v && style.active,
)}
onClick={() => {
setOption(v as Option)
resetCopyStatus()
setOption(v)
setIsCopied(EMPTY_OPTION_STATUS)
}}
>
</div>
@ -183,7 +227,7 @@ const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, classNam
>
<ActionButton>
<div
onClick={onClickCopy}
onClick={() => void onClickCopy()}
>
{isCopied[option] && <RiClipboardFill className="h-4 w-4" />}
{!isCopied[option] && <RiClipboardLine className="h-4 w-4" />}
@ -193,10 +237,43 @@ const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, classNam
</div>
<div className="flex w-full items-start justify-start gap-2 overflow-x-auto p-3">
<div className="shrink grow basis-0 font-mono text-[13px] leading-tight text-text-secondary">
<pre className="select-text">{OPTION_MAP[option].getContent(appBaseUrl, accessToken, themeBuilder.theme?.primaryColor ?? '#1C64F2', isTestEnv)}</pre>
<pre className="select-text">
{option === 'scripts'
? scriptsContent
: (
<Suspense fallback="">
<AsyncEmbeddedOptionContent option={option} iframeUrlPromise={iframeUrlPromise} />
</Suspense>
)}
</pre>
</div>
</div>
</div>
</>
)
}
const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, hiddenInputs, className }: Props) => {
const { t } = useTranslation()
return (
<Modal
title={t(`${prefixEmbedded}.title`, { ns: 'appOverview' })}
isShow={isShow}
onClose={onClose}
className="w-[640px] max-w-2xl!"
wrapperClassName={className}
closable={true}
>
{isShow && (
<EmbeddedContent
key={`${appBaseUrl ?? ''}:${accessToken ?? ''}:${JSON.stringify(hiddenInputs ?? [])}`}
siteInfo={siteInfo}
appBaseUrl={appBaseUrl ?? ''}
accessToken={accessToken ?? ''}
hiddenInputs={hiddenInputs}
/>
)}
</Modal>
)
}

View File

@ -56,6 +56,8 @@
"overview.appInfo.embedded.copy": "Copy",
"overview.appInfo.embedded.entry": "Embedded",
"overview.appInfo.embedded.explanation": "Choose the way to embed chat app to your website",
"overview.appInfo.embedded.hiddenInputs.description": "These values will be applied to the iframe URL or embedded script inputs.",
"overview.appInfo.embedded.hiddenInputs.title": "Config hidden inputs",
"overview.appInfo.embedded.iframe": "To add the chat app any where on your website, add this iframe to your html code.",
"overview.appInfo.embedded.scripts": "To add a chat app to the bottom right of your website add this code to your html.",
"overview.appInfo.embedded.title": "Embed on website",

View File

@ -56,6 +56,8 @@
"overview.appInfo.embedded.copy": "复制",
"overview.appInfo.embedded.entry": "嵌入",
"overview.appInfo.embedded.explanation": "选择一种方式将聊天应用嵌入到你的网站中",
"overview.appInfo.embedded.hiddenInputs.description": "这些值会应用到 iframe URL 或嵌入脚本的 inputs 中。",
"overview.appInfo.embedded.hiddenInputs.title": "配置隐藏字段",
"overview.appInfo.embedded.iframe": "将以下 iframe 嵌入到你的网站中的目标位置",
"overview.appInfo.embedded.scripts": "将以下代码嵌入到你的网站中",
"overview.appInfo.embedded.title": "嵌入到网站中",