ai agent 列表页点击记录或者记录操作栏的眼睛查看图标,改成直接进入与该 Agent 对话的界面, 与agent 对话时,agent的回复显示该agent的头像
This commit is contained in:
parent
9b1602587b
commit
55db0b7ad1
60
frontend/src/views/pagetype/ai_agent/ai_agent_list.vue
Normal file
60
frontend/src/views/pagetype/ai_agent/ai_agent_list.vue
Normal 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>
|
||||
196
frontend/src/views/pagetype/ai_agent/ai_agent_list_actions.vue
Normal file
196
frontend/src/views/pagetype/ai_agent/ai_agent_list_actions.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user