Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ui/src/api/admin/workspace/application/chat-log.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { del, get, post, postExportExcel, put } from '../../core/request'
import type { ParamsPage, ResponsePage } from '../../core/types'
import type { ChatLog, ChatLogAddKnowledgePayload, Dict } from '@/api/types'
import type { ChatLog, ChatLogAddKnowledgePayload, ChatLogRecord, Dict } from '@/api/types'
import { getWorkspaceId } from '@/utils/resource-context'

const getPrefix = () => `/workspace/${getWorkspaceId()}/application`
Expand All @@ -27,7 +27,7 @@ const postChatLogAddKnowledge = (applicationId: string, data: ChatLogAddKnowledg

/** 分页获取对话中的聊天记录。 */
const getChatRecordLog = (applicationId: string, chatId: string, page: ParamsPage, orderAsc = true) =>
get<ResponsePage<Dict<unknown>>>(`${getPrefix()}/${applicationId}/chat/${chatId}/chat_record/${page.currentPage}/${page.pageSize}`, {
get<ResponsePage<ChatLogRecord>>(`${getPrefix()}/${applicationId}/chat/${chatId}/chat_record/${page.currentPage}/${page.pageSize}`, {
order_asc: orderAsc,
})

Expand Down
15 changes: 15 additions & 0 deletions ui/src/api/types/chat-log.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CHAT_LOG_SOURCE } from '@/api/enums'
import type { Dict } from './common'

export type ChatLogSource = (typeof CHAT_LOG_SOURCE)[keyof typeof CHAT_LOG_SOURCE]

Expand All @@ -22,3 +23,17 @@ export interface ChatLog {
source?: { type?: string } | null
update_time: string
}

/** 对话日志中的单条问答记录,仅列出前端使用的字段。 */
export interface ChatLogRecord {
id: string
/** 提问内容项 */
question: Dict<unknown>
/** 回答内容项列表 */
messages: Dict<unknown>[]
message_tokens?: number
answer_tokens?: number
run_time?: number
paragraph_list?: Dict<unknown>[]
execution_details?: Dict<unknown>[]
}
84 changes: 84 additions & 0 deletions ui/src/conversation-panel/components/log-message-list/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { ref, inject, type InjectionKey } from 'vue'
import type { ChatLogRecord } from '@/api/types'
import type { ChatMessage, ChatRecordMeta } from '../../core/types'

// 只读会话记录(如对话日志):只分页拉取历史记录,不发送、不续跑、不流式。
// 与 message-list 刻意不共用 store 约定,也不注入 MESSAGE_LIST_KEY,表单等内容项因此自动只读。
export interface LogMessageListDeps {
pageRecords: (chatId: string, page: number, size: number) => Promise<{ records: ChatLogRecord[] }>
getChatId: () => string
}

// 从后端 chat_record(reset_chat_record 结构)提取底部信息栏数据
const buildRecordMeta = (raw: ChatLogRecord): ChatRecordMeta => ({
messageTokens: raw.message_tokens || 0,
answerTokens: raw.answer_tokens || 0,
runTime: raw.run_time || 0,
knowledgeCount: (raw.paragraph_list || []).length,
executionDetails: raw.execution_details || [],
})

export function createLogMessageListStore(deps: LogMessageListDeps) {
const { pageRecords, getChatId } = deps

const messages = ref<ChatMessage[]>([])
const loading = ref(false)

const pageSize = 20
const currentPage = ref(1)
const hasMore = ref(true)

// 接口按最新在前分页:页内 reverse 成正序,更旧的页拼到前面
const loadMessages = async (cid: string, page = 1) => {
loading.value = true
try {
const res = await pageRecords(cid, page, pageSize)
const records = [...(res?.records || [])].reverse()
const result = records.flatMap((record): ChatMessage[] => [
{ role: 'USER', content: [record.question], id: record.id + '_USER' },
{
role: 'ASSISTANT',
content: record.messages,
id: record.id + '_ASSISTANT',
recordId: record.id,
record: buildRecordMeta(record),
},
])
messages.value = page === 1 ? result : [...result, ...messages.value]
currentPage.value = page
hasMore.value = records.length >= pageSize
} catch {
// 静默处理
} finally {
loading.value = false
}
}

// 切换记录时由使用方主动调用。先清空再拉:无消息时的 loading 即首页加载
const load = async () => {
const cid = getChatId()
messages.value = []
currentPage.value = 1
hasMore.value = true
if (!cid) return
await loadMessages(cid, 1)
}

const loadMore = async () => {
const cid = getChatId()
if (!hasMore.value || loading.value || !cid) return
await loadMessages(cid, currentPage.value + 1)
}

return { messages, loading, hasMore, load, loadMore }
}

export type LogMessageListStore = ReturnType<typeof createLogMessageListStore>

export const LOG_MESSAGE_LIST_KEY: InjectionKey<LogMessageListStore> = Symbol('log-message-list')

export function useLogMessageListStore(): LogMessageListStore {
const store = inject(LOG_MESSAGE_LIST_KEY)
if (!store) throw new Error('useLogMessageListStore 必须在提供 LOG_MESSAGE_LIST_KEY 的视图内使用')
return store
}
97 changes: 97 additions & 0 deletions ui/src/conversation-panel/components/log-message-list/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<template>
<!-- 只读会话记录:首页加载整块遮罩,上滑加载更多时顶部小转圈 -->
<div ref="msgBoxRef" v-loading="fullLoading" class="log-message-list" @scroll="onScroll">
<template v-if="messages.length > 0">
<div v-if="loading" class="log-message-list__more">
<el-icon class="is-loading" :size="18"><LoadingIcon /></el-icon>
</div>

<div
v-for="(msg, i) in messages"
:key="msg.id || i"
:class="['msg-row', msg.role === 'USER' ? 'user' : 'assistant']"
>
<ContentList :content-list="msg.content" />
<!-- 助手消息底部信息栏(知识来源/执行详情/tokens/耗时) -->
<MessageFooter v-if="msg.role === 'ASSISTANT' && msg.record" :message="msg" />
</div>
</template>

<el-empty v-else-if="!loading" class="flex-1" description="暂无对话记录" :image-size="80" />
</div>
</template>

<script setup lang="ts">
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { Loading as LoadingIcon } from '@element-plus/icons-vue'
import ContentList from '../content-list/index.vue'
import MessageFooter from '../message-footer/index.vue'
import { Scroll } from '../../core/stream'
import { useLogMessageListStore } from './index'

const { messages, loading, hasMore, loadMore } = useLogMessageListStore()

// 整块遮罩:load 会先清空消息,故无消息时的加载即首页加载
const fullLoading = computed(() => loading.value && messages.value.length === 0)

const msgBoxRef = ref<HTMLElement | null>(null)
let scroll: Scroll | null = null
onMounted(() => {
if (msgBoxRef.value) scroll = new Scroll(msgBoxRef.value)
})

// 首页加载完成(消息从无到有)→ 定位到最新一条
watch(
() => messages.value.length,
(len, prevLen) => {
if (len > 0 && !prevLen) nextTick(() => scroll?.forceBottom())
},
)

// 上滑到顶加载更早的记录,并保持当前阅读位置不跳动
const onScroll = async () => {
const el = msgBoxRef.value
if (!el || el.scrollTop > 60 || !hasMore.value || loading.value) return
const prevHeight = el.scrollHeight
await loadMore()
await nextTick()
el.scrollTop += el.scrollHeight - prevHeight
}
</script>

<style scoped lang="scss">
.log-message-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 14px 12px;
scrollbar-width: thin;
display: flex;
flex-direction: column;
align-items: center;
}

.log-message-list__more {
display: flex;
justify-content: center;
padding: 4px 0 10px;
color: var(--t3, #909399);
}

.msg-row {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 10px;
max-width: 680px;
width: 100%;
box-sizing: border-box;
}
.msg-row.user {
align-items: flex-end;
flex-direction: row-reverse;
}
.msg-row.assistant {
align-items: flex-start;
}
</style>
12 changes: 6 additions & 6 deletions ui/src/conversation-panel/components/message-input/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ export interface MessageInputDeps {
composerResetSignal: Ref<number>
// 来自 message-list
messages: Ref<ChatMessage[]>
loading: Ref<boolean>
/** 对话加载(回答生成中):只读,由 message-list 维护 */
streamLoading: Readonly<Ref<boolean>>
sendMessage: (opts: SendMessageOptions) => void
uploadFile: (file: File, chatId: string, onProgress?: (percent: number) => void) => Promise<string>
stop: () => void
Expand All @@ -47,7 +48,7 @@ const UPLOAD_PROGRESS_PENDING_MAX = 95
* message-input 组件 store:本组件独有——输入内容 + 暂存文件 + 发送。
*/
export function createMessageInputStore(deps: MessageInputDeps) {
const { conversations, getChatId, renameChat, composerResetSignal, messages, loading, sendMessage, uploadFile, stop } = deps
const { conversations, getChatId, renameChat, composerResetSignal, messages, streamLoading, sendMessage, uploadFile, stop } = deps
const disabled = computed(() => deps.disabled?.value ?? false)

// 输入状态与附件限制
Expand All @@ -62,15 +63,15 @@ export function createMessageInputStore(deps: MessageInputDeps) {

const placeholder = computed(() => {
if (disabled.value) return '暂无可用智能体'
return loading.value ? '正在回复中...' : '输入消息...'
return streamLoading.value ? '正在回复中...' : '输入消息...'
})
// 附件上传完成前不能发送,否则消息中的附件没有地址
const isUploading = computed(() => fileList.value.some((attachment) => attachment.uploading))
const canSend = computed(
() =>
!disabled.value &&
(question.value.trim().length > 0 || fileList.value.length > 0) &&
!loading.value &&
!streamLoading.value &&
!sending.value &&
!isUploading.value,
)
Expand Down Expand Up @@ -179,7 +180,6 @@ export function createMessageInputStore(deps: MessageInputDeps) {
clearFiles()
} catch (e) {
console.error('send failed:', e)
loading.value = false
} finally {
sending.value = false
}
Expand All @@ -200,7 +200,7 @@ export function createMessageInputStore(deps: MessageInputDeps) {
maxFiles,
maxSizeMB,
acceptList,
loading,
streamLoading,
disabled,
placeholder,
canSend,
Expand Down
6 changes: 3 additions & 3 deletions ui/src/conversation-panel/components/message-input/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { formatFileSize } from '@/utils/number'
import { inputShortcut } from '../../core/shortcuts'
import { useMessageInputStore } from './index'

const { question, fileList, maxFiles, maxSizeMB, acceptList, loading, disabled, placeholder, canSend, imageFiles, addFiles, removeFile, send, stop } =
const { question, fileList, maxFiles, maxSizeMB, acceptList, streamLoading, disabled, placeholder, canSend, imageFiles, addFiles, removeFile, send, stop } =
useMessageInputStore()

// 选择和粘贴附件统一交给输入 Store 处理。
Expand Down Expand Up @@ -117,13 +117,13 @@ const handleKeydown = (event: KeyboardEvent) => {
</div>
</template>
<!-- 上传文件 -->
<el-button text :disabled="disabled || loading || fileList.length >= maxFiles" @click="fileInputRef?.click()">
<el-button text :disabled="disabled || streamLoading || fileList.length >= maxFiles" @click="fileInputRef?.click()">
<MkIcon name="icon_attachment_outlined" :size="18" class="text-N900!" />
</el-button>
</MkTooltip>
<el-divider direction="vertical" class="ml-3! mr-4!" />
<!-- 停止回复 -->
<el-button v-if="loading" circle type="primary" @click="stop">
<el-button v-if="streamLoading" circle type="primary" @click="stop">
<MkIcon name="icon_square_filled" />
</el-button>
<!-- 发送 -->
Expand Down
Loading
Loading