ai agent 列表页点击记录或者记录操作栏的眼睛查看图标,改成直接进入与该 Agent 对话的界面, 与agent 对话时,agent的回复显示该agent的头像

This commit is contained in:
jingrow 2026-06-30 14:44:53 +08:00
parent 9b1602587b
commit 55db0b7ad1
7 changed files with 388 additions and 21 deletions

View File

@ -0,0 +1,60 @@
<script setup lang="ts">
/**
* Ai Agent 列表页覆盖组件
*
* 注册导航守卫拦截到 ai-agent 详情页的导航
* - ?edit=1 创建关联该 Agent 的聊天并重定向至 Ai Chat 详情页
* - ?edit=1 或新建模式 放行使用默认详情页编辑
*
* 渲染 DefaultList 保持列表 UI 不变
*/
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { createChat } from '@/shared/api/chat'
import { pageTypeToSlug } from '@/shared/utils/slug'
import DefaultList from '@/core/pagetype/default_list.vue'
const router = useRouter()
const AI_CHAT_SLUG = pageTypeToSlug('Ai Chat')
let removeGuard: (() => void) | null = null
onMounted(() => {
removeGuard = router.beforeEach(async (to) => {
// ai-agent (?edit=1)
if (
to.name === 'PageTypeDetail' &&
String(to.params.entity) === 'ai-agent' &&
to.query.edit !== '1'
) {
const agentName = to.params.id as string
if (agentName && agentName !== 'new' && !agentName.startsWith('new-')) {
try {
const result = await createChat(
undefined, undefined, undefined, 'Ai Agent', agentName, agentName,
)
if (result.success && result.name) {
// Ai Chat
return {
name: 'PageTypeList',
params: { entity: AI_CHAT_SLUG },
query: { selectChat: result.name, agent: agentName },
}
}
} catch (e) {
console.error('Failed to create agent chat:', e)
}
return false //
}
}
})
})
onUnmounted(() => {
removeGuard?.()
})
</script>
<template>
<DefaultList />
</template>

View File

