diff --git a/frontend/src/api/embed/index.ts b/frontend/src/api/embed/index.ts index 2a75354a..8607199a 100644 --- a/frontend/src/api/embed/index.ts +++ b/frontend/src/api/embed/index.ts @@ -61,6 +61,39 @@ export type WidgetPosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top /** Prefix of short-lived embed session tokens minted by the backend. */ export const EMBED_SESSION_TOKEN_PREFIX = 'ems_' +/** localStorage key prefix for persisted embed chat sessions (per channel). */ +export const EMBED_CHAT_SESSION_STORAGE_PREFIX = 'weknora-embed-session:' + +export function embedChatSessionStorageKey(channelId: string): string { + return `${EMBED_CHAT_SESSION_STORAGE_PREFIX}${channelId}` +} + +/** Drop a persisted embed chat session so the next load starts fresh. */ +export function clearEmbedStoredChatSession(channelId: string): void { + if (typeof localStorage === 'undefined') return + try { + localStorage.removeItem(embedChatSessionStorageKey(channelId)) + } catch { + // localStorage may be unavailable in private mode. + } +} + +/** Clear a stored chat session when it was created under a different agent binding. */ +export function clearEmbedStoredChatSessionIfAgentMismatch(channelId: string, agentId: string): void { + if (typeof localStorage === 'undefined' || !channelId || !agentId) return + try { + const raw = localStorage.getItem(embedChatSessionStorageKey(channelId)) + if (!raw) return + const parsed = JSON.parse(raw) as { agentId?: string } + if (parsed?.agentId && parsed.agentId !== agentId) { + localStorage.removeItem(embedChatSessionStorageKey(channelId)) + } + } catch { + // Malformed entry — remove so bootstrap can recover. + localStorage.removeItem(embedChatSessionStorageKey(channelId)) + } +} + /** Whether a token is already a short-lived session token (secure mode). */ export function isEmbedSessionToken(token: string): boolean { return typeof token === 'string' && token.trim().startsWith(EMBED_SESSION_TOKEN_PREFIX) diff --git a/frontend/src/components/AgentEmbedChannelPanel.vue b/frontend/src/components/AgentEmbedChannelPanel.vue index 5a3f9bff..8e2e2edc 100644 --- a/frontend/src/components/AgentEmbedChannelPanel.vue +++ b/frontend/src/components/AgentEmbedChannelPanel.vue @@ -24,24 +24,22 @@
-

{{ ch.name || $t('embedPublish.unnamed') }}

+

{{ channelDisplayName(ch) }}

{{ $t('embedPublish.disabled') }}
-

{{ channelSummary(ch) }}

+ + {{ agentDisplayName(ch) }} +
- -
@@ -73,31 +76,62 @@ + + +
+ +
+ + +

{{ $t('embedPublish.sectionChannel') }}

-
- - +
+ +
+ +
-
-
- {{ $t('embedPublish.enabled') }} +
+
+ +
+
@@ -108,22 +142,17 @@ v-model="form.name" :disabled="!isAdmin" :placeholder="$t('embedPublish.namePlaceholder')" + @focus="channelNameTouched = true" /> -

{{ $t('embedPublish.nameDesc') }}

-
- -
- - +

{{ $t('embedPublish.nameDefaultHint') }}

+

{{ $t('embedPublish.nameDesc') }}

+
+ +

{{ $t('embedPublish.sectionSecurity') }}

@@ -141,38 +170,50 @@

{{ $t('embedPublish.originsHint') }}

-
-
- - -

{{ $t('embedPublish.rateLimitDesc') }}

-
+
+ + +

{{ $t('embedPublish.rateLimitDesc') }}

+
-
- - -

{{ $t('embedPublish.rateLimitDayDesc') }}

-
+
+ + +

{{ $t('embedPublish.rateLimitDayDesc') }}

+
+ +

{{ $t('embedPublish.sectionCapabilities') }}

+
+ + +

{{ $t('embedPublish.welcomeMessageDesc') }}

+
+
@@ -223,7 +264,10 @@
+
+ +

{{ $t('embedPublish.sectionAppearance') }}

@@ -237,47 +281,43 @@

{{ $t('embedPublish.pageTitleDesc') }}

-
-
- - -

{{ $t('embedPublish.headerTitleModeDesc') }}

-
- -
- - -
+
+ + +

{{ $t('embedPublish.headerTitleModeDesc') }}

-
-
- - -

{{ $t('embedPublish.defaultLocaleDesc') }}

-
+
+ + +
-
- - -
+
+ + +

{{ $t('embedPublish.defaultLocaleDesc') }}

+
+ +
+ +
@@ -296,7 +336,10 @@
+
+ +

{{ $t('embedPublish.sectionWebhook') }}

@@ -306,6 +349,7 @@

{{ $t('embedPublish.webhookUrlDesc') }}

@@ -317,6 +361,7 @@ v-model="form.webhook_secret" :disabled="!isAdmin" type="password" + autocomplete="new-password" :placeholder="webhookSecretPlaceholder" />

{{ $t('embedPublish.webhookSecretDesc') }}

@@ -328,73 +373,17 @@

{{ $t('embedPublish.deployAfterSaveHint') }}

+ -
+ +
+

{{ $t('embedPublish.sectionDeploy') }}

{{ $t('embedPublish.deployIntro') }}

-
- -
-
{{ $t('embedPublish.channelKey') }}
-

{{ $t('embedPublish.channelKeyDesc') }}

- -
- - - - - - - -
-

- {{ $t('embedPublish.channelKeyHint') }} -

-
-
- -
- -
-
{{ $t('embedPublish.deployStepEmbed') }}
-

{{ $t('embedPublish.deployStepEmbedDesc') }}

+
+
{{ $t('embedPublish.deployStepEmbed') }}
+

{{ $t('embedPublish.deployStepEmbedDesc') }}

@@ -458,10 +447,64 @@
{{ secureServerExample }}
+
+ +
+
{{ $t('embedPublish.channelKey') }}
+

{{ $t('embedPublish.channelKeyDesc') }}

+ +
+ + + + + + +
+

+ {{ $t('embedPublish.channelKeyHint') }} +

+
('iframe') @@ -548,6 +594,7 @@ const secureServerLangTab = ref<'node' | 'go'>('node') const sessionStats = ref>({}) const agents = ref([]) const createAgentId = ref('') +const channelNameTouched = ref(false) const agentOptions = computed(() => agents.value.map((agent) => ({ label: agent.name, value: agent.id })), @@ -632,52 +679,141 @@ const defaultLocaleOptions = computed(() => ([ { label: 'Русский', value: 'ru-RU' }, ])) -const channelMenuOptions = computed(() => ([ - { content: t('common.delete'), value: 'delete', theme: 'error' }, -])) +const channelMenuOptions = (ch: EmbedChannel) => { + const items: Array<{ content: string; value: string; theme?: 'error' }> = [] + if (isAdmin.value) { + items.push({ content: t('embedPublish.preview'), value: 'preview' }) + } + items.push({ + content: ch.enabled ? t('common.off') : t('common.on'), + value: 'toggle', + }) + if (isAdmin.value) { + items.push({ content: t('common.delete'), value: 'delete', theme: 'error' }) + } + return items +} + +function handleChannelMenuClick(data: { value?: string }, ch: EmbedChannel) { + if (data.value === 'preview') { + void openPreviewForChannel(ch) + return + } + if (data.value === 'toggle') { + void toggleEnabled(ch, !ch.enabled) + return + } + if (data.value === 'delete') { + confirmRemoveChannel(ch.id) + } +} + +const drawerTitle = computed(() => { + if (!editingId.value) return t('embedPublish.createTitle') + const ch = drawerChannel.value + const agentId = props.tenantMode + ? (createAgentId.value || ch?.agent_id || '') + : (ch?.agent_id || props.agentId || '') + return channelDisplayName({ + id: editingId.value, + name: form.value.name, + agent_id: agentId, + } as EmbedChannel) +}) + +function defaultEmbedChannelName(agentId = props.tenantMode ? createAgentId.value : props.agentId): string { + const agent = agentId ? agents.value.find((item) => item.id === agentId) : undefined + if (agent?.name?.trim()) { + return t('embedPublish.defaultChannelNameWithAgent', { agent: agent.name.trim() }) + } + return t('embedPublish.defaultChannelName') +} + +function resolvedEmbedChannelName(): string { + return form.value.name.trim() || defaultEmbedChannelName() +} + +function channelDisplayName(ch: EmbedChannel): string { + if (ch.name?.trim()) return ch.name.trim() + return defaultEmbedChannelName(ch.agent_id) +} + +function applyDefaultChannelNameIfNeeded() { + if (!editingId.value && !channelNameTouched.value) { + form.value.name = defaultEmbedChannelName() + } +} + +const stepTitles = computed(() => { + const steps = [ + t('embedPublish.stepChannel'), + t('embedPublish.stepSecurity'), + t('embedPublish.stepCapabilities'), + t('embedPublish.stepAppearance'), + t('embedPublish.stepWebhook'), + ] + if (editingId.value) steps.push(t('embedPublish.stepDeploy')) + return steps +}) + +const drawerStepDescription = computed(() => stepTitles.value[wizardStep.value] ?? '') + +const drawerConfirmText = computed(() => + wizardStep.value < stepTitles.value.length - 1 ? t('common.next') : t('common.save'), +) + +function validateWizardStep(step: number): boolean { + if (step === 0 && props.tenantMode && !createAgentId.value) { + MessagePlugin.warning(t('integrations.selectAgentHint')) + return false + } + if (step === 1) { + const originsValidation = validateAllowedOrigins(parseOrigins()) + if (!originsValidation.ok) { + originsError.value = originsValidationMessage(originsValidation.error) + MessagePlugin.warning(originsError.value) + return false + } + originsError.value = '' + } + return true +} + +function prevWizardStep() { + if (wizardStep.value > 0) wizardStep.value -= 1 +} + +function goToWizardStep(step: number) { + if (step < 0 || step >= stepTitles.value.length) return + wizardStep.value = step +} + +function goToDeployStep() { + if (!editingId.value) return + wizardStep.value = stepTitles.value.length - 1 +} + +async function handleDrawerConfirm() { + if (wizardStep.value < stepTitles.value.length - 1) { + if (!validateWizardStep(wizardStep.value)) return + wizardStep.value += 1 + return + } + await saveForm() +} const drawerChannel = computed(() => editingId.value ? channels.value.find((ch) => ch.id === editingId.value) : null) -const drawerTitle = computed(() => { - if (!editingId.value) return t('embedPublish.createTitle') - return form.value.name?.trim() || t('embedPublish.unnamed') -}) - -const drawerDescription = computed(() => { - if (!editingId.value) return '' - const ch = drawerChannel.value - if (!ch) return '' - const parts: string[] = [] - if (!ch.enabled) parts.push(t('embedPublish.disabled')) - const limit = ch.rate_limit_per_minute || 30 - parts.push(`${t('embedPublish.rateLimit')} ${limit}${t('embedPublish.rateLimitUnit')}`) - const count = sessionStats.value[ch.id] - if (typeof count === 'number') { - parts.push(t('embedPublish.sessionCountLabel', { n: count })) - } - return parts.join(' · ') -}) - const previewPosition = computed((): WidgetPosition => (previewChannel.value?.widget_position as WidgetPosition) || 'bottom-right') -const channelSummary = (ch: EmbedChannel) => { - const parts = [ - `${t('embedPublish.rateLimit')} ${ch.rate_limit_per_minute || 30}${t('embedPublish.rateLimitUnit')}`, - ] - if (props.tenantMode) { - const agentName = agents.value.find((agent) => agent.id === ch.agent_id)?.name - if (agentName) parts.unshift(agentName) - } - if (ch.allowed_origins?.length) { - parts.push(t('embedPublish.originsCount', { n: ch.allowed_origins.length })) - } - const count = sessionStats.value[ch.id] - if (typeof count === 'number') { - parts.push(t('embedPublish.sessionCountLabel', { n: count })) - } - return parts.join(' · ') +function agentDisplayName(ch: EmbedChannel): string { + return agentForChannel(ch)?.name || '' +} + +function agentForChannel(ch: EmbedChannel): CustomAgent | undefined { + return agents.value.find((agent) => agent.id === ch.agent_id) } const loadStoredTokens = (): Record => { @@ -730,6 +866,24 @@ watch(() => [props.tenantMode, props.agentId], () => { if (props.tenantMode || props.agentId) load() }, { immediate: true }) +watch(stepTitles, (titles) => { + if (wizardStep.value >= titles.length) { + wizardStep.value = Math.max(0, titles.length - 1) + } +}) + +watch([createAgentId, () => agents.value.length], () => { + applyDefaultChannelNameIfNeeded() +}) + +watch(createAgentId, (agentId, prev) => { + if (!editingId.value || !agentId || agentId === prev || !previewVisible.value) return + const ch = drawerChannel.value + if (!ch) return + clearEmbedStoredChatSessionIfAgentMismatch(ch.id, agentId) + previewNonce.value += 1 +}) + const tokenFor = (ch: EmbedChannel) => tokenByChannel.value[ch.id] || ch.publish_token const displayChannelKey = (channelId: string) => { @@ -815,6 +969,7 @@ const snippetScenarioHint = computed(() => { const fillFormFromChannel = (ch: EmbedChannel) => { editingId.value = ch.id editingEnabled.value = ch.enabled + createAgentId.value = ch.agent_id form.value = { name: ch.name, welcome_message: ch.welcome_message, @@ -838,10 +993,13 @@ const fillFormFromChannel = (ch: EmbedChannel) => { } const openCreate = () => { + wizardStep.value = 0 + channelNameTouched.value = false editingId.value = '' editingEnabled.value = true createAgentId.value = props.tenantMode ? (props.initialAgentId || '') : '' form.value = defaultForm() + applyDefaultChannelNameIfNeeded() originsText.value = '' originsError.value = '' drawerSnippetTab.value = 'iframe' @@ -850,12 +1008,15 @@ const openCreate = () => { } const openDrawer = (ch: EmbedChannel) => { + channelNameTouched.value = true fillFormFromChannel(ch) + goToDeployStep() showDrawer.value = true } const closeDrawer = () => { showDrawer.value = false + wizardStep.value = 0 } const parseOrigins = () => parseAllowedOrigins(originsText.value) @@ -882,6 +1043,10 @@ const mapOriginsApiError = (message: string): string | null => { const saveForm = async () => { if (!isAdmin.value) return + if (props.tenantMode && !createAgentId.value) { + MessagePlugin.warning(t('integrations.selectAgentHint')) + return + } const originsValidation = validateAllowedOrigins(parseOrigins()) if (!originsValidation.ok) { originsError.value = originsValidationMessage(originsValidation.error) @@ -892,7 +1057,7 @@ const saveForm = async () => { saving.value = true try { const payload = { - name: form.value.name, + name: resolvedEmbedChannelName(), welcome_message: form.value.welcome_message, allowed_origins: originsValidation.origins, rate_limit_per_minute: form.value.rate_limit_per_minute, @@ -908,13 +1073,25 @@ const saveForm = async () => { webhook_url: form.value.webhook_url || '', webhook_secret: form.value.webhook_secret || undefined, enabled: editingId.value ? editingEnabled.value : true, + ...(props.tenantMode ? { agent_id: createAgentId.value } : {}), } if (editingId.value) { + const previousAgentId = drawerChannel.value?.agent_id const res = await updateEmbedChannel(editingId.value, payload) MessagePlugin.success(t('embedPublish.updated')) + const saved = res?.data await load() - const updated = channels.value.find((ch) => ch.id === editingId.value) ?? res?.data - if (updated) fillFormFromChannel(updated) + const updated = saved ?? channels.value.find((ch) => ch.id === editingId.value) + if (updated) { + fillFormFromChannel(updated) + if (previousAgentId && updated.agent_id !== previousAgentId) { + clearEmbedStoredChatSession(updated.id) + if (previewVisible.value) { + previewChannel.value = updated + previewNonce.value += 1 + } + } + } } else { const targetAgentId = props.tenantMode ? createAgentId.value : props.agentId if (!targetAgentId) { @@ -931,8 +1108,11 @@ const saveForm = async () => { } await load() if (res?.data?.id) { - const created = channels.value.find((ch) => ch.id === res.data.id) - if (created) fillFormFromChannel(created) + const created = channels.value.find((ch) => ch.id === res.data.id) ?? res.data + if (created) { + fillFormFromChannel(created) + wizardStep.value = stepTitles.value.length - 1 + } } } } catch (err: any) { @@ -952,8 +1132,24 @@ const saveForm = async () => { const openPreviewFromDrawer = async () => { const ch = drawerChannel.value if (!ch) return + const mode = drawerSnippetTab.value === 'secure' || drawerSnippetTab.value === 'widget' + ? 'widget' + : 'iframe' + await openPreviewForChannel(ch, { useDraft: true, mode }) +} + +async function openPreviewForChannel( + ch: EmbedChannel, + opts?: { useDraft?: boolean; mode?: 'iframe' | 'widget' }, +) { previewLoading.value = true try { + const agentId = props.tenantMode + ? ((opts?.useDraft ? createAgentId.value : '') || ch.agent_id) + : ch.agent_id + if (agentId) { + clearEmbedStoredChatSessionIfAgentMismatch(ch.id, agentId) + } let token = tokenFor(ch) if (!token) { const res = await issueEmbedPreviewSession(ch.id) @@ -963,12 +1159,17 @@ const openPreviewFromDrawer = async () => { return } } - previewMode.value = drawerSnippetTab.value === 'iframe' ? 'iframe' : 'widget' - previewChannel.value = ch + previewMode.value = opts?.mode ?? 'iframe' + previewChannel.value = { + ...ch, + agent_id: agentId || ch.agent_id, + primary_color: opts?.useDraft ? form.value.primary_color : ch.primary_color, + widget_position: (opts?.useDraft ? form.value.widget_position : ch.widget_position) as WidgetPosition, + default_locale: opts?.useDraft ? (form.value.default_locale || ch.default_locale) : ch.default_locale, + } previewToken.value = token - previewLocale.value = form.value.default_locale || ch.default_locale || '' + previewLocale.value = (opts?.useDraft ? form.value.default_locale : '') || ch.default_locale || '' previewNonce.value += 1 - // Avoid TDesign textarea autosize recalculating on a blurred/hidden element. if (document.activeElement instanceof HTMLElement) { document.activeElement.blur() } @@ -1058,190 +1259,93 @@ const toggleEnabled = async (ch: EmbedChannel, enabled: boolean) => {