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:
wizardchen
2026-06-24 19:27:39 +08:00
committed by lyingbug
parent 2d6d1e3275
commit 635d7c9643
17 changed files with 1002 additions and 669 deletions
+33
View File
@@ -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
+62 -229
View File
@@ -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;
}
}
+17 -12
View File
@@ -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)
+13 -2
View File
@@ -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: {
+13 -2
View File
@@ -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: {
+13 -2
View File
@@ -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: {
+13 -2
View File
@@ -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: {
+2
View File
@@ -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();
});
+52
View File
@@ -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 });
}
+3 -3
View File
@@ -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)
}
}
+4
View File
@@ -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)
+11
View File
@@ -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)
+17
View File
@@ -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 {