@ -0,0 +1,196 @@
<script setup lang="ts">
/**
* Ai Agent 列表页操作列覆盖组件
*
* 自定义编辑按钮行为
* - 眼睛图标查看 触发默认 view 事件 导航守卫拦截并重定向至对话
* - 编辑按钮 使用 router.push ?edit=1 参数绕过守卫进入详情页编辑
* - 删除/收藏 与默认行为一致
*/
import { computed, ref } from 'vue'
import { toggleLike as toggleLikeApi } from '@/shared/api/timeline'
import { useAuthStore } from '@/shared/stores/auth'
import { t } from '@/shared/i18n'
interface Props {
row: any
entity: string
viewMode?: 'card' | 'list'
context?: {
row: any
entity: string
openDetail: (name: string) => void
editRecord: (row: any) => void
deleteRecord: (name: string) => void
router: any
t: any
viewMode: string
}
}
interface Emits {
(e: 'view', row: any): void
(e: 'delete', row: any): void
(e: 'like-toggled'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const authStore = useAuthStore()
const likeLoading = ref(false)
const canRead = computed(() => props.row._can_read === 1)
const canWrite = computed(() => props.row._can_write === 1)
const canDelete = computed(() => props.row._can_delete === 1)
const currentUser = computed(() => authStore.user?.username || authStore.user?.name || '')
const likedBy = computed<string[]>(() => {
const raw = props.row?._liked_by
if (!raw) return []
if (typeof raw === 'string') { try { return JSON.parse(raw) } catch { return [] } }
return Array.isArray(raw) ? raw : []
})
const isLiked = computed(() => likedBy.value.includes(currentUser.value))
const containerClass = computed(() => {
return props.viewMode === 'card' ? 'actions-container card-actions' : 'actions-container col-actions'
})
async function handleToggleLike() {
if (likeLoading.value) return
likeLoading.value = true
try {
await toggleLikeApi(props.entity, props.row.name, isLiked.value ? 'No' : 'Yes')
const list = [...likedBy.value]
if (isLiked.value) {
const idx = list.indexOf(currentUser.value)
if (idx >= 0) list.splice(idx, 1)
} else {
if (!list.includes(currentUser.value)) list.push(currentUser.value)
}
props.row._liked_by = JSON.stringify(list)
emit('like-toggled')
} catch {
// silently fail
} finally {
likeLoading.value = false
}
}
function handleView() {
emit('view', props.row)
}
function handleEdit() {
// 使 ?edit=1
if (props.context?.router) {
props.context.router.push({
name: 'PageTypeDetail',
params: { entity: 'ai-agent', id: props.row.name },
query: { edit: '1' },
})
}
}
function handleDelete() {
emit('delete', props.row)
}
</script>
<template>
<div :class="containerClass">
<!-- 收藏按钮 -->
<button
class="action-btn like-btn"
:class="{ liked: isLiked }"
@click.stop="handleToggleLike"
:title="isLiked ? t('Unlike') : t('Like')"
:disabled="likeLoading"
>
<i :class="isLiked ? 'fas fa-heart' : 'far fa-heart'"></i>
</button>
<!-- 眼睛图标查看 进入对话路由守卫拦截 -->
<button
class="action-btn"
@click.stop="handleView"
:title="t('View')"
:disabled="!canRead"
:class="{ disabled: !canRead }"
>
<i class="fa fa-eye"></i>
</button>
<!-- 编辑按钮?edit=1 绕过守卫 -->
<button
class="action-btn"
@click.stop="handleEdit"
:title="t('Edit')"
:disabled="!canWrite"
:class="{ disabled: !canWrite }"
>
<i class="fa fa-edit"></i>
</button>
<!-- 删除按钮 -->
<button
class="action-btn delete-btn"
@click.stop="handleDelete"
:title="t('Delete')"
:disabled="!canDelete"
:class="{ disabled: !canDelete }"
>
<i class="fa fa-trash"></i>
</button>
</div>
</template>
<style scoped>
.actions-container {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.card-actions {
padding: 8px 16px;
border-top: 1px solid #f3f4f6;
background: #fafbfc;
margin-top: auto;
}
.action-btn {
width: 28px;
height: 28px;
border: none;
background: #f3f4f6;
color: #6b7280;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.action-btn:hover {
background: #3b82f6;
color: white;
}
.action-btn.delete-btn:hover {
background: #ef4444;
color: white;
}
.action-btn:disabled,
.action-btn.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.action-btn:disabled:hover,
.action-btn.disabled:hover {
background: #f3f4f6;
color: #6b7280;
}
.action-btn.delete-btn:disabled:hover,
.action-btn.delete-btn.disabled:hover {
background: #f3f4f6;
color: #6b7280;
}
</style>

View File

@ -8,13 +8,16 @@ import ChatInput from './ChatInput.vue'
import ThinkingDots from '@/shared/components/ThinkingDots.vue'
import FileUploadDialog from '@/views/pagetype/file/FileUploadDialog.vue'
import { Icon } from '@iconify/vue'
import { getRecord } from '@/shared/api/common'
const props = withDefaults(defineProps<{
chatName?: string
entity?: string
agent?: string
}>(), {
chatName: '',
entity: 'File',
agent: '',
})
const emit = defineEmits<{
@ -28,6 +31,24 @@ const {
loadMessages, sendStream, editAndResend, handleScroll, scrollToBottom,
} = useChat()
// ===== Agent =====
const agentAvatarUrl = ref('')
watch(() => props.agent, async (agent) => {
if (agent) {
try {
const result = await getRecord('Ai Agent', agent)
if (result.success && result.data?.avatar) {
agentAvatarUrl.value = result.data.avatar
return
}
} catch (e) {
console.error('Failed to fetch agent avatar:', e)
}
}
agentAvatarUrl.value = ''
}, { immediate: true })
/** 当前 AI 回复消息streaming 中→取流式消息;已完成→取最后一条 assistant 消息,保持 thought panel 存活) */
const currentResponseMessage = computed(() => {
// 1.
@ -204,6 +225,7 @@ async function sendMessage(chatName: string, content: string, fileRefs?: Array<{
:key="msg.name"
:message="msg"
:streaming="false"
:agent-avatar-url="agentAvatarUrl"
@edit="(content: string) => onEditMessage(msg, content)"
/>
@ -216,6 +238,7 @@ async function sendMessage(chatName: string, content: string, fileRefs?: Array<{
:phase="currentPhase"
:thinking-elapsed="thinkingElapsed"
:tool-calls="activeToolCalls"
:agent-avatar-url="agentAvatarUrl"
/>
</template>

View File

@ -349,7 +349,7 @@ const { isDragOver } = useFileDrop((files) => {
</Transition>
<!-- 右侧聊天区域 -->
<ChatContainer ref="chatContainerRef" :chat-name="chatName" @create-and-send="handleCreateAndSend" @title-changed="onChatTitleChanged" />
<ChatContainer ref="chatContainerRef" :chat-name="chatName" :agent="agent" @create-and-send="handleCreateAndSend" @title-changed="onChatTitleChanged" />
</n-drawer-content>

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, onMounted, nextTick } from 'vue'
import { computed, ref, watch, onMounted, nextTick } from 'vue'
import { Marked } from 'marked'
import hljs from 'highlight.js'
import { t } from '@/shared/i18n'
@ -21,6 +21,8 @@ const props = defineProps<{
thinkingElapsed?: number
/** 当前轮次的实时 Tool Call 列表(在 thinking 面板与消息内容之间渲染) */
toolCalls?: { id: string; name: string; label?: string; icon?: string; color?: string; status: string; summary?: string }[]
/** 关联 Agent 头像 URL仅 assistant 消息显示) */
agentAvatarUrl?: string
}>()
const emit = defineEmits<{
@ -29,6 +31,20 @@ const emit = defineEmits<{
const isTool = computed(() => props.message.role === 'tool')
/** 是否显示 Agent 头像assistant 消息 + 有头像 URL */
const showAgentAvatar = computed(() =>
props.message.role === 'assistant' && !!props.agentAvatarUrl
)
/** 头像加载失败时用 fallback 图标 */
const avatarError = ref(false)
function onAvatarError() {
avatarError.value = true
}
watch(() => props.agentAvatarUrl, () => { avatarError.value = false })
/** 用于显示的 thinking 内容:优先取 prop流式实时其次取 message 已持久化的字段 */
const displayThinking = computed(() => {
return props.thinkingContent ?? props.message.reasoning_content ?? ''
@ -334,29 +350,40 @@ onMounted(() => {
<template>
<div
class="message-row"
:class="[message.role]"
:class="[message.role, { 'has-avatar': showAgentAvatar }]"
@mouseenter="showActions = true"
@mouseleave="showActions = false"
>
<!-- 文件卡片独立于气泡显示在文本上方 -->
<div v-if="messageFiles.length > 0" class="message-files">
<div
v-for="file in messageFiles"
:key="file.recordName"
class="file-card"
@click="previewFile(file)"
>
<div class="file-card-icon" :style="{ color: getFileColorValue(file.ext) }">
<Icon :icon="getFileIconName(file.ext)" class="file-card-icon-svg" />
</div>
<div class="file-card-info">
<span class="file-card-name">{{ file.displayName }}</span>
<span class="file-card-meta">{{ getFileTypeName(file.ext) }}<template v-if="file.size"> · {{ formatFileSize(file.size) }}</template></span>
<!-- Agent 头像 assistant 且有关联 Agent 时显示 -->
<div v-if="showAgentAvatar" class="message-avatar">
<div v-if="avatarError" class="avatar-fallback">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
</div>
<img v-else :src="agentAvatarUrl" alt="" @error="onAvatarError" />
</div>
<div class="message-body">
<!-- 文件卡片独立于气泡显示在文本上方 -->
<div v-if="messageFiles.length > 0" class="message-files">
<div
v-for="file in messageFiles"
:key="file.recordName"
class="file-card"
@click="previewFile(file)"
>
<div class="file-card-icon" :style="{ color: getFileColorValue(file.ext) }">
<Icon :icon="getFileIconName(file.ext)" class="file-card-icon-svg" />
</div>
<div class="file-card-info">
<span class="file-card-name">{{ file.displayName }}</span>
<span class="file-card-meta">{{ getFileTypeName(file.ext) }}<template v-if="file.size"> · {{ formatFileSize(file.size) }}</template></span>
</div>
</div>
</div>
</div>
<!-- Bubble -->
<div class="message-bubble" :class="[message.role, { editing: isEditing }]">
<!-- Bubble -->
<div class="message-bubble" :class="[message.role, { editing: isEditing }]">
<div v-if="isTool" class="tool-indicator">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
@ -472,6 +499,7 @@ onMounted(() => {
</svg>
</button>
</div>
</div><!-- /.message-body -->
</div>
<!-- 文件预览弹窗服务端文件 -->
@ -496,6 +524,55 @@ onMounted(() => {
align-items: flex-end;
}
/* ===== AI 头像agent 对话时显示在 assistant 消息左侧) ===== */
.message-row.has-avatar {
flex-direction: row;
align-items: flex-start;
gap: 12px;
}
.message-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
flex-shrink: 0;
overflow: hidden;
margin-top: 10px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
}
.message-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.avatar-fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #9ca3af;
background: #f3f4f6;
}
.message-body {
flex: 1;
min-width: 0;
}
/* 用户消息message-body 内右对齐气泡 */
.message-row.user .message-body {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.message-row.system {
align-items: center;
}

View File

@ -16,6 +16,7 @@ const route = useRoute()
const router = useRouter()
const chatName = computed(() => route.params.id as string)
const agent = computed(() => route.query.agent as string | undefined)
const isNew = computed(() => !chatName.value || chatName.value === 'new')
const chatContainerRef = ref<InstanceType<typeof ChatContainer> | null>(null)
@ -80,6 +81,7 @@ async function handleSend(content: string, files?: any[], agent?: string) {
v-else
ref="chatContainerRef"
:chat-name="chatName"
:agent="agent"
entity="File"
/>
</div>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, h, onMounted, computed, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { NButton, NSpin, NPagination, NDropdown } from 'naive-ui'
import { Icon } from '@iconify/vue'
import { getChatList, createChat, type AiChat } from '@/shared/api/chat'
@ -22,6 +22,7 @@ const props = defineProps<{
}>()
const router = useRouter()
const route = useRoute()
const chats = ref<AiChat[]>([])
const loading = ref(false)
@ -282,6 +283,13 @@ function onChatTitleChanged(title: string) {
onMounted(() => {
loadChats()
// ai_agent_list
const selectChat = route.query.selectChat as string | undefined
if (selectChat) {
activeChatName.value = selectChat
//
router.replace({ query: { ...route.query, selectChat: undefined } })
}
})
</script>
@ -397,6 +405,7 @@ onMounted(() => {
<ChatContainer
ref="chatContainerRef"
:chat-name="activeChatName"
:agent="route.query.agent as string | undefined"
entity="File"
@title-changed="onChatTitleChanged"
/>