feat(integrations): polish embed/IM channel editors and agent rebind
Align embed channel setup with a step wizard, simplify deploy layout, and allow changing bound agents on update for embed and IM channels.
This commit is contained in:
@@ -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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,19 +35,17 @@
|
||||
{{ $t('agentEditor.im.disabled') }}
|
||||
</t-tag>
|
||||
</div>
|
||||
<p class="channel-card__subtitle">{{ channelSummary(channel) }}</p>
|
||||
<span v-if="tenantMode && agentDisplayName(channel)" class="channel-card__agent-name">
|
||||
{{ agentDisplayName(channel) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="authStore.hasRole('admin')" class="channel-card__actions" @click.stop>
|
||||
<t-switch
|
||||
:value="channel.enabled"
|
||||
size="small"
|
||||
@change="() => handleToggle(channel)"
|
||||
/>
|
||||
<t-dropdown
|
||||
trigger="click"
|
||||
placement="bottom-right"
|
||||
:options="channelMenuOptions"
|
||||
@click="(data) => data.value === 'delete' && confirmDelete(channel.id)"
|
||||
attach="body"
|
||||
:options="channelMenuOptions(channel)"
|
||||
@click="(data) => handleChannelMenuClick(data, channel)"
|
||||
>
|
||||
<t-button
|
||||
variant="text"
|
||||
@@ -68,10 +66,15 @@
|
||||
class="channel-card channel-card--add"
|
||||
@click="openCreate"
|
||||
>
|
||||
<span class="channel-card--add__icon" aria-hidden="true">
|
||||
<span class="channel-card__badge" aria-hidden="true">
|
||||
<t-icon name="add" />
|
||||
</span>
|
||||
<span class="channel-card--add__label">{{ $t('agentEditor.im.addChannel') }}</span>
|
||||
<div class="channel-card__body">
|
||||
<div class="channel-card__header">
|
||||
<span class="channel-card__title">{{ $t('agentEditor.im.addChannel') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="channel-card__actions channel-card__actions--spacer" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</t-loading>
|
||||
@@ -125,14 +128,16 @@
|
||||
<section class="setting-drawer__section im-drawer__section">
|
||||
<h4 class="setting-drawer__section-title">{{ $t('agentEditor.im.sectionChannel') }}</h4>
|
||||
|
||||
<div v-if="tenantMode && !editingChannel" class="form-item">
|
||||
<label class="form-label required">{{ $t('integrations.selectAgent') }}</label>
|
||||
<t-select
|
||||
v-model="formData.target_agent_id"
|
||||
:options="agentOptions"
|
||||
filterable
|
||||
:placeholder="$t('integrations.selectAgentPlaceholder')"
|
||||
/>
|
||||
<div v-if="tenantMode" class="form-item">
|
||||
<label class="form-label required">{{ $t('integrations.boundAgent') }}</label>
|
||||
<div class="agent-field-row">
|
||||
<t-select
|
||||
v-model="formData.target_agent_id"
|
||||
:options="agentOptions"
|
||||
filterable
|
||||
:placeholder="$t('integrations.selectAgentPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
@@ -173,11 +178,8 @@
|
||||
/>
|
||||
<p v-if="!editingChannel" class="form-desc">{{ $t('agentEditor.im.channelNameDefaultHint') }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="editingChannel && authStore.hasRole('admin')" class="setting-drawer__section im-drawer__section">
|
||||
<h4 class="setting-drawer__section-title">{{ $t('agentEditor.im.sectionStatus') }}</h4>
|
||||
<div class="setting-row setting-row--last">
|
||||
<div v-if="editingChannel && authStore.hasRole('admin')" class="setting-row setting-row--last">
|
||||
<div class="setting-info">
|
||||
<label>{{ $t('agentEditor.im.enabled') }}</label>
|
||||
</div>
|
||||
@@ -651,7 +653,7 @@ const drawerTitle = computed(() => {
|
||||
});
|
||||
|
||||
function validateWizardStep(step: number): boolean {
|
||||
if (step === 0 && props.tenantMode && !editingChannel.value && !formData.value.target_agent_id) {
|
||||
if (step === 0 && props.tenantMode && !formData.value.target_agent_id) {
|
||||
MessagePlugin.warning(t('integrations.selectAgentHint'));
|
||||
return false;
|
||||
}
|
||||
@@ -696,29 +698,42 @@ const formData = ref({
|
||||
credentials: defaultCredentials(),
|
||||
});
|
||||
|
||||
const channelMenuOptions = computed(() => ([
|
||||
{ content: t('common.delete'), value: 'delete', theme: 'error' },
|
||||
]));
|
||||
const channelMenuOptions = (channel: IMChannel | IMChannelOverview) => ([
|
||||
{
|
||||
content: channel.enabled ? t('common.off') : t('common.on'),
|
||||
value: 'toggle',
|
||||
},
|
||||
{ content: t('common.delete'), value: 'delete', theme: 'error' as const },
|
||||
]);
|
||||
|
||||
const channelSummary = (channel: IMChannel | IMChannelOverview) => {
|
||||
const parts = [
|
||||
platformLabel(channel.platform),
|
||||
channel.mode,
|
||||
channel.output_mode === 'stream' ? t('agentEditor.im.outputStream') : t('agentEditor.im.outputFull'),
|
||||
];
|
||||
if (channel.session_mode === 'thread') {
|
||||
parts.push(t('agentEditor.im.sessionModeThread'));
|
||||
function handleChannelMenuClick(
|
||||
data: { value?: string },
|
||||
channel: IMChannel | IMChannelOverview,
|
||||
) {
|
||||
if (data.value === 'toggle') {
|
||||
void handleToggle(channel);
|
||||
return;
|
||||
}
|
||||
if (props.tenantMode) {
|
||||
const agentName = agentLabel(channel.agent_id, (channel as IMChannelOverview).agent_name);
|
||||
if (agentName) parts.unshift(agentName);
|
||||
if (data.value === 'delete') {
|
||||
confirmDelete(channel.id);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
};
|
||||
}
|
||||
|
||||
function agentLabel(agentId: string, overviewName?: string): string {
|
||||
if (overviewName) return overviewName;
|
||||
return agents.value.find((agent) => agent.id === agentId)?.name || '';
|
||||
function agentDisplayName(channel: IMChannel | IMChannelOverview): string {
|
||||
return agentForChannel(channel)?.name || '';
|
||||
}
|
||||
|
||||
function agentForChannel(channel: IMChannel | IMChannelOverview): CustomAgent | undefined {
|
||||
const found = agents.value.find((agent) => agent.id === channel.agent_id);
|
||||
if (found) return found;
|
||||
const overviewName = (channel as IMChannelOverview).agent_name;
|
||||
if (!overviewName) return undefined;
|
||||
return {
|
||||
id: channel.agent_id,
|
||||
name: overviewName,
|
||||
is_builtin: false,
|
||||
config: {},
|
||||
};
|
||||
}
|
||||
|
||||
function platformLabel(platform: string): string {
|
||||
@@ -1007,6 +1022,9 @@ async function handleSave() {
|
||||
knowledge_base_id: formData.value.knowledge_base_id,
|
||||
credentials: formData.value.credentials,
|
||||
enabled: editingEnabled.value,
|
||||
...(props.tenantMode && formData.value.target_agent_id
|
||||
? { agent_id: formData.value.target_agent_id }
|
||||
: {}),
|
||||
});
|
||||
MessagePlugin.success(t('common.updateSuccess'));
|
||||
} else {
|
||||
@@ -1077,198 +1095,13 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@import './css/channel-panel-list.less';
|
||||
|
||||
.im-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.channels-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.channels-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
.channels-count {
|
||||
padding: 2px 8px;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
.channels-loading-wrap {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.channels-empty {
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
.channel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.channel-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--td-component-stroke);
|
||||
border-radius: 10px;
|
||||
background: var(--td-bg-color-container);
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease;
|
||||
|
||||
&--clickable {
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--td-brand-color-3, var(--td-brand-color));
|
||||
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--td-brand-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&--add {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 68px;
|
||||
border-style: dashed;
|
||||
background: transparent;
|
||||
color: var(--td-text-color-placeholder);
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: var(--td-brand-color);
|
||||
border-color: var(--td-brand-color);
|
||||
background: color-mix(in srgb, var(--td-brand-color) 6%, transparent);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--td-brand-color) 10%, transparent);
|
||||
color: var(--td-brand-color);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
&__badge {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__logo {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--td-text-color-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--td-text-color-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
&__more {
|
||||
flex-shrink: 0;
|
||||
padding: 2px;
|
||||
opacity: 0;
|
||||
color: var(--td-text-color-placeholder);
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .channel-card__more,
|
||||
&:focus-within .channel-card__more,
|
||||
&__actions:focus-within .channel-card__more {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-platform-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
.channels-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.channels-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
.channels-count {
|
||||
padding: 2px 8px;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
.channels-loading-wrap {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.channels-empty {
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
.channel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.channel-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--td-component-stroke);
|
||||
border-radius: 10px;
|
||||
background: var(--td-bg-color-container);
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease;
|
||||
|
||||
&--clickable {
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--td-brand-color-3, var(--td-brand-color));
|
||||
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--td-brand-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&--add {
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
border-style: dashed;
|
||||
background: transparent;
|
||||
color: var(--td-text-color-placeholder);
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: var(--td-brand-color);
|
||||
border-color: var(--td-brand-color);
|
||||
background: color-mix(in srgb, var(--td-brand-color) 6%, transparent);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.channel-card__badge {
|
||||
background: color-mix(in srgb, var(--td-brand-color) 10%, transparent);
|
||||
color: var(--td-brand-color);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.channel-card__title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
&__badge {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
color: var(--td-text-color-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__logo {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--td-text-color-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__agent-name {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--td-text-color-placeholder);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
|
||||
&--spacer {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
&__more {
|
||||
flex-shrink: 0;
|
||||
padding: 2px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.agent-field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
|
||||
:deep(.t-select) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
createEmbedSession,
|
||||
embedChatSessionStorageKey,
|
||||
exchangeEmbedSession,
|
||||
getEmbedConfig,
|
||||
getEmbedMessageList,
|
||||
@@ -19,18 +20,16 @@ import { applyEmbedLocale, readEmbedLocaleFromUrl, syncEmbedLocaleFromUrl } from
|
||||
|
||||
// Persist the chat session id per channel so a page refresh resumes the same
|
||||
// conversation (and its history) instead of silently starting a new session.
|
||||
const EMBED_SESSION_STORAGE_PREFIX = 'weknora-embed-session:'
|
||||
|
||||
const sessionStorageKey = (channelId: string) => `${EMBED_SESSION_STORAGE_PREFIX}${channelId}`
|
||||
|
||||
interface StoredSession {
|
||||
id: string
|
||||
sig: string
|
||||
/** Agent bound when the session was created; mismatch forces a new session. */
|
||||
agentId?: string
|
||||
}
|
||||
|
||||
function readStoredSession(channelId: string): StoredSession | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(sessionStorageKey(channelId))
|
||||
const raw = window.localStorage.getItem(embedChatSessionStorageKey(channelId))
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed.id === 'string' && typeof parsed.sig === 'string' && parsed.id) {
|
||||
@@ -46,9 +45,9 @@ function readStoredSession(channelId: string): StoredSession | null {
|
||||
function writeStoredSession(channelId: string, session: StoredSession | null) {
|
||||
try {
|
||||
if (session?.id) {
|
||||
window.localStorage.setItem(sessionStorageKey(channelId), JSON.stringify(session))
|
||||
window.localStorage.setItem(embedChatSessionStorageKey(channelId), JSON.stringify(session))
|
||||
} else {
|
||||
window.localStorage.removeItem(sessionStorageKey(channelId))
|
||||
window.localStorage.removeItem(embedChatSessionStorageKey(channelId))
|
||||
}
|
||||
} catch {
|
||||
// localStorage may be unavailable (private mode / disabled cookies).
|
||||
@@ -141,15 +140,20 @@ export function useEmbedBridge(channelId: Ref<string>) {
|
||||
applyEmbedLocale(res.data.default_locale, activeLocale)
|
||||
}
|
||||
|
||||
// Resume a persisted session when still valid; otherwise create a fresh one.
|
||||
// Resume a persisted session when still valid and still bound to the same
|
||||
// agent; otherwise create a fresh one (e.g. after rebinding the channel).
|
||||
const configAgentId = String(res.data.agent_id || '').trim()
|
||||
let resolved: StoredSession | null = null
|
||||
const stored = readStoredSession(id)
|
||||
if (stored && (await isStoredSessionValid(id, apiToken, stored))) {
|
||||
resolved = stored
|
||||
const agentMatches = !stored?.agentId || !configAgentId || stored.agentId === configAgentId
|
||||
if (stored && agentMatches && (await isStoredSessionValid(id, apiToken, stored))) {
|
||||
resolved = { ...stored, agentId: configAgentId || stored.agentId }
|
||||
} else {
|
||||
const sessionRes = await createEmbedSession(id, apiToken)
|
||||
const newId = sessionRes?.data?.id || ''
|
||||
if (newId) resolved = { id: newId, sig: sessionRes?.data?.sig || '' }
|
||||
if (newId) {
|
||||
resolved = { id: newId, sig: sessionRes?.data?.sig || '', agentId: configAgentId }
|
||||
}
|
||||
}
|
||||
if (!resolved) {
|
||||
loadError.value = t('embedPublish.sessionFailed')
|
||||
@@ -185,7 +189,8 @@ export function useEmbedBridge(channelId: Ref<string>) {
|
||||
const sessionRes = await createEmbedSession(id, apiToken)
|
||||
const newId = sessionRes?.data?.id || ''
|
||||
if (!newId) return
|
||||
const next: StoredSession = { id: newId, sig: sessionRes?.data?.sig || '' }
|
||||
const agentId = String(config.value?.agent_id || '').trim()
|
||||
const next: StoredSession = { id: newId, sig: sessionRes?.data?.sig || '', agentId }
|
||||
sessionSig.value = next.sig
|
||||
sessionId.value = next.id
|
||||
writeStoredSession(id, next)
|
||||
|
||||
@@ -2320,8 +2320,12 @@ export default {
|
||||
name: 'Name',
|
||||
nameDesc: 'For admin lists and channel identification',
|
||||
namePlaceholder: 'e.g. Website support',
|
||||
nameDefaultHint: 'Defaults to "{agent} · Web Embed" when an agent is selected. Leave blank on save to use the default name.',
|
||||
defaultChannelName: 'Web Embed',
|
||||
defaultChannelNameWithAgent: '{agent} · Web Embed',
|
||||
welcomeMessage: 'Welcome message',
|
||||
welcomePlaceholder: 'Hi! How can I help you?',
|
||||
welcomeMessageDesc: 'Shown in the embed chat before the visitor sends their first message; leave blank to hide',
|
||||
showSuggestedQuestions: 'Suggested questions',
|
||||
showSuggestedQuestionsDesc: 'Show starter prompts before the first visitor message, from agent config and linked knowledge bases',
|
||||
originsLabel: 'Allowed origins (one per line, at least one required)',
|
||||
@@ -2329,7 +2333,7 @@ export default {
|
||||
originsRequired: 'At least one allowed origin is required',
|
||||
originsInvalid: 'Invalid origin: {origin}',
|
||||
originsWildcardProd: "Wildcard origin '*' is not allowed in production",
|
||||
originsCount: '{n} allowed origins',
|
||||
originsCount: '{n} origins',
|
||||
originsPlaceholder: 'https://shop.example.com',
|
||||
rateLimitLabel: 'Requests per minute',
|
||||
rateLimitDesc: 'Per visitor IP, max embed API requests per minute; excess requests are temporarily rejected. Default 30.',
|
||||
@@ -2409,7 +2413,13 @@ export default {
|
||||
sectionAppearance: 'Appearance',
|
||||
sectionDeploy: 'Deploy to site',
|
||||
sectionWebhook: 'Event callbacks',
|
||||
deployIntro: 'After saving, follow these two steps to embed chat on your website.',
|
||||
stepChannel: 'Channel info',
|
||||
stepSecurity: 'Security',
|
||||
stepCapabilities: 'Capabilities',
|
||||
stepAppearance: 'Appearance',
|
||||
stepWebhook: 'Webhooks',
|
||||
stepDeploy: 'Deploy',
|
||||
deployIntro: 'Choose an embed style and copy the snippet below into your site. View or rotate the channel key at the bottom when you need credentials.',
|
||||
deployStepToken: 'Channel key',
|
||||
deployStepTokenDesc: 'Long-lived secret (em_…), like an API key. Shown on this page only when created or reset—store it safely.',
|
||||
deployStepEmbed: 'Copy embed code',
|
||||
@@ -5604,6 +5614,7 @@ export default {
|
||||
selectAgentDesc: 'Each integration channel is bound to one agent. Choose the agent to configure.',
|
||||
selectAgentPlaceholder: 'Choose an agent',
|
||||
selectAgentHint: 'Please select an agent first',
|
||||
boundAgent: 'Bound agent',
|
||||
manageChannel: 'Manage channel',
|
||||
goToCenter: 'Open Integrations',
|
||||
imOverview: {
|
||||
|
||||
@@ -3205,8 +3205,12 @@ export default {
|
||||
name: '이름',
|
||||
nameDesc: '관리 목록 및 채널 식별용',
|
||||
namePlaceholder: '예: 웹사이트 고객센터',
|
||||
nameDefaultHint: '기본값은 「에이전트명 · 웹 페이지 임베드」입니다. 직접 수정할 수 있으며, 비워 두고 저장해도 기본 이름이 사용됩니다.',
|
||||
defaultChannelName: '웹 페이지 임베드',
|
||||
defaultChannelNameWithAgent: '{agent} · 웹 페이지 임베드',
|
||||
welcomeMessage: '환영 메시지',
|
||||
welcomePlaceholder: '안녕하세요! 무엇을 도와드릴까요?',
|
||||
welcomeMessageDesc: '방문자가 임베드 채팅을 열고 첫 메시지를 보내기 전에 표시됩니다. 비우면 표시하지 않습니다',
|
||||
showSuggestedQuestions: '추천 질문',
|
||||
showSuggestedQuestionsDesc: '방문자 첫 메시지 전에 빠른 질문을 표시합니다(에이전트 설정 및 연결된 지식베이스에서 가져옴)',
|
||||
originsLabel: '허용 도메인(한 줄에 하나, 최소 1개)',
|
||||
@@ -3214,7 +3218,7 @@ export default {
|
||||
originsRequired: '허용 도메인을 최소 1개 입력하세요',
|
||||
originsInvalid: '잘못된 도메인: {origin}',
|
||||
originsWildcardProd: "프로덕션에서는 와일드카드 '*'를 사용할 수 없습니다",
|
||||
originsCount: '허용 도메인 {n}개',
|
||||
originsCount: '도메인 {n}개',
|
||||
originsPlaceholder: 'https://shop.example.com',
|
||||
rateLimitLabel: '분당 요청 상한',
|
||||
rateLimitDesc: '방문자 IP 기준, 분당 최대 임베드 API 요청 수; 초과 시 일시 거부. 기본 30.',
|
||||
@@ -3285,7 +3289,13 @@ export default {
|
||||
agentImageUploadDisabledHint: '현재 에이전트에서 이미지 업로드가 활성화되지 않았습니다. 방문자에게 업로드 버튼이 표시되지 않습니다. 에이전트 설정에서 이미지 업로드를 먼저 활성화하세요.',
|
||||
sectionAppearance: '외관 및 표시',
|
||||
sectionDeploy: '사이트에 배포',
|
||||
deployIntro: '저장 후 다음 두 단계로 웹사이트에 채팅을 임베드하세요.',
|
||||
stepChannel: '채널 정보',
|
||||
stepSecurity: '보안·제한',
|
||||
stepCapabilities: '대화 기능',
|
||||
stepAppearance: '외관·표시',
|
||||
stepWebhook: '이벤트',
|
||||
stepDeploy: '배포',
|
||||
deployIntro: '임베드 방식을 선택하고 아래 코드를 사이트에 붙여넣으세요. 인증이 필요하면 하단의 채널 키 영역에서 확인하거나 재설정하세요.',
|
||||
deployStepToken: '채널 키',
|
||||
deployStepTokenDesc: '장기 비밀키(em_…), API Key와 유사. 생성 또는 재설정 시 이 페이지에서만 표시 — 안전하게 보관하세요.',
|
||||
deployStepEmbed: '임베드 코드 복사',
|
||||
@@ -5615,6 +5625,7 @@ export default {
|
||||
selectAgentDesc: "각 통합 채널은 하나의 에이전트에 연결됩니다. 구성할 에이전트를 선택하세요",
|
||||
selectAgentPlaceholder: "에이전트를 선택하세요",
|
||||
selectAgentHint: "먼저 에이전트를 선택하세요",
|
||||
boundAgent: "연결된 에이전트",
|
||||
manageChannel: "채널 관리",
|
||||
goToCenter: "통합 센터로 이동",
|
||||
imOverview: {
|
||||
|
||||
@@ -2706,8 +2706,12 @@ export default {
|
||||
name: 'Название',
|
||||
nameDesc: 'Для админ-списков и идентификации канала',
|
||||
namePlaceholder: 'напр. Поддержка на сайте',
|
||||
nameDefaultHint: 'По умолчанию «{agent} · Встраивание на сайт». Можно изменить; при пустом поле при сохранении используется имя по умолчанию.',
|
||||
defaultChannelName: 'Встраивание на сайт',
|
||||
defaultChannelNameWithAgent: '{agent} · Встраивание на сайт',
|
||||
welcomeMessage: 'Приветствие',
|
||||
welcomePlaceholder: 'Здравствуйте! Чем могу помочь?',
|
||||
welcomeMessageDesc: 'Показывается во встроенном чате до первого сообщения посетителя; оставьте пустым, чтобы скрыть',
|
||||
showSuggestedQuestions: 'Рекомендуемые вопросы',
|
||||
showSuggestedQuestionsDesc: 'Показывать быстрые вопросы до первого сообщения посетителя — из настроек агента и связанных баз знаний',
|
||||
originsLabel: 'Разрешённые домены (по одному в строке, минимум один)',
|
||||
@@ -2715,7 +2719,7 @@ export default {
|
||||
originsRequired: 'Укажите хотя бы один разрешённый домен',
|
||||
originsInvalid: 'Неверный домен: {origin}',
|
||||
originsWildcardProd: "В production запрещён wildcard '*'",
|
||||
originsCount: '{n} разрешённых доменов',
|
||||
originsCount: '{n} дом.',
|
||||
originsPlaceholder: 'https://shop.example.com',
|
||||
rateLimitLabel: 'Запросов в минуту',
|
||||
rateLimitDesc: 'На IP посетителя, макс. запросов к API встраивания в минуту; избыток временно отклоняется. По умолчанию 30.',
|
||||
@@ -2786,7 +2790,13 @@ export default {
|
||||
agentImageUploadDisabledHint: 'У этого агента не включена загрузка изображений. Посетители не увидят кнопку загрузки, пока вы не включите её в настройках агента.',
|
||||
sectionAppearance: 'Внешний вид',
|
||||
sectionDeploy: 'Развернуть на сайте',
|
||||
deployIntro: 'После сохранения выполните два шага для встраивания чата на сайт.',
|
||||
stepChannel: 'Канал',
|
||||
stepSecurity: 'Безопасность',
|
||||
stepCapabilities: 'Возможности',
|
||||
stepAppearance: 'Внешний вид',
|
||||
stepWebhook: 'Webhook',
|
||||
stepDeploy: 'Развёртывание',
|
||||
deployIntro: 'Выберите способ встраивания и скопируйте код ниже на сайт. Ключ канала внизу — для аутентификации и сброса.',
|
||||
deployStepToken: 'Ключ канала',
|
||||
deployStepTokenDesc: 'Долгоживущий секрет (em_…), как API Key. Только на этой странице при создании/сбросе — храните надёжно.',
|
||||
deployStepEmbed: 'Копировать код встраивания',
|
||||
@@ -5437,6 +5447,7 @@ export default {
|
||||
selectAgentDesc: 'Каждый канал интеграции привязан к одному агенту. Выберите агента для настройки.',
|
||||
selectAgentPlaceholder: 'Выберите агента',
|
||||
selectAgentHint: 'Сначала выберите агента',
|
||||
boundAgent: 'Привязанный агент',
|
||||
manageChannel: 'Управлять каналом',
|
||||
goToCenter: 'Открыть интеграции',
|
||||
imOverview: {
|
||||
|
||||
@@ -3223,8 +3223,12 @@ export default {
|
||||
name: "名称",
|
||||
nameDesc: "后台管理用,用于列表与渠道识别",
|
||||
namePlaceholder: "例如:官网客服",
|
||||
nameDefaultHint: "默认为「智能体名 · 网页嵌入」,可自行修改;留空保存时也会使用该名称",
|
||||
defaultChannelName: "网页嵌入",
|
||||
defaultChannelNameWithAgent: "{agent} · 网页嵌入",
|
||||
welcomeMessage: "欢迎语",
|
||||
welcomePlaceholder: "你好,有什么可以帮您?",
|
||||
welcomeMessageDesc: "访客打开嵌入聊天后、尚未发言时展示;留空则不显示",
|
||||
showSuggestedQuestions: "推荐问题",
|
||||
showSuggestedQuestionsDesc: "访客未发言前展示快捷提问,内容来自智能体配置与关联知识库",
|
||||
originsLabel: "域名白名单(每行一个,至少填写一个)",
|
||||
@@ -3232,7 +3236,7 @@ export default {
|
||||
originsRequired: "请至少填写一个域名白名单",
|
||||
originsInvalid: "域名格式无效:{origin}",
|
||||
originsWildcardProd: "生产环境不允许使用通配符 *",
|
||||
originsCount: "白名单 {n} 个",
|
||||
originsCount: "{n} 个域名",
|
||||
originsPlaceholder: "https://shop.example.com",
|
||||
rateLimitLabel: "每分钟请求上限",
|
||||
rateLimitDesc: "按访客 IP 统计,每分钟最多允许的嵌入接口请求次数;超出后暂时拒绝访问,默认 30。",
|
||||
@@ -3312,7 +3316,13 @@ export default {
|
||||
sectionAppearance: "外观与展示",
|
||||
sectionDeploy: "嵌入到网页",
|
||||
sectionWebhook: "事件回调",
|
||||
deployIntro: "保存设置后,按以下两步将聊天嵌入到您的网站。",
|
||||
stepChannel: "渠道信息",
|
||||
stepSecurity: "安全限流",
|
||||
stepCapabilities: "对话能力",
|
||||
stepAppearance: "外观展示",
|
||||
stepWebhook: "事件回调",
|
||||
stepDeploy: "网页嵌入",
|
||||
deployIntro: "选择嵌入方式并复制下方代码到您的网页;需要鉴权或轮换密钥时,可在页面底部的「渠道密钥」区域操作。",
|
||||
deployStepToken: "渠道密钥",
|
||||
deployStepTokenDesc: "长期密钥(em_…),相当于 API Key。创建或重置时仅在本页显示,请妥善保存。",
|
||||
deployStepEmbed: "复制嵌入代码",
|
||||
@@ -5622,6 +5632,7 @@ export default {
|
||||
selectAgentDesc: "每个集成渠道绑定一个智能体,请先选择要配置的智能体",
|
||||
selectAgentPlaceholder: "请选择智能体",
|
||||
selectAgentHint: "请先选择一个智能体",
|
||||
boundAgent: "绑定智能体",
|
||||
manageChannel: "管理渠道",
|
||||
goToCenter: "前往集成中心",
|
||||
imOverview: {
|
||||
|
||||
@@ -17,6 +17,7 @@ import i18n from "./i18n";
|
||||
import { initTheme } from "@/composables/useTheme";
|
||||
import { initFont } from "@/composables/useFont";
|
||||
import { installTDesignIconOfflineGuard } from "@/utils/tdesign-icon-offline";
|
||||
import { installAutofillGuard } from "@/utils/disable-autofill";
|
||||
|
||||
// 必须在 Vue 组件挂载之前执行,避免 tdesign-icons 运行时请求 tdesign.gtimg.com
|
||||
installTDesignIconOfflineGuard();
|
||||
@@ -34,4 +35,5 @@ app.use(i18n);
|
||||
// 等首屏路由(含导航守卫、Lite 自动登录)完成后再挂载,避免先闪默认页再跳转
|
||||
router.isReady().finally(() => {
|
||||
app.mount("#app");
|
||||
installAutofillGuard();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Globally discourage browser/password-manager autofill on app inputs.
|
||||
* Skips elements that already declare autocomplete (e.g. login email).
|
||||
*/
|
||||
function guardElement(el: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement) {
|
||||
const explicit = el.getAttribute('autocomplete');
|
||||
if (explicit !== null && explicit !== '') return;
|
||||
if (el.closest('[data-allow-autofill]')) return;
|
||||
|
||||
if (el instanceof HTMLInputElement && el.type === 'password') {
|
||||
el.setAttribute('autocomplete', 'new-password');
|
||||
} else {
|
||||
el.setAttribute('autocomplete', 'off');
|
||||
}
|
||||
}
|
||||
|
||||
function guardTree(root: ParentNode) {
|
||||
if (root instanceof HTMLInputElement || root instanceof HTMLTextAreaElement || root instanceof HTMLSelectElement) {
|
||||
guardElement(root);
|
||||
}
|
||||
|
||||
root.querySelectorAll?.('input, textarea, select').forEach((node) => {
|
||||
guardElement(node as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement);
|
||||
});
|
||||
|
||||
root.querySelectorAll?.('form').forEach((form) => {
|
||||
if (!form.hasAttribute('autocomplete')) {
|
||||
form.setAttribute('autocomplete', 'off');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let installed = false;
|
||||
|
||||
export function installAutofillGuard() {
|
||||
if (installed || typeof document === 'undefined') return;
|
||||
installed = true;
|
||||
|
||||
guardTree(document);
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node instanceof HTMLElement) {
|
||||
guardTree(node);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
@@ -192,7 +192,7 @@
|
||||
|
||||
<t-form-item :label="$t('auth.password')" name="password">
|
||||
<t-input v-model="formData.password" :placeholder="$t('auth.passwordPlaceholder')" type="password"
|
||||
size="large" :disabled="loading" @enter="handleLogin" />
|
||||
autocomplete="current-password" size="large" :disabled="loading" @enter="handleLogin" />
|
||||
</t-form-item>
|
||||
|
||||
<t-button type="submit" theme="primary" size="large" block :loading="loading" class="submit-button">
|
||||
@@ -280,12 +280,12 @@
|
||||
|
||||
<t-form-item :label="$t('auth.password')" name="password">
|
||||
<t-input v-model="registerData.password" :placeholder="$t('auth.passwordPlaceholder')" type="password"
|
||||
size="large" :disabled="loading" />
|
||||
autocomplete="new-password" size="large" :disabled="loading" />
|
||||
</t-form-item>
|
||||
|
||||
<t-form-item :label="$t('auth.confirmPassword')" name="confirmPassword">
|
||||
<t-input v-model="registerData.confirmPassword" :placeholder="$t('auth.confirmPasswordPlaceholder')"
|
||||
type="password" size="large" :disabled="loading" @enter="handleRegister" />
|
||||
type="password" autocomplete="new-password" size="large" :disabled="loading" @enter="handleRegister" />
|
||||
</t-form-item>
|
||||
|
||||
<t-button type="submit" theme="primary" size="large" block :loading="loading" class="submit-button">
|
||||
|
||||
@@ -177,6 +177,12 @@ func (s *embedChannelService) Update(
|
||||
ch.AllowedOrigins = req.AllowedOrigins
|
||||
}
|
||||
}
|
||||
if trimmed := strings.TrimSpace(req.AgentID); trimmed != "" && trimmed != ch.AgentID {
|
||||
if _, err := s.ensureAgentOwned(ctx, tenantID, trimmed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch.AgentID = trimmed
|
||||
}
|
||||
if err := s.repo.Update(ctx, ch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
type stubEmbedChannelRepo struct {
|
||||
interfaces.EmbedChannelRepository
|
||||
ch *types.EmbedChannel
|
||||
}
|
||||
|
||||
func (r *stubEmbedChannelRepo) GetByID(_ context.Context, id string) (*types.EmbedChannel, error) {
|
||||
if r.ch == nil || r.ch.ID != id {
|
||||
return nil, nil
|
||||
}
|
||||
cp := *r.ch
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (r *stubEmbedChannelRepo) Update(_ context.Context, ch *types.EmbedChannel) error {
|
||||
cp := *ch
|
||||
r.ch = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestEmbedChannelUpdateAgentID(t *testing.T) {
|
||||
repo := &stubEmbedChannelRepo{
|
||||
ch: &types.EmbedChannel{
|
||||
ID: "ch-1",
|
||||
TenantID: 42,
|
||||
AgentID: "agent-old",
|
||||
Name: "Support",
|
||||
},
|
||||
}
|
||||
svc := &embedChannelService{
|
||||
repo: repo,
|
||||
agentService: &stubAgentForEmbed{
|
||||
agent: &types.CustomAgent{ID: "agent-new", TenantID: 42},
|
||||
},
|
||||
}
|
||||
enabled := true
|
||||
updated, err := svc.Update(
|
||||
context.Background(),
|
||||
42,
|
||||
"ch-1",
|
||||
&types.EmbedChannel{AgentID: "agent-new"},
|
||||
&enabled, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
if updated.AgentID != "agent-new" {
|
||||
t.Fatalf("updated.AgentID = %q, want agent-new", updated.AgentID)
|
||||
}
|
||||
if repo.ch.AgentID != "agent-new" {
|
||||
t.Fatalf("persisted AgentID = %q, want agent-new", repo.ch.AgentID)
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ type embedChannelRequest struct {
|
||||
DefaultLocale *string `json:"default_locale"`
|
||||
WebhookURL *string `json:"webhook_url"`
|
||||
WebhookSecret *string `json:"webhook_secret"`
|
||||
AgentID *string `json:"agent_id"`
|
||||
}
|
||||
|
||||
// isProductionMode reports whether the server runs in a hardened (release) mode.
|
||||
@@ -242,6 +243,9 @@ func (h *EmbedChannelHandler) UpdateEmbedChannel(c *gin.Context) {
|
||||
HeaderTitleMode: req.HeaderTitleMode,
|
||||
WidgetPosition: req.WidgetPosition,
|
||||
}
|
||||
if req.AgentID != nil {
|
||||
update.AgentID = strings.TrimSpace(*req.AgentID)
|
||||
}
|
||||
ch, err := h.embedSvc.Update(c.Request.Context(), tenantID, channelID, update, req.Enabled, req.ShowSuggestedQuestions, req.AllowWebSearch, req.AllowMemory, req.AllowFileUpload, req.DefaultLocale, req.WebhookURL, req.WebhookSecret)
|
||||
if err != nil {
|
||||
writeEmbedMgmtError(c, err)
|
||||
|
||||
@@ -196,6 +196,7 @@ func (h *IMHandler) UpdateIMChannel(c *gin.Context) {
|
||||
KnowledgeBaseID *string `json:"knowledge_base_id"`
|
||||
Credentials types.JSON `json:"credentials"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
AgentID *string `json:"agent_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -220,6 +221,16 @@ func (h *IMHandler) UpdateIMChannel(c *gin.Context) {
|
||||
if req.Enabled != nil {
|
||||
channel.Enabled = *req.Enabled
|
||||
}
|
||||
if req.AgentID != nil {
|
||||
newAgentID := strings.TrimSpace(*req.AgentID)
|
||||
if newAgentID != "" && newAgentID != channel.AgentID {
|
||||
if err := h.imService.SetChannelAgentID(c.Request.Context(), channel, newAgentID); err != nil {
|
||||
logger.Errorf(c.Request.Context(), "[IM] Update channel agent failed: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "agent not found"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.imService.UpdateChannel(channel); err != nil {
|
||||
logger.Errorf(c.Request.Context(), "[IM] Update channel failed: %v", err)
|
||||
|
||||
@@ -2501,6 +2501,23 @@ func (s *Service) CreateChannel(channel *IMChannel) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetChannelAgentID validates and assigns a new agent for an existing channel.
|
||||
func (s *Service) SetChannelAgentID(ctx context.Context, channel *IMChannel, agentID string) error {
|
||||
agentID = strings.TrimSpace(agentID)
|
||||
if agentID == "" {
|
||||
return fmt.Errorf("agent_id is required")
|
||||
}
|
||||
agent, err := s.agentService.GetAgentByID(ctx, agentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if agent == nil || agent.TenantID != channel.TenantID {
|
||||
return fmt.Errorf("agent not found")
|
||||
}
|
||||
channel.AgentID = agentID
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateChannel updates a channel and restarts it if needed.
|
||||
// Returns a duplicate_bot error if the bot identity is already used by another channel.
|
||||
func (s *Service) UpdateChannel(channel *IMChannel) error {
|
||||
|
||||
Reference in New Issue
Block a user