Browse Source
- Create AgentDocumentDrawer.vue: right-side drawer with document list + detail view - AgentSidebar: change document NuxtLink to button emitting showDocuments - AgentToolbar: change document NuxtLink to button emitting showDocuments - AgentChatArea: forward showDocuments event from toolbar to page - Chat page: wire showDocumentDrawer state + render AgentDocumentDrawer Drawer features: - List view with type filter (all/document/memory) + search - Detail view with markdown rendering via AgentMarkdown - Slide-in transition + overlay backdrop - Teleport to body for proper z-index stacking Co-authored-by: CodeFree <codefree@chinatelcom.cn>feat/agent-independence
5 changed files with 527 additions and 7 deletions
@ -0,0 +1,508 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
import { request } from '~/utils/http/factory' |
||||
|
|
||||
|
const props = defineProps<{ |
||||
|
show: boolean |
||||
|
}>() |
||||
|
|
||||
|
const emit = defineEmits<{ |
||||
|
close: [] |
||||
|
}>() |
||||
|
|
||||
|
const { $toast } = useNuxtApp() |
||||
|
|
||||
|
interface AgentDocumentItem { |
||||
|
id: number |
||||
|
title: string |
||||
|
type: 'memory' | 'document' |
||||
|
summary: string | null |
||||
|
tags: string[] |
||||
|
createdAt: string |
||||
|
updatedAt: string |
||||
|
} |
||||
|
|
||||
|
interface ListResult { |
||||
|
items: AgentDocumentItem[] |
||||
|
total: number |
||||
|
page: number |
||||
|
pageSize: number |
||||
|
hasMore: boolean |
||||
|
} |
||||
|
|
||||
|
interface DocDetail { |
||||
|
id: number |
||||
|
title: string |
||||
|
type: 'memory' | 'document' |
||||
|
content: string |
||||
|
summary: string | null |
||||
|
tags: string[] |
||||
|
createdAt: string |
||||
|
updatedAt: string |
||||
|
} |
||||
|
|
||||
|
const documents = ref<AgentDocumentItem[]>([]) |
||||
|
const loading = ref(false) |
||||
|
const selectedDoc = ref<DocDetail | null>(null) |
||||
|
const detailLoading = ref(false) |
||||
|
const typeFilter = ref<'memory' | 'document' | ''>('') |
||||
|
const searchQuery = ref('') |
||||
|
|
||||
|
async function fetchDocuments() { |
||||
|
loading.value = true |
||||
|
try { |
||||
|
const params = new URLSearchParams() |
||||
|
params.set('page', '1') |
||||
|
params.set('pageSize', '50') |
||||
|
if (typeFilter.value) params.set('type', typeFilter.value) |
||||
|
if (searchQuery.value) params.set('keyword', searchQuery.value) |
||||
|
|
||||
|
const raw = await request<{ code: number; data: ListResult }>( |
||||
|
`/api/agent-documents?${params.toString()}`, |
||||
|
) |
||||
|
documents.value = raw.data.items |
||||
|
} catch { |
||||
|
$toast.error('文档加载失败') |
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function openDetail(id: number) { |
||||
|
detailLoading.value = true |
||||
|
selectedDoc.value = null |
||||
|
try { |
||||
|
const raw = await request<{ code: number; data: DocDetail }>( |
||||
|
`/api/agent-documents/${id}`, |
||||
|
) |
||||
|
selectedDoc.value = raw.data |
||||
|
} catch { |
||||
|
$toast.error('文档详情加载失败') |
||||
|
} finally { |
||||
|
detailLoading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function closeDetail() { |
||||
|
selectedDoc.value = null |
||||
|
} |
||||
|
|
||||
|
function onFilterChange(type: 'memory' | 'document' | '') { |
||||
|
typeFilter.value = type |
||||
|
fetchDocuments() |
||||
|
} |
||||
|
|
||||
|
let searchTimer: ReturnType<typeof setTimeout> | null = null |
||||
|
function onSearchInput() { |
||||
|
if (searchTimer) clearTimeout(searchTimer) |
||||
|
searchTimer = setTimeout(() => { |
||||
|
fetchDocuments() |
||||
|
}, 400) |
||||
|
} |
||||
|
|
||||
|
watch(() => props.show, (val) => { |
||||
|
if (val && documents.value.length === 0) { |
||||
|
fetchDocuments() |
||||
|
} |
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<ClientOnly> |
||||
|
<Teleport to="body"> |
||||
|
<Transition name="doc-drawer-overlay"> |
||||
|
<div v-if="show" class="doc-drawer-overlay" @click="emit('close')" /> |
||||
|
</Transition> |
||||
|
<Transition name="doc-drawer-slide"> |
||||
|
<aside v-if="show" class="doc-drawer"> |
||||
|
<!-- List view --> |
||||
|
<div v-if="!selectedDoc" class="drawer-list-view"> |
||||
|
<div class="drawer-header"> |
||||
|
<h2 class="drawer-title">文档</h2> |
||||
|
<button class="close-btn" @click="emit('close')"> |
||||
|
<Icon name="lucide:x" /> |
||||
|
</button> |
||||
|
</div> |
||||
|
|
||||
|
<div class="drawer-filters"> |
||||
|
<div class="filter-tabs"> |
||||
|
<button |
||||
|
class="filter-tab" |
||||
|
:class="{ active: typeFilter === '' }" |
||||
|
@click="onFilterChange('')" |
||||
|
> |
||||
|
全部 |
||||
|
</button> |
||||
|
<button |
||||
|
class="filter-tab" |
||||
|
:class="{ active: typeFilter === 'document' }" |
||||
|
@click="onFilterChange('document')" |
||||
|
> |
||||
|
文档 |
||||
|
</button> |
||||
|
<button |
||||
|
class="filter-tab" |
||||
|
:class="{ active: typeFilter === 'memory' }" |
||||
|
@click="onFilterChange('memory')" |
||||
|
> |
||||
|
记忆 |
||||
|
</button> |
||||
|
</div> |
||||
|
<div class="search-box"> |
||||
|
<Icon name="lucide:search" class="search-icon" /> |
||||
|
<input |
||||
|
v-model="searchQuery" |
||||
|
type="text" |
||||
|
placeholder="搜索…" |
||||
|
class="search-input" |
||||
|
@input="onSearchInput" |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="drawer-body"> |
||||
|
<div v-if="loading" class="loading-state">加载中…</div> |
||||
|
<div v-else-if="documents.length === 0" class="empty-state"> |
||||
|
<p>暂无文档</p> |
||||
|
<p class="empty-hint">Agent 创建的文档会显示在这里</p> |
||||
|
</div> |
||||
|
<div v-else class="doc-list"> |
||||
|
<article |
||||
|
v-for="item in documents" |
||||
|
:key="item.id" |
||||
|
class="doc-card" |
||||
|
@click="openDetail(item.id)" |
||||
|
> |
||||
|
<div class="card-meta"> |
||||
|
<span class="type-badge" :class="item.type"> |
||||
|
{{ item.type === 'document' ? '文档' : '记忆' }} |
||||
|
</span> |
||||
|
<span class="card-date">{{ new Date(item.updatedAt).toLocaleDateString('zh-CN') }}</span> |
||||
|
</div> |
||||
|
<h3 class="card-title">{{ item.title }}</h3> |
||||
|
<p v-if="item.summary" class="card-summary">{{ item.summary }}</p> |
||||
|
</article> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Detail view --> |
||||
|
<div v-else class="drawer-detail-view"> |
||||
|
<div class="drawer-header"> |
||||
|
<button class="back-btn" @click="closeDetail"> |
||||
|
<Icon name="lucide:arrow-left" /> |
||||
|
<span>返回</span> |
||||
|
</button> |
||||
|
<button class="close-btn" @click="emit('close')"> |
||||
|
<Icon name="lucide:x" /> |
||||
|
</button> |
||||
|
</div> |
||||
|
|
||||
|
<div class="drawer-body"> |
||||
|
<div v-if="detailLoading" class="loading-state">加载中…</div> |
||||
|
<div v-else-if="selectedDoc" class="doc-detail"> |
||||
|
<div class="detail-meta"> |
||||
|
<span class="type-badge" :class="selectedDoc.type"> |
||||
|
{{ selectedDoc.type === 'document' ? '文档' : '记忆' }} |
||||
|
</span> |
||||
|
<span class="detail-date"> |
||||
|
{{ new Date(selectedDoc.updatedAt).toLocaleString('zh-CN') }} |
||||
|
</span> |
||||
|
</div> |
||||
|
<h2 class="detail-title">{{ selectedDoc.title }}</h2> |
||||
|
<AgentMarkdown :text="selectedDoc.content" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</aside> |
||||
|
</Transition> |
||||
|
</Teleport> |
||||
|
</ClientOnly> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
.doc-drawer-overlay { |
||||
|
position: fixed; |
||||
|
inset: 0; |
||||
|
background: rgba(0, 0, 0, 0.35); |
||||
|
z-index: 299; |
||||
|
} |
||||
|
|
||||
|
.doc-drawer { |
||||
|
position: fixed; |
||||
|
top: 0; |
||||
|
right: 0; |
||||
|
bottom: 0; |
||||
|
width: 440px; |
||||
|
max-width: 90vw; |
||||
|
background: var(--color-canvas, #faf9f5); |
||||
|
z-index: 300; |
||||
|
box-shadow: -2px 0 16px rgba(0, 0, 0, 0.12); |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.drawer-list-view, |
||||
|
.drawer-detail-view { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
height: 100%; |
||||
|
} |
||||
|
|
||||
|
.drawer-header { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
padding: 16px 20px; |
||||
|
border-bottom: 1px solid var(--color-hairline, #e6dfd8); |
||||
|
flex-shrink: 0; |
||||
|
} |
||||
|
|
||||
|
.drawer-title { |
||||
|
font-size: 18px; |
||||
|
font-weight: 600; |
||||
|
color: var(--color-ink, #141413); |
||||
|
margin: 0; |
||||
|
} |
||||
|
|
||||
|
.close-btn { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
width: 32px; |
||||
|
height: 32px; |
||||
|
border: none; |
||||
|
background: none; |
||||
|
border-radius: 8px; |
||||
|
cursor: pointer; |
||||
|
color: var(--color-muted, #6c6a64); |
||||
|
font-size: 18px; |
||||
|
transition: all 0.15s; |
||||
|
} |
||||
|
|
||||
|
.close-btn:hover { |
||||
|
background: var(--color-surface-soft, #efe9de); |
||||
|
color: var(--color-ink, #141413); |
||||
|
} |
||||
|
|
||||
|
.back-btn { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 6px; |
||||
|
padding: 6px 10px; |
||||
|
border: none; |
||||
|
background: none; |
||||
|
border-radius: 8px; |
||||
|
cursor: pointer; |
||||
|
color: var(--color-muted, #6c6a64); |
||||
|
font-size: 14px; |
||||
|
transition: all 0.15s; |
||||
|
} |
||||
|
|
||||
|
.back-btn:hover { |
||||
|
background: var(--color-surface-soft, #efe9de); |
||||
|
color: var(--color-ink, #141413); |
||||
|
} |
||||
|
|
||||
|
.drawer-filters { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 10px; |
||||
|
padding: 12px 20px; |
||||
|
border-bottom: 1px solid var(--color-hairline, #e6dfd8); |
||||
|
flex-shrink: 0; |
||||
|
flex-wrap: wrap; |
||||
|
} |
||||
|
|
||||
|
.filter-tabs { |
||||
|
display: flex; |
||||
|
gap: 4px; |
||||
|
background: var(--color-surface-soft, #efe9de); |
||||
|
border-radius: 8px; |
||||
|
padding: 3px; |
||||
|
} |
||||
|
|
||||
|
.filter-tab { |
||||
|
padding: 4px 12px; |
||||
|
border: none; |
||||
|
background: none; |
||||
|
font-size: 12px; |
||||
|
color: var(--color-muted, #6c6a64); |
||||
|
border-radius: 6px; |
||||
|
cursor: pointer; |
||||
|
transition: all 0.2s; |
||||
|
} |
||||
|
|
||||
|
.filter-tab.active { |
||||
|
background: #fff; |
||||
|
color: var(--color-ink, #141413); |
||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); |
||||
|
} |
||||
|
|
||||
|
.search-box { |
||||
|
position: relative; |
||||
|
flex: 1; |
||||
|
min-width: 100px; |
||||
|
} |
||||
|
|
||||
|
.search-icon { |
||||
|
position: absolute; |
||||
|
left: 10px; |
||||
|
top: 50%; |
||||
|
transform: translateY(-50%); |
||||
|
font-size: 14px; |
||||
|
color: var(--color-muted, #b8b2a6); |
||||
|
} |
||||
|
|
||||
|
.search-input { |
||||
|
width: 100%; |
||||
|
padding: 6px 10px 6px 30px; |
||||
|
border: 1px solid var(--color-hairline, #e6dfd8); |
||||
|
border-radius: 8px; |
||||
|
font-size: 13px; |
||||
|
color: var(--color-body, #3d3d3a); |
||||
|
background: var(--color-canvas, #faf9f5); |
||||
|
outline: none; |
||||
|
transition: border-color 0.2s; |
||||
|
} |
||||
|
|
||||
|
.search-input:focus { |
||||
|
border-color: var(--color-primary, #cc785c); |
||||
|
} |
||||
|
|
||||
|
.drawer-body { |
||||
|
flex: 1; |
||||
|
overflow-y: auto; |
||||
|
padding: 12px 20px; |
||||
|
} |
||||
|
|
||||
|
.loading-state, |
||||
|
.empty-state { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
align-items: center; |
||||
|
padding: 60px 0; |
||||
|
gap: 6px; |
||||
|
font-size: 14px; |
||||
|
color: var(--color-muted, #6c6a64); |
||||
|
} |
||||
|
|
||||
|
.empty-hint { |
||||
|
font-size: 12px; |
||||
|
color: var(--color-muted, #b8b2a6); |
||||
|
} |
||||
|
|
||||
|
.doc-list { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 8px; |
||||
|
} |
||||
|
|
||||
|
.doc-card { |
||||
|
padding: 14px 16px; |
||||
|
background: var(--color-surface-card, #fff); |
||||
|
border: 1px solid var(--color-hairline, #e6dfd8); |
||||
|
border-radius: 10px; |
||||
|
cursor: pointer; |
||||
|
transition: all 0.15s; |
||||
|
} |
||||
|
|
||||
|
.doc-card:hover { |
||||
|
border-color: var(--color-primary, #cc785c); |
||||
|
background: var(--color-canvas, #f5f0e8); |
||||
|
} |
||||
|
|
||||
|
.card-meta { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 8px; |
||||
|
margin-bottom: 6px; |
||||
|
} |
||||
|
|
||||
|
.type-badge { |
||||
|
font-size: 10px; |
||||
|
padding: 2px 6px; |
||||
|
border-radius: 4px; |
||||
|
font-weight: 500; |
||||
|
} |
||||
|
|
||||
|
.type-badge.document { |
||||
|
background: #d4edda; |
||||
|
color: #2d6a4f; |
||||
|
} |
||||
|
|
||||
|
.type-badge.memory { |
||||
|
background: #d1e4f0; |
||||
|
color: #1a5276; |
||||
|
} |
||||
|
|
||||
|
.card-date { |
||||
|
font-size: 11px; |
||||
|
color: var(--color-muted, #b8b2a6); |
||||
|
} |
||||
|
|
||||
|
.card-title { |
||||
|
font-size: 15px; |
||||
|
font-weight: 500; |
||||
|
color: var(--color-ink, #141413); |
||||
|
margin: 0 0 4px; |
||||
|
line-height: 1.4; |
||||
|
} |
||||
|
|
||||
|
.card-summary { |
||||
|
font-size: 13px; |
||||
|
color: var(--color-muted, #6c6a64); |
||||
|
margin: 0; |
||||
|
line-height: 1.5; |
||||
|
display: -webkit-box; |
||||
|
-webkit-line-clamp: 2; |
||||
|
-webkit-box-orient: vertical; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
/* Detail view */ |
||||
|
.doc-detail { |
||||
|
padding: 4px 0; |
||||
|
} |
||||
|
|
||||
|
.detail-meta { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 10px; |
||||
|
margin-bottom: 12px; |
||||
|
} |
||||
|
|
||||
|
.detail-date { |
||||
|
font-size: 12px; |
||||
|
color: var(--color-muted, #b8b2a6); |
||||
|
} |
||||
|
|
||||
|
.detail-title { |
||||
|
font-size: 20px; |
||||
|
font-weight: 600; |
||||
|
color: var(--color-ink, #141413); |
||||
|
margin: 0 0 16px; |
||||
|
line-height: 1.4; |
||||
|
} |
||||
|
|
||||
|
/* Transitions */ |
||||
|
.doc-drawer-overlay-enter-active, |
||||
|
.doc-drawer-overlay-leave-active { |
||||
|
transition: opacity 0.2s ease; |
||||
|
} |
||||
|
|
||||
|
.doc-drawer-overlay-enter-from, |
||||
|
.doc-drawer-overlay-leave-to { |
||||
|
opacity: 0; |
||||
|
} |
||||
|
|
||||
|
.doc-drawer-slide-enter-active, |
||||
|
.doc-drawer-slide-leave-active { |
||||
|
transition: transform 0.25s ease; |
||||
|
} |
||||
|
|
||||
|
.doc-drawer-slide-enter-from, |
||||
|
.doc-drawer-slide-leave-to { |
||||
|
transform: translateX(100%); |
||||
|
} |
||||
|
</style> |
||||
Loading…
Reference in new issue