feat(embed): session sidebar grouping and embed theme alignment (#1657)

Group embed sessions in the sidebar by channel, replace session source filter
with grouping buckets, and align shared chat components with embed theme colors.
This commit is contained in:
wizardchen
2026-06-12 22:40:18 +08:00
committed by lyingbug
parent 08852ab19d
commit 20b01a6cd5
25 changed files with 2365 additions and 2040 deletions
@@ -0,0 +1,123 @@
<template>
<t-popup
v-model:visible="visible"
trigger="click"
placement="bottom-left"
:overlay-style="{ padding: 0 }"
:overlay-inner-style="{ padding: 0 }"
>
<template #content>
<div class="session-group-card">
<div class="session-group-header">{{ headerLabel }}</div>
<div class="session-group-list">
<button
v-for="item in modes"
:key="item.value"
type="button"
class="session-group-row"
:class="{ active: item.value === current }"
@click="handleSelect(item.value)"
>
<span class="session-group-row-name">{{ item.label }}</span>
<t-icon
v-if="item.value === current"
name="check"
class="session-group-row-check"
size="14px"
/>
</button>
</div>
</div>
</template>
<slot />
</t-popup>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { SessionGroupMode } from './sessionGrouping'
interface GroupModeItem {
value: SessionGroupMode
label: string
}
defineProps<{
modes: GroupModeItem[]
current: SessionGroupMode
headerLabel: string
}>()
const emit = defineEmits<{
(e: 'select', value: SessionGroupMode): void
}>()
const visible = ref(false)
const handleSelect = (value: SessionGroupMode): void => {
visible.value = false
emit('select', value)
}
</script>
<style scoped lang="less">
.session-group-card {
min-width: 180px;
max-width: 240px;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.session-group-header {
padding: 8px 12px 6px;
font-size: 11px;
font-weight: 600;
color: var(--td-text-color-placeholder);
border-bottom: 1px solid var(--td-component-border);
user-select: none;
}
.session-group-list {
padding: 6px;
display: flex;
flex-direction: column;
gap: 1px;
}
.session-group-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--td-text-color-primary);
font-size: 13px;
line-height: 1.4;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
text-align: left;
width: 100%;
&:hover {
background: var(--td-bg-color-secondarycontainer);
}
&.active {
color: var(--td-brand-color);
font-weight: 500;
}
}
.session-group-row-name {
flex: 1 1 auto;
}
.session-group-row-check {
flex: 0 0 auto;
color: var(--td-brand-color);
}
</style>
@@ -0,0 +1,56 @@
<template>
<div
:class="[
'submenu_item',
!batchMode && activePath === item.path ? 'submenu_item_active' : '',
batchMode && selectedIds.includes(item.id) ? 'submenu_item_selected' : '',
batchMode ? 'submenu_item_batch' : '',
]"
@mouseenter="emit('hover-in')"
@mouseleave="emit('hover-out')"
@click="batchMode ? emit('toggle-select') : emit('navigate')"
>
<t-checkbox
v-if="batchMode"
class="batch-checkbox"
:checked="selectedIds.includes(item.id)"
@click.stop
@change="emit('toggle-select')"
/>
<span class="submenu_title" :class="batchMode ? 'submenu_title--batch' : ''" :title="item.title">
<t-icon v-if="item.is_pinned" name="pin" class="submenu_pin_icon" />
<span class="submenu_title-text">{{ item.title }}</span>
</span>
<t-dropdown
v-if="!batchMode"
:options="menuOptions"
placement="bottom-right"
trigger="click"
@click="emit('menu-click', $event)"
>
<div @click.stop class="menu-more-wrap">
<t-icon name="ellipsis" class="menu-more" />
</div>
</t-dropdown>
</div>
</template>
<script setup lang="ts">
defineProps<{
item: { id: string; path: string; title: string; is_pinned?: boolean }
batchMode: boolean
activePath: string
selectedIds: string[]
menuOptions: any[]
/** 渠道文件夹下的会话(样式与聊天区会话共用文案列对齐) */
nested?: boolean
}>()
const emit = defineEmits<{
(e: 'navigate'): void
(e: 'toggle-select'): void
(e: 'menu-click', data: { value: string }): void
(e: 'hover-in'): void
(e: 'hover-out'): void
}>()
</script>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,98 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
EMBED_SESSION_MARKER_PREFIX,
classifyDateBucket,
configuredPlatforms,
groupSessions,
groupSessionsBySource,
resolveSessionOrigin,
shouldShowSessionSourceBadge,
} from './sessionGrouping.ts'
const sourceLabels = {
web: 'Web',
embedFallback: 'Embed',
embedChannel: (id: string) => `Embed ${id}`,
imPlatform: (p: string) => `IM ${p}`,
}
test('resolveSessionOrigin distinguishes web, IM, and embed sessions', () => {
assert.deepEqual(resolveSessionOrigin({ id: '1' }), { kind: 'web' })
assert.deepEqual(resolveSessionOrigin({ id: '2', im_platform: 'feishu' }), {
kind: 'im',
platform: 'feishu',
})
assert.deepEqual(
resolveSessionOrigin({
id: '3',
description: `${EMBED_SESSION_MARKER_PREFIX}ch-1`,
}),
{ kind: 'embed', channelId: 'ch-1' },
)
})
test('configuredPlatforms returns distinct platform keys in first-seen order', () => {
const channels = [
{ platform: 'feishu' },
{ platform: 'wecom' },
{ platform: 'feishu' },
{ platform: '' },
]
assert.deepEqual(configuredPlatforms(channels), ['feishu', 'wecom'])
})
test('groupSessionsBySource orders web, configured IM, then embed channels', () => {
const sessions = [
{ id: 'w', title: 'web chat' },
{ id: 'f', title: 'feishu', im_platform: 'feishu' },
{ id: 'e', title: 'embed', description: `${EMBED_SESSION_MARKER_PREFIX}ec-1` },
]
const groups = groupSessionsBySource(sessions, sourceLabels, { 'ec-1': 'Help widget' }, ['feishu'], 'Pinned')
assert.deepEqual(
groups.map((g) => g.key),
['web', 'im:feishu', 'embed:ec-1'],
)
assert.equal(groups[2]?.label, 'Help widget')
})
test('groupSessions date mode keeps pinned sessions in their own bucket', () => {
const now = new Date().toISOString()
const groups = groupSessions(
'date',
[
{ id: 'p', is_pinned: true, updated_at: now },
{ id: 't', updated_at: now },
],
{
pinnedLabel: 'Pinned',
bucketLabels: {
pinned: 'Pinned',
today: 'Today',
yesterday: 'Yesterday',
last7Days: '7d',
last30Days: '30d',
lastYear: 'Year',
earlier: 'Earlier',
},
categorizeDate: () => 'today',
sourceLabels,
embedChannelNames: {},
configuredImPlatforms: [],
},
)
assert.deepEqual(
groups.map((g) => g.key),
['pinned', 'today'],
)
})
test('classifyDateBucket buckets recent sessions as today', () => {
assert.equal(classifyDateBucket(new Date().toISOString()), 'today')
})
test('shouldShowSessionSourceBadge is off when channels use separate folders', () => {
assert.equal(shouldShowSessionSourceBadge('date'), false)
assert.equal(shouldShowSessionSourceBadge('none'), false)
})
+277
View File
@@ -0,0 +1,277 @@
// Pure logic for sidebar session grouping (replaces the old source filter).
export type SessionGroupMode = 'none' | 'date'
export const SESSION_GROUP_MODE_STORAGE_KEY = 'weknora:session-group-mode'
export const DEFAULT_SESSION_GROUP_MODE: SessionGroupMode = 'none'
/** Mirrors backend types.EmbedSessionMarkerPrefix */
export const EMBED_SESSION_MARKER_PREFIX = 'embed_channel:'
export interface SessionForGrouping {
id: string
title?: string
is_pinned?: boolean
created_at?: string
updated_at?: string
im_platform?: string
description?: string
originalIndex?: number
}
export interface SessionGroup<T extends SessionForGrouping = SessionForGrouping> {
key: string
label: string
items: T[]
}
export type SessionOrigin =
| { kind: 'web' }
| { kind: 'im'; platform: string }
| { kind: 'embed'; channelId: string }
export interface GroupModeOption {
value: SessionGroupMode
label: string
}
export interface SourceGroupLabels {
web: string
embedFallback: string
embedChannel: (channelId: string) => string
imPlatform: (platform: string) => string
}
// Distinct platform keys from the tenant's IM channel overview, in first-seen order.
export function configuredPlatforms(channels: Array<{ platform: string }>): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const c of channels) {
if (!c.platform || seen.has(c.platform)) continue
seen.add(c.platform)
out.push(c.platform)
}
return out
}
export function buildGroupModeOptions(labels: {
none: string
date: string
}): GroupModeOption[] {
return [
{ value: 'none', label: labels.none },
{ value: 'date', label: labels.date },
]
}
export function readStoredGroupMode(): SessionGroupMode {
if (typeof localStorage === 'undefined') return DEFAULT_SESSION_GROUP_MODE
const raw = localStorage.getItem(SESSION_GROUP_MODE_STORAGE_KEY)
if (raw === 'none' || raw === 'date') return raw
// Legacy "source" mode — channels are now separate folders like OpenAI projects.
if (raw === 'source') return 'none'
return DEFAULT_SESSION_GROUP_MODE
}
export function storeGroupMode(mode: SessionGroupMode): void {
if (typeof localStorage === 'undefined') return
localStorage.setItem(SESSION_GROUP_MODE_STORAGE_KEY, mode)
}
export function resolveSessionOrigin(session: SessionForGrouping): SessionOrigin {
const platform = (session.im_platform || '').trim().toLowerCase()
if (platform) return { kind: 'im', platform }
const desc = (session.description || '').trim()
if (desc.startsWith(EMBED_SESSION_MARKER_PREFIX)) {
const channelId = desc.slice(EMBED_SESSION_MARKER_PREFIX.length).trim()
if (channelId) return { kind: 'embed', channelId }
}
return { kind: 'web' }
}
export function originGroupKey(origin: SessionOrigin): string {
switch (origin.kind) {
case 'web':
return 'web'
case 'im':
return `im:${origin.platform}`
case 'embed':
return `embed:${origin.channelId}`
}
}
function labelForOrigin(
origin: SessionOrigin,
labels: SourceGroupLabels,
embedChannelNames: Record<string, string>,
): string {
switch (origin.kind) {
case 'web':
return labels.web
case 'im':
return labels.imPlatform(origin.platform)
case 'embed': {
const name = embedChannelNames[origin.channelId]?.trim()
return name || labels.embedChannel(origin.channelId)
}
}
}
export function classifyDateBucket(dateStr: string | undefined): DateBucketKey {
if (!dateStr) return 'earlier'
const date = new Date(dateStr)
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000)
const sevenDaysAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000)
const thirtyDaysAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000)
const oneYearAgo = new Date(today.getTime() - 365 * 24 * 60 * 60 * 1000)
const sessionDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
if (sessionDate.getTime() >= today.getTime()) return 'today'
if (sessionDate.getTime() >= yesterday.getTime()) return 'yesterday'
if (date.getTime() >= sevenDaysAgo.getTime()) return 'last7Days'
if (date.getTime() >= thirtyDaysAgo.getTime()) return 'last30Days'
if (date.getTime() >= oneYearAgo.getTime()) return 'lastYear'
return 'earlier'
}
const DATE_BUCKET_ORDER = [
'pinned',
'today',
'yesterday',
'last7Days',
'last30Days',
'lastYear',
'earlier',
] as const
export type DateBucketKey = (typeof DATE_BUCKET_ORDER)[number]
export function groupSessionsByDate<T extends SessionForGrouping>(
sessions: T[],
bucketLabels: Record<DateBucketKey, string>,
categorize: (session: T) => DateBucketKey,
): SessionGroup<T>[] {
const buckets = new Map<DateBucketKey, T[]>()
for (const key of DATE_BUCKET_ORDER) buckets.set(key, [])
for (const session of sessions) {
const bucket: DateBucketKey = session.is_pinned ? 'pinned' : categorize(session)
buckets.get(bucket)!.push(session)
}
return DATE_BUCKET_ORDER
.filter((key) => (buckets.get(key)?.length ?? 0) > 0)
.map((key) => ({
key,
label: bucketLabels[key],
items: buckets.get(key)!,
}))
}
export function groupSessionsBySource<T extends SessionForGrouping>(
sessions: T[],
labels: SourceGroupLabels,
embedChannelNames: Record<string, string>,
configuredImPlatforms: string[],
pinnedLabel: string,
): SessionGroup<T>[] {
const pinned: T[] = []
const byKey = new Map<string, T[]>()
for (const session of sessions) {
if (session.is_pinned) {
pinned.push(session)
continue
}
const origin = resolveSessionOrigin(session)
const key = originGroupKey(origin)
if (!byKey.has(key)) byKey.set(key, [])
byKey.get(key)!.push(session)
}
const orderedKeys: string[] = []
if (byKey.has('web')) orderedKeys.push('web')
const seenIm = new Set<string>()
for (const platform of configuredImPlatforms) {
const key = `im:${platform}`
if (byKey.has(key)) {
orderedKeys.push(key)
seenIm.add(platform)
}
}
const extraIm = Array.from(byKey.keys())
.filter((k) => k.startsWith('im:') && !seenIm.has(k.slice(3)))
.sort((a, b) => a.localeCompare(b))
orderedKeys.push(...extraIm)
const embedKeys = Array.from(byKey.keys())
.filter((k) => k.startsWith('embed:'))
.sort((a, b) => {
const idA = a.slice(7)
const idB = b.slice(7)
const nameA = embedChannelNames[idA] || idA
const nameB = embedChannelNames[idB] || idB
return nameA.localeCompare(nameB)
})
orderedKeys.push(...embedKeys)
const groups: SessionGroup<T>[] = []
if (pinned.length > 0) {
groups.push({ key: 'pinned', label: pinnedLabel, items: pinned })
}
for (const key of orderedKeys) {
const items = byKey.get(key)
if (!items?.length) continue
const sample = items[0]
const origin = resolveSessionOrigin(sample)
groups.push({
key,
label: labelForOrigin(origin, labels, embedChannelNames),
items,
})
}
return groups
}
export function groupSessionsFlat<T extends SessionForGrouping>(
sessions: T[],
pinnedLabel: string,
): SessionGroup<T>[] {
const pinned = sessions.filter((s) => s.is_pinned)
const rest = sessions.filter((s) => !s.is_pinned)
const groups: SessionGroup<T>[] = []
if (pinned.length > 0) groups.push({ key: 'pinned', label: pinnedLabel, items: pinned })
if (rest.length > 0) groups.push({ key: 'all', label: '', items: rest })
return groups
}
export function groupSessions<T extends SessionForGrouping>(
mode: SessionGroupMode,
sessions: T[],
opts: {
pinnedLabel: string
bucketLabels: Record<DateBucketKey, string>
categorizeDate: (session: T) => DateBucketKey
sourceLabels: SourceGroupLabels
embedChannelNames: Record<string, string>
configuredImPlatforms: string[]
},
): SessionGroup<T>[] {
if (!sessions.length) return []
switch (mode) {
case 'none':
return groupSessionsFlat(sessions, opts.pinnedLabel)
case 'date':
default:
return groupSessionsByDate(sessions, opts.bucketLabels, opts.categorizeDate)
}
}
/** Chat list rows show source badges when channels live in separate folders. */
export function shouldShowSessionSourceBadge(_mode: SessionGroupMode): boolean {
return false
}
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
applyBucketCountProbe,
bucketVisible,
createEmptyBucket,
prependSessionToWebBucket,
type BucketDefinition,
} from './sessionSidebarBuckets.ts'
const imDef: BucketDefinition = {
key: 'im:feishu',
apiSource: 'feishu',
label: 'Feishu',
kind: 'im',
platform: 'feishu',
}
test('bucketVisible hides channel buckets until count is known', () => {
const bucket = createEmptyBucket(imDef)
assert.equal(bucketVisible(bucket), false)
})
test('bucketVisible hides channel buckets with zero sessions after probe', () => {
const bucket = applyBucketCountProbe(createEmptyBucket(imDef), 0)
assert.equal(bucketVisible(bucket), false)
})
test('bucketVisible shows channel buckets with sessions after probe', () => {
const bucket = applyBucketCountProbe(createEmptyBucket(imDef), 3)
assert.equal(bucketVisible(bucket), true)
})
test('prependSessionToWebBucket inserts new session at front and bumps total', () => {
const webDef: BucketDefinition = {
key: 'web',
apiSource: 'web',
label: 'Chats',
kind: 'web',
}
const bucket = { ...createEmptyBucket(webDef), total: 2, items: [{ id: 'a' }] }
const next = prependSessionToWebBucket(bucket, { id: 'b', title: 'New' })
assert.deepEqual(next.items.map((s) => s.id), ['b', 'a'])
assert.equal(next.total, 3)
})
test('prependSessionToWebBucket is idempotent for existing session', () => {
const webDef: BucketDefinition = {
key: 'web',
apiSource: 'web',
label: 'Chats',
kind: 'web',
}
const bucket = { ...createEmptyBucket(webDef), total: 1, items: [{ id: 'a' }] }
const next = prependSessionToWebBucket(bucket, { id: 'a', title: 'Same' })
assert.equal(next, bucket)
})
@@ -0,0 +1,172 @@
// OpenAI-style sidebar: channel folders (IM / embed) + flat/date-grouped chats,
// each bucket paginates independently via the API `source` param.
import type { SessionForGrouping } from './sessionGrouping'
export const SIDEBAR_BUCKET_PAGE_SIZE = 30
export type SidebarBucketKind = 'web' | 'im' | 'embed'
export interface SidebarSessionBucket {
key: string
apiSource: string
label: string
kind: SidebarBucketKind
platform?: string
page: number
total: number
items: SessionForGrouping[]
loaded: boolean
loading: boolean
/** 已通过轻量 count 探测(page_size=1),用于隐藏无会话的渠道文件夹 */
countKnown: boolean
}
export interface BucketDefinition {
key: string
apiSource: string
label: string
kind: SidebarBucketKind
platform?: string
}
export function createEmptyBucket(def: BucketDefinition): SidebarSessionBucket {
return {
...def,
page: 0,
total: 0,
items: [],
loaded: false,
loading: false,
countKnown: false,
}
}
export function bucketHasMore(bucket: SidebarSessionBucket): boolean {
return bucket.items.length < bucket.total
}
export function bucketVisible(bucket: SidebarSessionBucket): boolean {
if (!isChannelBucket(bucket)) return true
if (!bucket.countKnown) return false
return bucket.total > 0
}
export function applyBucketCountProbe(
bucket: SidebarSessionBucket,
total: number,
): SidebarSessionBucket {
return { ...bucket, total, countKnown: true }
}
export function isChannelBucket(bucket: SidebarSessionBucket): boolean {
return bucket.kind === 'im' || bucket.kind === 'embed'
}
export function isChannelBucketKey(key: string): boolean {
return key.startsWith('im:') || key.startsWith('embed:')
}
export function buildBucketDefinitions(
imPlatforms: string[],
embedChannels: Record<string, string>,
labels: {
web: string
imPlatform: (platform: string) => string
embedChannel: (name: string) => string
},
): BucketDefinition[] {
const imDefs = imPlatforms.map((platform) => ({
key: `im:${platform}`,
apiSource: platform,
label: labels.imPlatform(platform),
kind: 'im' as const,
platform,
}))
const embedDefs = Object.entries(embedChannels).map(([id, name]) => ({
key: `embed:${id}`,
apiSource: `embed:${id}`,
label: labels.embedChannel(name || id.slice(0, 8)),
kind: 'embed' as const,
}))
return [
...imDefs,
...embedDefs,
{
key: 'web',
apiSource: 'web',
label: labels.web,
kind: 'web',
},
]
}
export function mergeBucketPage(
bucket: SidebarSessionBucket,
rows: SessionForGrouping[],
total: number,
page: number,
): SidebarSessionBucket {
const seen = new Set(bucket.items.map((s) => s.id))
const merged = [...bucket.items]
for (const row of rows) {
if (seen.has(row.id)) continue
seen.add(row.id)
merged.push(row)
}
return {
...bucket,
page,
total,
items: merged,
loaded: true,
loading: false,
}
}
export function flattenBucketItems(
buckets: Record<string, SidebarSessionBucket>,
order: string[],
): SessionForGrouping[] {
const out: SessionForGrouping[] = []
const seen = new Set<string>()
for (const key of order) {
const bucket = buckets[key]
if (!bucket) continue
for (const item of bucket.items) {
if (seen.has(item.id)) continue
seen.add(item.id)
out.push(item)
}
}
return out
}
export function prependSessionToWebBucket(
bucket: SidebarSessionBucket,
session: SessionForGrouping,
): SidebarSessionBucket {
if (bucket.items.some((row) => row.id === session.id)) return bucket
return {
...bucket,
items: [session, ...bucket.items],
total: bucket.total + 1,
loaded: true,
}
}
export function removeSessionFromBuckets(
buckets: Record<string, SidebarSessionBucket>,
sessionId: string,
): Record<string, SidebarSessionBucket> {
const next: Record<string, SidebarSessionBucket> = {}
for (const [key, bucket] of Object.entries(buckets)) {
const items = bucket.items.filter((s) => s.id !== sessionId)
next[key] = {
...bucket,
items,
total: Math.max(0, bucket.total - (items.length < bucket.items.length ? 1 : 0)),
}
}
return next
}
@@ -1,48 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
DEFAULT_SESSION_SOURCE,
buildSourceOptions,
configuredPlatforms,
shouldShowSourceFilter,
} from './sessionSourceFilter.ts'
test('defaults the sidebar to web sessions so IM stays hidden until opted into', () => {
assert.equal(DEFAULT_SESSION_SOURCE, 'web')
})
test('source filter only appears once at least one IM channel is configured', () => {
assert.equal(shouldShowSourceFilter([]), false)
assert.equal(shouldShowSourceFilter(['feishu']), true)
assert.equal(shouldShowSourceFilter(['feishu', 'wecom']), true)
})
test('configuredPlatforms returns distinct platform keys in first-seen order', () => {
const channels = [
{ platform: 'feishu' },
{ platform: 'wecom' },
{ platform: 'feishu' }, // duplicate: a second feishu channel
{ platform: '' }, // malformed row: ignored
]
assert.deepEqual(configuredPlatforms(channels), ['feishu', 'wecom'])
})
test('configuredPlatforms tolerates an empty overview', () => {
assert.deepEqual(configuredPlatforms([]), [])
})
test('buildSourceOptions puts web first, then platforms with injected labels', () => {
const opts = buildSourceOptions(['feishu', 'wecom'], 'My chats', (p) => `LABEL:${p}`)
assert.deepEqual(opts, [
{ value: 'web', label: 'My chats', platform: null },
{ value: 'feishu', label: 'LABEL:feishu', platform: 'feishu' },
{ value: 'wecom', label: 'LABEL:wecom', platform: 'wecom' },
])
})
test('buildSourceOptions with no IM platforms yields just the web option', () => {
assert.deepEqual(buildSourceOptions([], 'My chats', (p) => p), [
{ value: 'web', label: 'My chats', platform: null },
])
})
@@ -1,57 +0,0 @@
// Pure logic for the sidebar's session source filter (issue #1560).
//
// IM-channel sessions are auto-generated operational traffic, not the user's own
// chats, and IM is an optional module. So the sidebar defaults to web sessions
// and only surfaces a quiet source filter for tenants that actually configured a
// channel — keeping IM low-key rather than promoting it to a first-class group.
// 'web' = the user's own chats (no IM mapping); any other value is a platform
// key (e.g. 'feishu') selecting that channel's sessions. Sent verbatim as the
// `source` query param. The `(string & {})` arm keeps 'web' as an autocomplete
// hint instead of letting the union collapse to a bare `string`.
export type SessionSource = 'web' | (string & {})
export const DEFAULT_SESSION_SOURCE: SessionSource = 'web'
// The source filter control is only rendered once the tenant has at least one
// configured IM channel — tenants that never enabled IM see an unchanged,
// channel-free sidebar.
export function shouldShowSourceFilter(configured: string[]): boolean {
return configured.length > 0
}
// Distinct platform keys from the tenant's IM channel overview, in first-seen
// order. Duplicates (multiple channels on one platform) collapse to one option,
// and malformed rows with no platform are dropped.
export function configuredPlatforms(channels: Array<{ platform: string }>): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const c of channels) {
if (!c.platform || seen.has(c.platform)) continue
seen.add(c.platform)
out.push(c.platform)
}
return out
}
export interface SourceOption {
value: SessionSource
label: string
// null for the user's own web chats; the platform key for an IM source.
platform: string | null
}
// Build the source switcher's options: the user's own web chats first, then each
// configured IM platform in first-seen order. Labels are injected so this stays
// pure and unit-testable (no i18n dependency); logos are resolved by the caller
// (where the platform→asset map lives), keeping the dropdown component dumb.
export function buildSourceOptions(
platforms: string[],
webLabel: string,
labelForPlatform: (platform: string) => string,
): SourceOption[] {
return [
{ value: DEFAULT_SESSION_SOURCE, label: webLabel, platform: null },
...platforms.map((p) => ({ value: p, label: labelForPlatform(p), platform: p })),
]
}
+5 -3
View File
@@ -1502,9 +1502,11 @@ const confirmDelete = () => {
})
}
const handleEditorSuccess = () => {
editorVisible.value = false
editingAgent.value = null
const handleEditorSuccess = (agent?: CustomAgent) => {
if (agent) {
editingAgent.value = agent
editorMode.value = 'edit'
}
fetchList(true)
}
@@ -1,11 +1,11 @@
<template>
<div ref="rootElement" class="agent-stream-display">
<div ref="rootElement" class="agent-stream-display" :class="{ 'is-embedded': embeddedMode }">
<!-- Collapsed intermediate steps (tree root) -->
<div v-if="shouldShowCollapsedSteps" class="tree-container">
<div class="tree-root" @click="toggleIntermediateSteps">
<div class="tree-root-title">
<img :src="agentIcon" alt="" />
<span class="icon-mask tree-root-icon" :style="maskIconStyle(agentIcon, 16)" aria-hidden="true" />
<span v-html="intermediateStepsSummaryHtml"></span>
</div>
<div class="tree-root-toggle">
@@ -34,7 +34,7 @@
<div class="action-card" :class="{ 'action-pending': isThinkingActive(event.event_id) }">
<div class="action-header" @click="toggleEvent(event.event_id)">
<div class="action-title">
<img class="action-title-icon" :src="thinkingIcon" alt="" />
<span class="action-title-icon icon-mask" :style="maskIconStyle(thinkingIcon)" aria-hidden="true" />
<span v-if="event.title" class="action-name action-preamble-title">{{ event.title }}</span>
<span v-else-if="isEventExpanded(event.event_id)" class="action-name">{{ $t('agent.think') }}</span>
<span v-else-if="getThinkingSummary(event)" class="action-summary">{{ getThinkingSummary(event) }}</span>
@@ -56,7 +56,7 @@
<div class="action-card" :class="{ 'action-pending': event.pending || isThinkingActive(event.tool_call_id) }">
<div class="action-header" @click="toggleEvent(event.tool_call_id)">
<div class="action-title">
<img class="action-title-icon" :src="thinkingIcon" alt="" />
<span class="action-title-icon icon-mask" :style="maskIconStyle(thinkingIcon)" aria-hidden="true" />
<span class="action-name">{{ $t('agent.think') }}</span>
<span v-if="event.tool_data?.thought_number" class="action-badge">{{ event.tool_data.thought_number }}/{{ event.tool_data.total_thoughts }}</span>
<span v-if="getThinkingSummary(event) && !isEventExpanded(event.tool_call_id)" class="action-summary">{{ getThinkingSummary(event) }}</span>
@@ -100,7 +100,12 @@
>
<div class="action-header" @click="handleActionHeaderClick(event)" :class="{ 'no-results': !hasResults(event) }">
<div class="action-title">
<img v-if="event.tool_name && !isBookIcon(event.tool_name)" class="action-title-icon" :src="getToolIcon(event.tool_name)" alt="" />
<span
v-if="event.tool_name && !isBookIcon(event.tool_name)"
class="action-title-icon icon-mask"
:style="maskIconStyle(getToolIcon(event.tool_name))"
aria-hidden="true"
/>
<t-icon v-if="event.tool_name && isBookIcon(event.tool_name)" class="action-title-icon" name="book" />
<t-tooltip v-if="event.tool_name === 'todo_write' && event.tool_data?.steps" :content="t('agent.updatePlan')" placement="top">
<span class="action-name">{{ $t('agent.updatePlan') }}</span>
@@ -184,7 +189,7 @@
<div class="action-card" :class="{ 'action-pending': isThinkingActive(event.event_id) }">
<div class="action-header" @click="toggleEvent(event.event_id)">
<div class="action-title">
<img class="action-title-icon" :src="thinkingIcon" alt="" />
<span class="action-title-icon icon-mask" :style="maskIconStyle(thinkingIcon)" aria-hidden="true" />
<span v-if="event.title" class="action-name action-preamble-title">{{ event.title }}</span>
<span v-else class="action-name">{{ $t('agent.think') }}</span>
<span v-if="!event.title && getThinkingSummary(event) && !isEventExpanded(event.event_id)" class="action-summary">{{ getThinkingSummary(event) }}</span>
@@ -222,7 +227,7 @@
<div class="action-card" :class="{ 'action-pending': event.pending || isThinkingActive(event.tool_call_id) }">
<div class="action-header" @click="toggleEvent(event.tool_call_id)">
<div class="action-title">
<img class="action-title-icon" :src="thinkingIcon" alt="" />
<span class="action-title-icon icon-mask" :style="maskIconStyle(thinkingIcon)" aria-hidden="true" />
<span class="action-name">{{ $t('agent.think') }}</span>
<span v-if="event.tool_data?.thought_number" class="action-badge">{{ event.tool_data.thought_number }}/{{ event.tool_data.total_thoughts }}</span>
<span v-if="getThinkingSummary(event) && !isEventExpanded(event.tool_call_id)" class="action-summary">{{ getThinkingSummary(event) }}</span>
@@ -247,7 +252,7 @@
>
<div v-html="renderAnswerContent(event.content)"></div>
</div>
<div v-if="event.done && event.content && event.content.trim()" class="answer-toolbar">
<div v-if="event.done && event.content && event.content.trim() && !embeddedMode" class="answer-toolbar">
<t-button size="small" variant="outline" shape="round" @click.stop="handleCopyAnswer(event)" :title="$t('agent.copy')">
<t-icon name="copy" />
</t-button>
@@ -278,7 +283,12 @@
>
<div class="action-header" @click="handleActionHeaderClick(event)" :class="{ 'no-results': !hasResults(event) }">
<div class="action-title">
<img v-if="event.tool_name && !isBookIcon(event.tool_name)" class="action-title-icon" :src="getToolIcon(event.tool_name)" alt="" />
<span
v-if="event.tool_name && !isBookIcon(event.tool_name)"
class="action-title-icon icon-mask"
:style="maskIconStyle(getToolIcon(event.tool_name))"
aria-hidden="true"
/>
<t-icon v-if="event.tool_name && isBookIcon(event.tool_name)" class="action-title-icon" name="book" />
<t-tooltip v-if="event.tool_name === 'todo_write' && event.tool_data?.steps" :content="t('agent.updatePlan')" placement="top">
<span class="action-name">
@@ -425,6 +435,7 @@ import ToolApprovalCard from './ToolApprovalCard.vue';
import ChatRequestInfoButton from '@/components/ChatRequestInfoButton.vue';
import picturePreview from '@/components/picture-preview.vue';
import { getChunkByIdOnly } from '@/api/knowledge-base';
import { getEmbedChunkById } from '@/api/embed';
import { getRootZoom, rectToCssPx } from '@/utils/zoom';
import { getWikiPage, type WikiPage } from '@/api/wiki';
import { MessagePlugin } from 'tdesign-vue-next';
@@ -772,10 +783,10 @@ const openFloatForEl = (el: HTMLElement, widthAdjust = 120) => {
// Import icons
import agentIcon from '@/assets/img/agent.svg';
import thinkingIcon from '@/assets/img/Frame3718.svg';
import knowledgeIcon from '@/assets/img/zhishiku-thin.svg';
import knowledgeIcon from '@/assets/img/zhishiku.svg';
import documentIcon from '@/assets/img/ziliao.svg';
import fileAddIcon from '@/assets/img/file-add-green.svg';
import webSearchGlobeGreenIcon from '@/assets/img/websearch-globe-green.svg';
import fileAddIcon from '@/assets/img/file-add.svg';
import webSearchGlobeIcon from '@/assets/img/websearch-globe.svg';
interface SessionData {
id?: string;
@@ -790,9 +801,14 @@ const props = defineProps<{
session: SessionData;
sessionId?: string;
userQuery?: string;
embeddedMode?: boolean;
embedChannelId?: string;
embedToken?: string;
}>();
const showRequestInfo = computed(() => !!(props.session?.request_id || props.session?.id));
const showRequestInfo = computed(
() => !props.embeddedMode && !!(props.session?.request_id || props.session?.id),
);
// Configure marked for security
marked.use({});
@@ -1526,7 +1542,9 @@ const loadChunkDetails = async (chunkId: string) => {
setKbCacheState(chunkId, { loading: true });
try {
const response = await getChunkByIdOnly(chunkId);
const response = props.embeddedMode && props.embedChannelId && props.embedToken
? await getEmbedChunkById(props.embedChannelId, props.embedToken, chunkId)
: await getChunkByIdOnly(chunkId);
const content = response.data?.content;
if (content) {
const html = buildKbTooltipContent(content);
@@ -2178,6 +2196,18 @@ const isBookIcon = (toolName: string): boolean => {
return false; // 不再使用 t-icon 的 book,改用 SVG 图标
};
/** Render SVG assets in the channel / brand color via CSS mask. */
function maskIconStyle(src: string, size = 14): Record<string, string> {
if (!src) return {}
const url = `url("${src}")`
return {
width: `${size}px`,
height: `${size}px`,
WebkitMaskImage: url,
maskImage: url,
}
}
// Get icon for tool type
const getToolIcon = (toolName: string): string => {
if (toolName === 'thinking') {
@@ -2187,7 +2217,7 @@ const getToolIcon = (toolName: string): string => {
} else if (toolName === 'grep_chunks') {
return knowledgeIcon; // Use same icon as knowledge_search for consistency
} else if (toolName === 'web_search') {
return webSearchGlobeGreenIcon;
return webSearchGlobeIcon;
} else if (toolName === 'get_document_info' || toolName === 'list_knowledge_chunks') {
return documentIcon;
} else if (toolName === 'todo_write') {
@@ -2517,6 +2547,34 @@ const handleAddToKnowledge = (answerEvent: any) => {
gap: 0;
margin-bottom: 10px;
position: relative;
--stream-brand-2: color-mix(in srgb, var(--td-brand-color) 2%, transparent);
--stream-brand-3: color-mix(in srgb, var(--td-brand-color) 3%, transparent);
--stream-brand-4: color-mix(in srgb, var(--td-brand-color) 4%, transparent);
--stream-brand-5: color-mix(in srgb, var(--td-brand-color) 5%, transparent);
--stream-brand-6: color-mix(in srgb, var(--td-brand-color) 6%, transparent);
--stream-brand-8: color-mix(in srgb, var(--td-brand-color) 8%, transparent);
--stream-brand-10: color-mix(in srgb, var(--td-brand-color) 10%, transparent);
--stream-brand-12: color-mix(in srgb, var(--td-brand-color) 12%, transparent);
--stream-brand-15: color-mix(in srgb, var(--td-brand-color) 15%, transparent);
--stream-brand-20: color-mix(in srgb, var(--td-brand-color) 20%, transparent);
&.is-embedded {
margin-bottom: 0;
/* 与 EmbedChatCore 等待首包时的三点 loading 对齐 */
.loading-indicator {
height: 41px;
padding: 0 0 0 4px;
margin-top: 0;
animation: none;
.loading-typing span {
width: 6px;
height: 6px;
background: var(--embed-primary, var(--td-brand-color));
}
}
}
}
// Streaming steps container
@@ -2571,27 +2629,35 @@ const handleAddToKnowledge = (answerEvent: any) => {
border-radius: 8px;
background-color: var(--td-bg-color-container);
border: .5px solid var(--td-component-stroke);
box-shadow: 0 2px 4px rgba(7, 192, 95, 0.08);
box-shadow: 0 2px 4px var(--stream-brand-8);
color: var(--td-text-color-primary);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
background-color: rgba(7, 192, 95, 0.04);
background-color: var(--stream-brand-4);
}
}
.icon-mask {
display: inline-block;
flex-shrink: 0;
background-color: var(--td-brand-color);
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
}
.tree-root-icon {
margin-right: 8px;
}
.tree-root-title {
display: flex;
align-items: center;
img {
width: 16px;
height: 16px;
color: var(--td-brand-color);
fill: currentColor;
margin-right: 8px;
}
span {
white-space: nowrap;
font-size: 12px;
@@ -2712,7 +2778,7 @@ const handleAddToKnowledge = (answerEvent: any) => {
/* keyboard focus */
:deep(.citation-web:focus-visible) {
outline: 2px solid var(--td-success-color); /* green-400 */
outline: 2px solid var(--td-brand-color);
outline-offset: 2px;
}
@@ -2852,7 +2918,7 @@ const handleAddToKnowledge = (answerEvent: any) => {
&:hover {
border-color: var(--td-brand-color);
box-shadow: 0 1px 4px rgba(7, 192, 95, 0.08);
box-shadow: 0 1px 4px var(--stream-brand-8);
}
&.action-error {
@@ -2862,8 +2928,8 @@ const handleAddToKnowledge = (answerEvent: any) => {
&.action-pending {
opacity: 1;
box-shadow: none;
border-color: rgba(7, 192, 95, 0.15);
background: linear-gradient(120deg, rgba(7, 192, 95, 0.01), var(--td-bg-color-container));
border-color: var(--stream-brand-15);
background: linear-gradient(120deg, var(--stream-brand-2), var(--td-bg-color-container));
&::after {
content: '';
@@ -2872,8 +2938,8 @@ const handleAddToKnowledge = (answerEvent: any) => {
background: linear-gradient(
120deg,
transparent 0%,
rgba(7, 192, 95, 0.06) 40%,
rgba(7, 192, 95, 0.08) 55%,
var(--stream-brand-6) 40%,
var(--stream-brand-8) 55%,
transparent 85%
);
transform: translateX(-100%);
@@ -2938,7 +3004,7 @@ const handleAddToKnowledge = (answerEvent: any) => {
transition: background-color 0.15s ease;
&:hover {
background-color: rgba(7, 192, 95, 0.03);
background-color: var(--stream-brand-3);
}
&.no-results {
@@ -2958,17 +3024,12 @@ const handleAddToKnowledge = (answerEvent: any) => {
min-width: 0;
.action-title-icon {
width: 14px;
height: 14px;
color: var(--td-brand-color);
fill: currentColor;
flex-shrink: 0;
:deep(svg) {
&.t-icon {
width: 14px;
height: 14px;
color: var(--td-brand-color);
fill: currentColor;
}
}
@@ -2999,7 +3060,7 @@ const handleAddToKnowledge = (answerEvent: any) => {
padding: 0 6px;
height: 18px;
border-radius: 9px;
background: rgba(7, 192, 95, 0.10);
background: var(--stream-brand-10);
color: var(--td-brand-color);
font-size: 11px;
font-weight: 500;
@@ -3104,11 +3165,11 @@ const handleAddToKnowledge = (answerEvent: any) => {
@keyframes pulseBorder {
0%, 100% {
border-left-color: var(--td-brand-color);
box-shadow: 0 1px 3px rgba(7, 192, 95, 0.06);
box-shadow: 0 1px 3px var(--stream-brand-6);
}
50% {
border-left-color: var(--td-brand-color);
box-shadow: 0 1px 4px rgba(7, 192, 95, 0.12);
box-shadow: 0 1px 4px var(--stream-brand-12);
}
}
@@ -3243,9 +3304,9 @@ const handleAddToKnowledge = (answerEvent: any) => {
.plan-task-change-card {
padding: 8px 12px;
background: linear-gradient(135deg, rgba(7, 192, 95, 0.05), rgba(7, 192, 95, 0.02));
background: linear-gradient(135deg, var(--stream-brand-5), var(--stream-brand-2));
border-radius: 6px;
border: 1px solid rgba(7, 192, 95, 0.2);
border: 1px solid var(--stream-brand-20);
font-size: 12px;
color: var(--td-text-color-primary);
@@ -3341,20 +3402,18 @@ const handleAddToKnowledge = (answerEvent: any) => {
}
:deep(.citation-web) {
/* Align with app primary green scheme */
background: var(--td-success-color-light); /* green-50 */
color: var(--td-success-color); /* green-800 */
border: 1px solid var(--td-success-color-focus); /* green-200 */
background: color-mix(in srgb, var(--td-brand-color) 8%, transparent);
color: var(--td-brand-color);
border: 1px solid color-mix(in srgb, var(--td-brand-color) 20%, transparent);
cursor: pointer;
white-space: nowrap;
position: relative;
}
:deep(.citation-web:hover) {
/* Subtle hover in green tone */
background: var(--td-success-color-light); /* green-100 */
border-color: var(--td-success-color); /* green-300 */
color: var(--td-success-color); /* keep readable on light bg */
background: color-mix(in srgb, var(--td-brand-color) 12%, transparent);
border-color: var(--td-brand-color);
color: var(--td-brand-color);
}
/* Embedded tooltip bubble - hidden, use global floatPopup instead */
@@ -3373,17 +3432,29 @@ const handleAddToKnowledge = (answerEvent: any) => {
background-repeat: no-repeat;
background-size: contain;
background-position: center;
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
flex-shrink: 0;
}
/* Web icon (globe) */
:deep(.citation .citation-icon.web) {
background-image: url("../../../assets/img/websearch-globe-green.svg");
background-image: none;
background-color: var(--td-brand-color);
mask-image: url("../../../assets/img/websearch-globe.svg");
-webkit-mask-image: url("../../../assets/img/websearch-globe.svg");
}
/* Knowledge base icon */
:deep(.citation .citation-icon.kb) {
background-image: url("../../../assets/img/zhishiku-thin.svg");
background-image: none;
background-color: var(--td-brand-color);
mask-image: url("../../../assets/img/zhishiku.svg");
-webkit-mask-image: url("../../../assets/img/zhishiku.svg");
}
.kb-float-popup {
@@ -3449,12 +3520,11 @@ const handleAddToKnowledge = (answerEvent: any) => {
overflow-x: hidden;
}
/* KB citation styles - same green theme as web citations */
/* KB citation pill — follow brand / embed primary */
:deep(.citation.citation-kb) {
/* Green theme - same as web citations */
background: var(--td-success-color-light); /* green-50 */
color: var(--td-success-color); /* green-800 */
border: 1px solid var(--td-success-color-focus); /* green-200 */
background: color-mix(in srgb, var(--td-brand-color) 8%, transparent);
color: var(--td-brand-color);
border: 1px solid color-mix(in srgb, var(--td-brand-color) 20%, transparent);
cursor: pointer;
white-space: nowrap;
position: relative;
@@ -3462,14 +3532,13 @@ const handleAddToKnowledge = (answerEvent: any) => {
}
:deep(.citation.citation-kb:hover) {
/* Subtle hover in green tone */
background: var(--td-success-color-light); /* green-100 */
border-color: var(--td-success-color); /* green-300 */
color: var(--td-success-color); /* keep readable on light bg */
background: color-mix(in srgb, var(--td-brand-color) 12%, transparent);
border-color: var(--td-brand-color);
color: var(--td-brand-color);
}
:deep(.citation.citation-kb:focus-visible) {
outline: 2px solid var(--td-success-color); /* green-400 */
outline: 2px solid var(--td-brand-color);
outline-offset: 2px;
}
@@ -3807,9 +3876,4 @@ const handleAddToKnowledge = (answerEvent: any) => {
}
}
}
// Dark mode: invert agent icon (uses currentColor which doesn't work in <img>)
html[theme-mode="dark"] .tree-root-title img {
filter: invert(1);
opacity: 0.55;
}
</style>
@@ -88,7 +88,7 @@ const toggleFold = () => {
border-radius: 8px;
background-color: var(--td-bg-color-container);
border: .5px solid var(--td-component-stroke);
box-shadow: 0 2px 4px rgba(7, 192, 95, 0.08);
box-shadow: 0 2px 4px color-mix(in srgb, var(--td-brand-color) 8%, transparent);
overflow: hidden;
box-sizing: border-box;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
@@ -105,7 +105,7 @@ const toggleFold = () => {
user-select: none;
&:hover {
background-color: rgba(7, 192, 95, 0.04);
background-color: color-mix(in srgb, var(--td-brand-color) 4%, transparent);
}
.think-title {
+36 -16
View File
@@ -2,7 +2,7 @@
<div class="refer" v-if="session.knowledge_references && session.knowledge_references.length">
<div class="refer_header" @click="referBoxSwitch">
<div class="refer_title">
<img src="@/assets/img/ziliao.svg" :alt="$t('chat.referenceIconAlt')" />
<span class="refer-title-icon icon-mask" aria-hidden="true" />
<span>{{ headerText }}</span>
</div>
<div class="refer_show_icon">
@@ -32,7 +32,7 @@
<span class="doc-group-title" :title="group.title">{{ group.title }}</span>
<span class="doc-group-count">{{ $t('chat.referenceChunkCount', { count: group.chunks.length }) }}</span>
</div>
<div class="doc-group-actions" v-if="group.knowledgeBaseId" @click.stop>
<div class="doc-group-actions" v-if="!embeddedMode && group.knowledgeBaseId" @click.stop>
<t-tooltip :content="$t('chat.navigateToDocument')">
<span class="doc-group-navigate" @click="navigateToDocument(group)">
<t-icon name="jump" size="14px" />
@@ -75,6 +75,10 @@ const props = defineProps({
session: {
type: Object,
required: false
},
embeddedMode: {
type: Boolean,
default: false
}
});
@@ -189,6 +193,10 @@ const getWebSearchDisplayText = (item) => {
</script>
<style lang="less" scoped>
.refer {
--refer-brand-4: color-mix(in srgb, var(--td-brand-color) 4%, transparent);
--refer-brand-8: color-mix(in srgb, var(--td-brand-color) 8%, transparent);
--refer-brand-12: color-mix(in srgb, var(--td-brand-color) 12%, transparent);
display: flex;
flex-direction: column;
font-size: 12px;
@@ -196,7 +204,7 @@ const getWebSearchDisplayText = (item) => {
border-radius: 8px;
background-color: var(--td-bg-color-container);
border: .5px solid var(--td-component-stroke);
box-shadow: 0 2px 4px rgba(7, 192, 95, 0.08);
box-shadow: 0 2px 4px var(--refer-brand-8);
box-sizing: border-box;
overflow: hidden;
box-sizing: border-box;
@@ -215,14 +223,6 @@ const getWebSearchDisplayText = (item) => {
display: flex;
align-items: center;
img {
width: 16px;
height: 16px;
color: var(--td-brand-color);
fill: currentColor;
margin-right: 8px;
}
span {
white-space: nowrap;
font-size: 12px;
@@ -237,7 +237,7 @@ const getWebSearchDisplayText = (item) => {
}
.refer_header:hover {
background-color: rgba(7, 192, 95, 0.04);
background-color: var(--refer-brand-4);
cursor: pointer;
}
@@ -248,6 +248,26 @@ const getWebSearchDisplayText = (item) => {
}
}
.icon-mask {
display: inline-block;
flex-shrink: 0;
background-color: var(--embed-primary, var(--td-brand-color));
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
}
.refer-title-icon {
width: 16px;
height: 16px;
margin-right: 8px;
mask-image: url('@/assets/img/ziliao.svg');
-webkit-mask-image: url('@/assets/img/ziliao.svg');
}
.doc {
text-decoration: none;
color: var(--td-brand-color);
@@ -263,7 +283,7 @@ const getWebSearchDisplayText = (item) => {
border-bottom: 1px dashed var(--td-brand-color);
&:hover {
background-color: rgba(7, 192, 95, 0.08);
background-color: var(--refer-brand-8);
border-radius: 3px;
padding-right: 4px;
}
@@ -287,7 +307,7 @@ const getWebSearchDisplayText = (item) => {
transition: background-color 0.15s ease;
&:hover {
background-color: rgba(7, 192, 95, 0.04);
background-color: var(--refer-brand-4);
}
.doc-group-left {
@@ -343,7 +363,7 @@ const getWebSearchDisplayText = (item) => {
transition: all 0.15s ease;
&:hover {
background-color: var(--td-brand-color-light);
background-color: var(--refer-brand-12);
}
}
}
@@ -368,7 +388,7 @@ const getWebSearchDisplayText = (item) => {
white-space: nowrap;
&:hover {
background-color: rgba(7, 192, 95, 0.04);
background-color: var(--refer-brand-4);
color: var(--td-brand-color);
}
@@ -4,11 +4,11 @@
<div v-for="(step, index) in data.steps" :key="step.id || index" class="step-item" :class="`status-${step.status}`">
<div class="step-checkbox" :class="{ 'checked': step.status === 'completed', 'in-progress': step.status === 'in_progress' }">
<svg v-if="step.status === 'completed'" width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="2" y="2" width="12" height="12" rx="2" fill="#07C05F"/>
<path d="M5 8L7 10L11 6" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="2" y="2" width="12" height="12" rx="2" fill="currentColor" />
<path d="M5 8L7 10L11 6" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<svg v-else width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="2" y="2" width="12" height="12" rx="2" stroke="#d1d5db" stroke-width="1.5" fill="none"/>
<rect x="2" y="2" width="12" height="12" rx="2" stroke="currentColor" stroke-width="1.5" fill="none" />
</svg>
</div>
<span class="step-description" :class="{ 'completed': step.status === 'completed' }">
@@ -79,21 +79,17 @@ const props = defineProps<Props>();
display: flex;
align-items: center;
justify-content: center;
color: var(--td-text-color-placeholder);
&.checked {
svg {
rect {
fill: var(--td-brand-color);
}
}
color: var(--embed-primary, var(--td-brand-color));
}
&.in-progress {
svg {
rect {
stroke: var(--td-brand-color);
stroke-width: 2;
}
color: var(--embed-primary, var(--td-brand-color));
svg rect {
stroke-width: 2;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -17,17 +17,20 @@
<h2 class="sidebar-title">{{ mode === 'create' ? $t('knowledgeEditor.titleCreate') : $t('knowledgeEditor.titleEdit') }}</h2>
</div>
<div class="settings-nav" data-guide="kb-editor-sidebar">
<div
v-for="(item, index) in navItems"
:key="index"
:class="['nav-item', { 'active': currentSection === item.key }]"
:data-guide="`kb-editor-nav-${item.key}`"
@click="currentSection = item.key"
>
<t-icon :name="item.icon" class="nav-icon" />
<span class="nav-label">{{ item.label }}</span>
<span v-if="item.badge" class="nav-badge">{{ item.badge }}</span>
</div>
<template v-for="group in navGroups" :key="group.key">
<div class="nav-group-title">{{ group.label }}</div>
<div
v-for="(item, index) in group.items"
:key="index"
:class="['nav-item', { 'active': currentSection === item.key }]"
:data-guide="`kb-editor-nav-${item.key}`"
@click="currentSection = item.key"
>
<t-icon :name="item.icon" class="nav-icon" />
<span class="nav-label">{{ item.label }}</span>
<span v-if="item.badge" class="nav-badge">{{ item.badge }}</span>
</div>
</template>
</div>
</div>
@@ -549,6 +552,35 @@ const navItems = computed(() => {
return items
})
// 左侧导航分组(与 AgentEditorModal 对齐)
const navGroups = computed(() => {
const itemMap = new Map(navItems.value.map((item) => [item.key, item]))
const pickItems = (keys: string[]) =>
keys.map((key) => itemMap.get(key)).filter(Boolean) as typeof navItems.value
return [
{
key: 'basic',
label: t('knowledgeEditor.navGroups.basic'),
items: pickItems(['basic', 'models', 'vectorStore', 'faq']),
},
{
key: 'processing',
label: t('knowledgeEditor.navGroups.processing'),
items: pickItems(['parser', 'chunking', 'multimodal', 'asr', 'graph', 'advanced']),
},
{
key: 'data',
label: t('knowledgeEditor.navGroups.data'),
items: pickItems(['storage', 'datasource']),
},
{
key: 'integration',
label: t('knowledgeEditor.navGroups.integration'),
items: pickItems(['share']),
},
].filter((group) => group.items.length > 0)
})
// 模型配置引用
const modelConfigRef = ref<InstanceType<typeof KBModelConfig>>()
const advancedSettingsRef = ref<InstanceType<typeof KBAdvancedSettings>>()
@@ -1442,68 +1474,89 @@ watch(
.settings-container {
display: flex;
height: 100%;
width: 100%;
overflow: hidden;
}
/* 左侧导航:与 AgentEditorModal 对齐 */
.settings-sidebar {
width: 200px;
background: var(--td-bg-color-settings-modal);
width: 208px;
background-color: var(--td-bg-color-settings-modal);
border-right: 1px solid var(--td-component-stroke);
flex-shrink: 0;
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
}
.sidebar-header {
padding: 24px 20px;
padding: 16px 14px 12px;
border-bottom: 1px solid var(--td-component-stroke);
flex-shrink: 0;
}
.sidebar-title {
margin: 0;
font-family: var(--app-font-family);
font-size: 18px;
font-size: 16px;
font-weight: 600;
color: var(--td-text-color-primary);
}
.settings-nav {
flex: 1;
padding: 12px 8px;
padding: 8px 8px 12px;
overflow-y: auto;
min-height: 0;
}
.nav-group-title {
padding: 6px 14px 2px;
color: var(--td-text-color-placeholder);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
.settings-nav > &:first-child {
padding-top: 2px;
}
.settings-nav > &:not(:first-child) {
padding-top: 8px;
}
}
.nav-item {
display: flex;
align-items: center;
padding: 10px 12px;
margin-bottom: 4px;
padding: 6px 12px;
margin-bottom: 2px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
font-family: var(--app-font-family);
font-size: 14px;
color: var(--td-text-color-secondary);
color: var(--td-text-color-primary);
user-select: none;
&:hover {
background: var(--td-bg-color-secondarycontainer-hover);
background-color: var(--td-bg-color-secondarycontainer-hover);
color: var(--td-text-color-primary);
}
&.active {
background: var(--td-brand-color-light);
background-color: rgba(7, 192, 95, 0.1);
color: var(--td-brand-color);
font-weight: 500;
}
}
.nav-icon {
margin-right: 8px;
font-size: 18px;
margin-right: 9px;
font-size: 16px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: inherit;
}
.nav-label {
@@ -1511,24 +1564,21 @@ watch(
}
.nav-badge {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
padding: 0 6px;
border-radius: 10px;
background: var(--td-bg-color-secondarycontainer);
font-size: 11px;
font-weight: 600;
background: var(--td-bg-color-component);
line-height: 18px;
text-align: center;
color: var(--td-text-color-secondary);
line-height: 1;
flex-shrink: 0;
}
.nav-item.active .nav-badge {
background: var(--td-brand-color);
color: #fff;
.nav-item.active & {
background: color-mix(in srgb, var(--td-brand-color) 18%, transparent);
color: var(--td-brand-color);
}
}
.settings-content {
@@ -1554,11 +1604,11 @@ watch(
.section-content {
.section-header {
margin-bottom: 20px;
margin-bottom: 16px;
}
.section-title {
margin: 0 0 8px 0;
margin: 0 0 6px 0;
font-family: var(--app-font-family);
font-size: 20px;
font-weight: 600;
@@ -1781,13 +1831,13 @@ watch(
width: 100%;
.section-header {
margin-bottom: 32px;
margin-bottom: 20px;
h2 {
font-size: 20px;
font-weight: 600;
color: var(--td-text-color-primary);
margin: 0 0 8px 0;
margin: 0 0 6px 0;
}
.section-description {
@@ -1807,7 +1857,7 @@ watch(
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 20px 0;
padding: 16px 0;
border-bottom: 1px solid var(--td-component-stroke);
&:last-child {
@@ -216,129 +216,146 @@ onBeforeUnmount(stopPolling)
<p class="section-desc">{{ t('datasource.description') }}</p>
</div>
<div v-if="loading" class="ds-loading">
<t-loading size="small" />
</div>
<div v-else-if="dataSources.length === 0" class="ds-empty">
<div class="ds-empty-icon">
<t-icon name="cloud-download" size="32px" />
<div class="channels-section">
<div class="channels-header">
<span class="channels-title">{{ t('datasource.channelsTitle') }}</span>
<span class="channels-count">{{ dataSources.length }}</span>
</div>
<div class="ds-empty-text">
<p class="ds-empty-title">{{ t('datasource.empty') }}</p>
</div>
<t-button v-if="canManageDataSource" theme="primary" variant="outline" @click="openCreate">
<template #icon><t-icon name="add" /></template>
{{ t('datasource.addFirst') }}
</t-button>
</div>
<div v-else class="ds-list">
<div v-for="ds in dataSources" :key="ds.id" class="ds-card">
<div class="ds-card-header">
<div class="ds-card-title-wrap">
<DataSourceTypeIcon :type="ds.type" :size="32" />
<div class="ds-title-text">
<div class="ds-name-row">
<span class="ds-name" :title="ds.name">{{ ds.name }}</span>
<t-tag size="small" :theme="statusTheme(ds.status)" variant="light-outline" class="ds-status-tag">
<t-loading :loading="loading" size="small" class="channels-loading-wrap">
<div
v-if="!loading && dataSources.length === 0 && !canManageDataSource"
class="channels-empty"
>
<t-empty :description="t('datasource.empty')" />
</div>
<div v-else-if="!loading" class="channel-grid">
<component
:is="canManageDataSource ? 'button' : 'div'"
v-for="ds in dataSources"
:key="ds.id"
:type="canManageDataSource ? 'button' : undefined"
:class="['channel-card', { 'channel-card--clickable': canManageDataSource }]"
@click="canManageDataSource ? openEdit(ds) : undefined"
>
<div class="channel-card__badge">
<DataSourceTypeIcon :type="ds.type" :size="22" />
</div>
<div class="channel-card__body">
<div class="channel-card__header">
<h3 class="channel-card__title" :title="ds.name">{{ ds.name }}</h3>
<t-tag size="small" :theme="statusTheme(ds.status)" variant="light">
{{ statusLabel(ds.status) }}
</t-tag>
</div>
<span class="ds-type-desc">{{ connectorLabel(ds.type) }}</span>
<p class="channel-card__subtitle">
{{ connectorLabel(ds.type) }} · {{ syncModeLabel(ds.sync_mode) }}
</p>
<div class="channel-card__meta">
<div class="channel-card__meta-item">
<span class="channel-card__meta-label">{{ t('datasource.schedule') }}</span>
<span class="channel-card__meta-value">{{ scheduleLabel(ds.sync_schedule) }}</span>
</div>
<div class="channel-card__meta-item">
<span class="channel-card__meta-label">{{ t('datasource.lastSync') }}</span>
<t-tooltip :content="lastSyncFullTime(ds)" :disabled="!lastSyncFullTime(ds)">
<span class="channel-card__meta-value">{{ lastSyncTime(ds) }}</span>
</t-tooltip>
</div>
<div class="channel-card__meta-item channel-card__meta-item--wide">
<span class="channel-card__meta-label">{{ t('datasource.lastStatus') }}</span>
<span class="channel-card__meta-value">
<template v-if="ds.latest_sync_log">
<span :style="{ color: lastSyncStatusColor(ds), fontWeight: 500 }">
{{ lastSyncStatusLabel(ds) }}
</span>
<span
v-for="pill in syncResultPills(ds)"
:key="pill.cls"
:class="['ds-pill', pill.cls]"
>{{ pill.text }}</span>
</template>
<span v-else class="channel-card__meta-placeholder">--</span>
</span>
</div>
</div>
<div v-if="ds.error_message" class="channel-card__error">
<t-icon name="error-circle-filled" size="14px" />
<span>{{ ds.error_message }}</span>
</div>
</div>
</div>
<div class="ds-card-actions">
<t-tooltip v-if="canManageDataSource" :content="isSyncRunning(ds) ? t('datasource.logStatus.running') : t('datasource.syncNow')">
<t-button
size="small"
variant="text"
theme="primary"
:disabled="isSyncRunning(ds)"
@click="handleSync(ds)"
<div class="channel-card__actions" @click.stop>
<t-tooltip
v-if="canManageDataSource"
:content="isSyncRunning(ds) ? t('datasource.logStatus.running') : t('datasource.syncNow')"
>
<template #icon>
<t-icon name="refresh" :class="{ 'ds-icon-spin': isSyncRunning(ds) }" />
</template>
</t-button>
</t-tooltip>
<t-tooltip :content="t('datasource.logs')">
<t-button size="small" variant="text" @click="openLogs(ds)">
<template #icon><t-icon name="root-list" /></template>
</t-button>
</t-tooltip>
<t-dropdown v-if="canManageDataSource" trigger="click" :min-column-width="120">
<t-tooltip :content="t('datasource.moreActions')">
<t-button size="small" variant="text" shape="square">
<template #icon><t-icon name="ellipsis" /></template>
<t-button
size="small"
variant="text"
theme="primary"
:disabled="isSyncRunning(ds)"
@click="handleSync(ds)"
>
<template #icon>
<t-icon name="refresh" :class="{ 'ds-icon-spin': isSyncRunning(ds) }" />
</template>
</t-button>
</t-tooltip>
<template #dropdown>
<t-dropdown-menu>
<t-dropdown-item @click="openEdit(ds)">
<t-icon name="edit" /> {{ t('datasource.edit') }}
</t-dropdown-item>
<t-dropdown-item
v-if="ds.status === 'active'"
@click="handlePause(ds)"
>
<t-icon name="pause-circle" /> {{ t('datasource.pause') }}
</t-dropdown-item>
<t-dropdown-item
v-else-if="ds.status === 'paused'"
@click="handleResume(ds)"
>
<t-icon name="play-circle" /> {{ t('datasource.resume') }}
</t-dropdown-item>
<t-dropdown-item theme="error" @click="handleDelete(ds)">
<t-icon name="delete" /> {{ t('datasource.delete') }}
</t-dropdown-item>
</t-dropdown-menu>
</template>
</t-dropdown>
</div>
</div>
<div class="ds-card-stats">
<div class="ds-stat-item">
<span class="ds-stat-label">{{ t('datasource.syncModeLabel') }}</span>
<span class="ds-stat-value">{{ syncModeLabel(ds.sync_mode) }}</span>
</div>
<div class="ds-stat-item">
<span class="ds-stat-label">{{ t('datasource.schedule') }}</span>
<span class="ds-stat-value">{{ scheduleLabel(ds.sync_schedule) }}</span>
</div>
<div class="ds-stat-item">
<span class="ds-stat-label">{{ t('datasource.lastSync') }}</span>
<t-tooltip :content="lastSyncFullTime(ds)" :disabled="!lastSyncFullTime(ds)">
<span class="ds-stat-value">{{ lastSyncTime(ds) }}</span>
</t-tooltip>
</div>
<div class="ds-stat-item" style="flex: 1.2">
<span class="ds-stat-label">{{ t('datasource.lastStatus') }}</span>
<div class="ds-stat-value">
<template v-if="ds.latest_sync_log">
<span :style="{ color: lastSyncStatusColor(ds), fontWeight: 500 }">{{ lastSyncStatusLabel(ds) }}</span>
<span v-for="pill in syncResultPills(ds)" :key="pill.cls" :class="['ds-pill', pill.cls]">{{ pill.text }}</span>
</template>
<span v-else class="ds-stat-placeholder">--</span>
<t-tooltip :content="t('datasource.logs')">
<t-button size="small" variant="text" @click="openLogs(ds)">
<template #icon><t-icon name="root-list" /></template>
</t-button>
</t-tooltip>
<t-dropdown v-if="canManageDataSource" trigger="click" :min-column-width="120">
<t-button
variant="text"
shape="square"
size="small"
class="channel-card__more"
@click.stop
>
<template #icon><t-icon name="ellipsis" /></template>
</t-button>
<template #dropdown>
<t-dropdown-menu>
<t-dropdown-item @click="openEdit(ds)">
<t-icon name="edit" /> {{ t('datasource.edit') }}
</t-dropdown-item>
<t-dropdown-item
v-if="ds.status === 'active'"
@click="handlePause(ds)"
>
<t-icon name="pause-circle" /> {{ t('datasource.pause') }}
</t-dropdown-item>
<t-dropdown-item
v-else-if="ds.status === 'paused'"
@click="handleResume(ds)"
>
<t-icon name="play-circle" /> {{ t('datasource.resume') }}
</t-dropdown-item>
<t-dropdown-item theme="error" @click="handleDelete(ds)">
<t-icon name="delete" /> {{ t('datasource.delete') }}
</t-dropdown-item>
</t-dropdown-menu>
</template>
</t-dropdown>
</div>
</div>
</div>
</component>
<div v-if="ds.error_message" class="ds-card-error">
<t-icon name="error-circle-filled" size="16px" />
<span>{{ ds.error_message }}</span>
<button
v-if="canManageDataSource"
type="button"
class="channel-card channel-card--add"
@click="openCreate"
>
<span class="channel-card--add__icon" aria-hidden="true">
<t-icon name="add" />
</span>
<span class="channel-card--add__label">{{ t('datasource.add') }}</span>
</button>
</div>
</div>
<div v-if="canManageDataSource" class="ds-card-add" @click="openCreate">
<div class="ds-card-add-icon">
<t-icon name="add" size="20px" />
</div>
<span>{{ t('datasource.addCard') }}</span>
</div>
</t-loading>
</div>
<DataSourceEditorDialog
@@ -356,218 +373,278 @@ onBeforeUnmount(stopPolling)
</div>
</template>
<style scoped>
<style scoped lang="less">
.ds-settings {
padding: 0;
width: 100%;
}
/* --- Section header --- */
.section-header {
margin-bottom: 20px;
.section-title {
margin: 0 0 6px;
font-size: 20px;
font-weight: 600;
color: var(--td-text-color-primary);
}
.section-desc {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: var(--td-text-color-secondary);
}
}
.section-title {
margin: 0 0 6px 0;
font-size: 20px;
font-weight: 600;
color: var(--td-text-color-primary);
letter-spacing: -0.01em;
}
.section-desc {
margin: 0;
font-size: 13px;
color: var(--td-text-color-placeholder);
line-height: 20px;
}
/* --- Loading --- */
.ds-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 60px 0;
}
/* --- Empty state --- */
.ds-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 56px 24px;
gap: 16px;
border: 1px dashed var(--td-border-level-2-color);
border-radius: 12px;
background: var(--td-bg-color-container);
}
.ds-empty-icon {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
background: var(--td-brand-color-light);
color: var(--td-brand-color);
}
.ds-empty-text {
text-align: center;
}
.ds-empty-title {
margin: 0;
font-size: 14px;
color: var(--td-text-color-secondary);
line-height: 22px;
}
/* --- List --- */
.ds-list {
display: flex;
flex-direction: column;
gap: 10px;
}
/* --- Card --- */
.ds-card {
position: relative;
background: var(--td-bg-color-container);
border: 1px solid var(--td-border-level-2-color);
border-radius: 8px;
padding: 15px 20px;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
}
.ds-card:hover {
border-color: var(--td-brand-color);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
}
/* --- Card header --- */
.ds-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.ds-card-title-wrap {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
flex: 1;
}
.ds-title-text {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.ds-name-row {
.channels-header {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
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);
}
}
.ds-name {
font-size: 16px;
font-weight: 600;
color: var(--td-text-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 24px;
.channels-loading-wrap {
min-height: 80px;
}
.ds-status-tag {
border-radius: 4px;
.channels-empty {
padding: 32px 0;
}
.ds-type-desc {
font-size: 13px;
color: var(--td-text-color-secondary);
line-height: 18px;
.channel-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.ds-card-actions {
.channel-card {
position: relative;
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
opacity: 0.6;
transition: opacity 0.2s ease;
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: 36px;
height: 36px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--td-brand-color) 10%, transparent);
overflow: hidden;
}
&__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;
}
&__meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--td-component-stroke);
}
&__meta-item {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
&--wide {
grid-column: 1 / -1;
}
}
&__meta-label {
font-size: 11px;
line-height: 1.4;
color: var(--td-text-color-placeholder);
}
&__meta-value {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
font-size: 12px;
line-height: 1.4;
color: var(--td-text-color-primary);
}
&__meta-placeholder {
color: var(--td-text-color-disabled);
}
&__error {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 8px;
padding: 8px 10px;
border-radius: 6px;
background: var(--td-error-color-1);
color: var(--td-error-color);
font-size: 12px;
line-height: 1.45;
text-align: left;
}
&__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;
}
}
.ds-card:hover .ds-card-actions {
opacity: 1;
}
.ds-card-actions :deep(.t-button) {
border-radius: 6px;
}
/* --- Info stats --- */
.ds-card-stats {
display: flex;
gap: 24px;
padding-top: 16px;
border-top: 1px solid var(--td-border-level-1-color);
}
.ds-stat-item {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
}
.ds-stat-label {
font-size: 11px;
font-weight: 500;
color: var(--td-text-color-placeholder);
text-transform: uppercase;
letter-spacing: 0.5px;
line-height: 16px;
white-space: nowrap;
}
.ds-stat-value {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
font-size: 13px;
color: var(--td-text-color-primary);
line-height: 20px;
}
.ds-stat-placeholder {
color: var(--td-text-color-disabled);
}
/* --- Sync result pills --- */
.ds-pill {
font-size: 11px;
padding: 1px 6px;
font-size: 10px;
padding: 1px 5px;
border-radius: 4px;
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 18px;
}
line-height: 16px;
.ds-pill.created { background: var(--td-success-color-1); color: var(--td-success-color); }
.ds-pill.updated { background: var(--td-brand-color-light); color: var(--td-brand-color); }
.ds-pill.deleted { background: var(--td-warning-color-1); color: var(--td-warning-color); }
.ds-pill.skipped { background: var(--td-bg-color-component); color: var(--td-text-color-placeholder); }
.ds-pill.failed { background: var(--td-error-color-1); color: var(--td-error-color); }
&.created { background: var(--td-success-color-1); color: var(--td-success-color); }
&.updated { background: var(--td-brand-color-light); color: var(--td-brand-color); }
&.deleted { background: var(--td-warning-color-1); color: var(--td-warning-color); }
&.skipped { background: var(--td-bg-color-component); color: var(--td-text-color-placeholder); }
&.failed { background: var(--td-error-color-1); color: var(--td-error-color); }
}
.ds-icon-spin {
animation: ds-spin 1s linear infinite;
@@ -577,60 +654,4 @@ onBeforeUnmount(stopPolling)
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* --- Error alert --- */
.ds-card-error {
margin-top: 16px;
padding: 10px 14px;
border-radius: 8px;
background: var(--td-error-color-1);
color: var(--td-error-color);
font-size: 13px;
display: flex;
align-items: flex-start;
gap: 8px;
line-height: 20px;
}
/* --- Add card --- */
.ds-card-add {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 6px 15px;
background: var(--td-bg-color-secondarycontainer);
border: 1px solid transparent;
border-radius: 8px;
color: var(--td-text-color-secondary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
margin-top: 4px;
}
.ds-card-add-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--td-bg-color-component);
color: var(--td-text-color-placeholder);
transition: all 0.2s ease;
}
.ds-card-add:hover {
background: var(--td-bg-color-container);
border-color: var(--td-brand-color);
color: var(--td-brand-color);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
}
.ds-card-add:hover .ds-card-add-icon {
background: var(--td-brand-color-light);
color: var(--td-brand-color);
}
</style>
@@ -588,13 +588,13 @@ onMounted(async () => {
}
.section-header {
margin-bottom: 32px;
margin-bottom: 20px;
h2 {
font-size: 20px;
font-weight: 600;
color: var(--td-text-color-primary);
margin: 0 0 8px 0;
margin: 0 0 6px 0;
}
.section-description {
@@ -615,7 +615,7 @@ onMounted(async () => {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 20px 0;
padding: 16px 0;
border-bottom: 1px solid var(--td-component-stroke);
&:last-child {
@@ -99,13 +99,13 @@ const handleQuestionGenerationChange = () => {
}
.section-header {
margin-bottom: 32px;
margin-bottom: 20px;
h2 {
font-size: 20px;
font-weight: 600;
color: var(--td-text-color-primary);
margin: 0 0 8px 0;
margin: 0 0 6px 0;
}
.section-description {
@@ -126,7 +126,7 @@ const handleQuestionGenerationChange = () => {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 20px 0;
padding: 16px 0;
border-bottom: 1px solid var(--td-component-stroke);
&:last-child {
@@ -408,8 +408,8 @@ const emitUpdate = () => {
top: -24px;
z-index: 5;
background: var(--td-bg-color-container);
padding: 24px 32px 16px 32px;
margin: -24px -32px 24px -32px;
padding: 24px 32px 12px 32px;
margin: -24px -32px 16px -32px;
border-bottom: 1px solid var(--td-component-stroke);
.section-header-text {
@@ -421,7 +421,7 @@ const emitUpdate = () => {
font-size: 20px;
font-weight: 600;
color: var(--td-text-color-primary);
margin: 0 0 8px 0;
margin: 0 0 6px 0;
}
.section-description {
@@ -442,7 +442,7 @@ const emitUpdate = () => {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 20px 0;
padding: 16px 0;
border-bottom: 1px solid var(--td-component-stroke);
&:last-child {
@@ -138,13 +138,13 @@ const handleAddModel = (subSection: string) => {
}
.section-header {
margin-bottom: 32px;
margin-bottom: 20px;
h2 {
font-size: 20px;
font-weight: 600;
color: var(--td-text-color-primary);
margin: 0 0 8px 0;
margin: 0 0 6px 0;
}
.section-description {
@@ -165,7 +165,7 @@ const handleAddModel = (subSection: string) => {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 20px 0;
padding: 16px 0;
border-bottom: 1px solid var(--td-component-stroke);
&:last-child {
@@ -307,13 +307,13 @@ watch(() => props.parserEngineRules, (v) => {
}
.section-header {
margin-bottom: 32px;
margin-bottom: 20px;
h2 {
font-size: 20px;
font-weight: 600;
color: var(--td-text-color-primary);
margin: 0 0 8px 0;
margin: 0 0 6px 0;
}
.section-description {
@@ -346,7 +346,7 @@ watch(() => props.parserEngineRules, (v) => {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 20px 0;
padding: 16px 0;
border-bottom: 1px solid var(--td-component-stroke);
&:last-child {
@@ -309,11 +309,11 @@ watch(() => props.kbId, async (newKbId) => {
<style scoped lang="less">
.section-content {
.section-header {
margin-bottom: 20px;
margin-bottom: 16px;
}
.section-title {
margin: 0 0 8px 0;
margin: 0 0 6px 0;
font-family: var(--app-font-family);
font-size: 20px;
font-weight: 600;
@@ -220,13 +220,13 @@ onMounted(load)
}
.section-header {
margin-bottom: 32px;
margin-bottom: 20px;
h2 {
font-size: 20px;
font-weight: 600;
color: var(--td-text-color-primary);
margin: 0 0 8px 0;
margin: 0 0 6px 0;
}
.section-description {
@@ -253,7 +253,7 @@ onMounted(load)
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 20px 0;
padding: 16px 0;
border-bottom: 1px solid var(--td-component-stroke);
}
@@ -5,14 +5,14 @@
<p class="section-description">{{ $t('kbSettings.vectorStore.description') }}</p>
</div>
<!-- CREATE mode: dropdown -->
<div v-if="props.mode === 'create'" class="settings-group">
<div v-if="loading" class="loading-inline">
<t-loading size="small" />
<span>{{ $t('kbSettings.vectorStore.loading') }}</span>
</div>
<div v-if="props.mode === 'create' && loading" class="loading-inline">
<t-loading size="small" />
<span>{{ $t('kbSettings.vectorStore.loading') }}</span>
</div>
<div v-else class="setting-row">
<!-- CREATE mode: dropdown -->
<div v-else-if="props.mode === 'create'" class="settings-group">
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('kbSettings.vectorStore.engineLabel') }}</label>
<p class="desc">{{ $t('kbSettings.vectorStore.engineDesc') }}</p>
@@ -23,7 +23,7 @@
size="medium"
:placeholder="$t('kbSettings.vectorStore.systemDefault')"
:clearable="true"
style="width: 100%; min-width: 240px;"
style="width: 100%; min-width: 220px;"
@change="handleChange"
>
<t-option :value="''" :label="$t('kbSettings.vectorStore.systemDefault')">
@@ -177,92 +177,110 @@ onMounted(async () => {
})
</script>
<style scoped>
<style lang="less" scoped>
.kb-vector-store-settings {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
}
.section-header h2 {
font-size: 16px;
font-weight: 600;
margin: 0 0 4px;
}
.section-header {
margin-bottom: 20px;
.section-description {
color: var(--td-text-color-secondary, #4e5969);
font-size: 13px;
margin: 0;
}
h2 {
font-size: 20px;
font-weight: 600;
color: var(--td-text-color-primary);
margin: 0 0 6px 0;
}
.settings-group {
background: var(--td-bg-color-container, #fff);
border: 1px solid var(--td-component-stroke, #dcdcdc);
border-radius: 6px;
padding: 16px;
}
.setting-row {
display: flex;
gap: 24px;
align-items: flex-start;
}
.setting-info {
flex: 0 0 220px;
}
.setting-info label {
font-weight: 500;
display: block;
margin-bottom: 4px;
}
.setting-info .desc {
font-size: 12px;
color: var(--td-text-color-secondary, #4e5969);
margin: 0;
}
.setting-control {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.option-hint {
font-size: 12px;
color: var(--td-text-color-secondary, #4e5969);
margin: 0;
}
.change-warning {
color: var(--td-error-color, #d54941);
}
.go-settings {
font-size: 12px;
color: var(--td-brand-color, #0052d9);
text-decoration: none;
}
.go-settings:hover {
text-decoration: underline;
}
.select-option {
display: flex;
align-items: center;
gap: 8px;
.section-description {
font-size: 14px;
color: var(--td-text-color-secondary);
margin: 0;
line-height: 1.5;
}
}
.loading-inline {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 0;
font-size: 13px;
color: var(--td-text-color-secondary, #4e5969);
color: var(--td-text-color-secondary);
}
.settings-group {
display: flex;
flex-direction: column;
}
.setting-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid var(--td-component-stroke);
&:last-child {
border-bottom: none;
}
}
.setting-info {
flex: 0 0 40%;
max-width: 40%;
padding-right: 24px;
label {
font-size: 15px;
font-weight: 500;
color: var(--td-text-color-primary);
display: block;
margin-bottom: 4px;
}
.desc {
font-size: 13px;
color: var(--td-text-color-secondary);
margin: 0;
line-height: 1.5;
}
}
.setting-control {
flex: 0 0 55%;
max-width: 55%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.select-option {
display: inline-flex;
align-items: center;
gap: 8px;
}
.option-hint {
font-size: 12px;
color: var(--td-text-color-placeholder);
margin: 0;
line-height: 1.4;
&.change-warning {
color: var(--td-warning-color);
}
}
.go-settings {
font-size: 13px;
color: var(--td-brand-color, #0052d9);
margin-top: 8px;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
</style>