会话相关

This commit is contained in:
2026-08-14 19:57:46 +08:00
parent 362cb4332f
commit 315d05c3ea
8 changed files with 660 additions and 134 deletions
+5 -1
View File
@@ -40,6 +40,9 @@
</div>
<!-- AI 消息操作栏hover 显示 -->
<div v-if="!msg.isUser && msg.content && !isErrorMsg(msg)" class="msg-actions">
<button class="msg-action" title="重新生成" @click="emit('regenerate', msg)">
<el-icon><RefreshRight /></el-icon>
</button>
<button class="msg-action" title="复制回答" @click="copyText(msg.content)">
<el-icon><DocumentCopy /></el-icon>
</button>
@@ -53,7 +56,7 @@
<script setup lang="ts">
import { nextTick, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { DocumentCopy } from '@element-plus/icons-vue';
import { DocumentCopy, RefreshRight } from '@element-plus/icons-vue';
import 'highlight.js/styles/github-dark.css';
import { renderMarkdown } from '../utils/markdown';
@@ -70,6 +73,7 @@ interface ChatMessage {
interface Emits {
(e: 'retry', msg: ChatMessage): void;
(e: 'regenerate', msg: ChatMessage): void;
}
interface Props {
+22 -3
View File
@@ -41,8 +41,14 @@
<div class="toolbar-right">
<span class="hint-text">Shift+Enter 换行</span>
<button class="send-btn" :disabled="sendDisabled" @click="handleSend">
<el-icon><Top /></el-icon>
<button
class="send-btn"
:class="{ 'is-stop': generating }"
:disabled="!generating && sendDisabled"
@click="generating ? emit('stop') : handleSend()"
>
<el-icon v-if="generating"><VideoPause /></el-icon>
<el-icon v-else><Top /></el-icon>
</button>
</div>
</div>
@@ -69,7 +75,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { Top, MagicStick, Promotion, Close, Paperclip } from '@element-plus/icons-vue';
import { Top, MagicStick, Promotion, Close, Paperclip, VideoPause } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { getWorkflowList } from '/@/api/settings/creation';
@@ -77,11 +83,13 @@ interface Props {
sendDisabled?: boolean;
workflowLocked?: boolean;
hideShortcuts?: boolean;
generating?: boolean;
}
interface Emits {
(e: 'send', message: string): void;
(e: 'workflow-select', workflowId: string | null, isTemplate?: boolean): void;
(e: 'stop'): void;
}
interface Workflow {
@@ -96,6 +104,7 @@ const props = withDefaults(defineProps<Props>(), {
sendDisabled: false,
workflowLocked: false,
hideShortcuts: false,
generating: false,
});
const emit = defineEmits<Emits>();
const message = ref('');
@@ -384,6 +393,16 @@ const onCapsuleAfterLeave = (el: Element) => {
cursor: not-allowed;
transform: none;
}
/* 生成中:变停止按钮 */
&.is-stop {
background: #f43f5e;
color: #fff;
&:hover {
background: #e11d48;
}
}
}
/* 快捷工作流胶囊 */
+42 -3
View File
@@ -130,9 +130,21 @@
</div>
</div>
<!-- 对话页 — 无工作流但有消息时展示消息流 -->
<div v-else-if="hasMessages" :key="'chat'" class="content-body chat-body">
<ChatList :messages="messages" @retry="emit('retry', $event)" />
<!-- 对话页 — 无工作流但有消息或会话结果时展示:结果卡片(仅工作流结果时显示)+ 消息流(下) -->
<div v-else-if="hasMessages || hasResults" :key="'chat'" class="content-body chat-body">
<SessionResults
v-if="hasWorkflowResults"
:results="results"
:loading="resultsLoading"
@preview="emit('preview-result', $event)"
@download="emit('download-result', $event)"
/>
<ChatList
:messages="messages"
class="chat-list-scroll"
@retry="emit('retry', $event)"
@regenerate="emit('regenerate', $event)"
/>
</div>
<!-- 默认占位 — 无工作流时显示引导 -->
@@ -170,26 +182,37 @@ import { collectHomeFormFields } from '../utils/flowDsl';
import { getWorkflowList } from '/@/api/settings/creation';
import { Promotion } from '@element-plus/icons-vue';
import ChatList from './ChatList.vue';
import SessionResults from './SessionResults.vue';
import type { VOSessionInfoResult } from '/@/api/settings/workflow/session';
interface Props {
activeMenu: string;
workflowDetail: any;
activeHistoryId?: string | null;
messages?: any[];
results?: VOSessionInfoResult[];
resultsLoading?: boolean;
}
interface Emits {
(e: 'retry', msg: any): void;
(e: 'regenerate', msg: any): void;
(e: 'workflow-select', id: string, isTemplate?: boolean): void;
(e: 'preview-result', url: string): void;
(e: 'download-result', url: string): void;
}
const props = withDefaults(defineProps<Props>(), {
activeHistoryId: null,
messages: () => [],
results: () => [],
resultsLoading: false,
});
const emit = defineEmits<Emits>();
const hasMessages = computed(() => Array.isArray(props.messages) && props.messages.length > 0);
const hasResults = computed(() => Array.isArray(props.results) && props.results.length > 0);
const hasWorkflowResults = computed(() => Array.isArray(props.results) && props.results.some((r) => r.type === 'workflow'));
const formValues = reactive<Record<string, any>>({});
const fieldFiles = reactive<Record<string, { name: string; url: string }[]>>({});
const uploadingFields = reactive<Record<string, boolean>>({});
@@ -593,11 +616,27 @@ defineExpose({ formValues, fieldFiles, templates, validateFormFields });
flex-direction: column;
padding: 0;
box-sizing: border-box;
overflow: hidden; /* 结果列表固定高度、消息流内部滚动,避免整体滚动与内部冲突 */
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
/* 会话结果区块:固定在消息流上方,结果多时内部滚动(不挤压消息流) */
.chat-body :deep(.session-results) {
flex-shrink: 0;
max-height: 42%;
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
/* 消息流填充剩余高度并在内部滚动 */
.chat-body :deep(.chat-list-scroll) {
flex: 1;
min-height: 0;
height: auto;
}
.placeholder-body { display: flex; align-items: center; justify-content: center; }
.placeholder-content { text-align: center; padding: 24px 20px; width: 100%; max-width: 740px; }
@@ -0,0 +1,317 @@
<template>
<div class="session-results">
<div class="results-header">
<span class="results-title">会话结果</span>
<span v-if="results.length" class="results-count">{{ results.length }}</span>
</div>
<!-- 加载中 -->
<div v-if="loading && results.length === 0" class="results-empty">加载中</div>
<!-- 空态 -->
<div v-else-if="results.length === 0" class="results-empty">暂无会话结果</div>
<!-- 结果列表 -->
<div v-else class="results-list">
<div v-for="r in results" :key="r.id" class="result-card" :class="`is-${statusKey(r.status)}`">
<div class="result-main">
<span class="result-type">{{ r.type === 'workflow' ? '工作流' : '对话' }}</span>
<span class="result-status" :class="`is-${statusKey(r.status)}`">
<i v-if="r.status === 1" class="status-dot"></i>{{ statusText(r.status) }}
</span>
<span v-if="r.totalTokens" class="result-meta">{{ r.totalTokens }} tokens</span>
<span v-if="r.totalFee" class="result-meta">¥{{ r.totalFee }}</span>
<span class="result-time">{{ r.createdAt || '' }}</span>
</div>
<!-- 问题 / 参数摘要 -->
<div v-if="getQuestionText(r)" class="result-question">{{ getQuestionText(r) }}</div>
<!-- AI 回复resultFileUrl 指向的 txt 内容进入自动读取 -->
<div v-if="r.resultFileUrl" class="result-reply">
<div v-if="getReplyState(r)?.loading" class="result-reply-tip">加载回复中</div>
<div v-else-if="getReplyState(r)?.error" class="result-reply-tip is-error">回复加载失败</div>
<div v-else-if="getReplyState(r)?.text" class="result-reply-text">{{ getReplyState(r)?.text }}</div>
<div v-else class="result-reply-tip">暂无回复内容</div>
</div>
<div v-if="r.errorMsg || r.resultFileUrl" class="result-sub">
<span v-if="r.errorMsg" class="result-error"> {{ r.errorMsg }}</span>
<div v-if="r.type === 'workflow' && r.resultFileUrl" class="result-actions">
<button type="button" class="result-action" @click="emit('preview', r.resultFileUrl)">预览</button>
<button type="button" class="result-action" @click="emit('download', r.resultFileUrl)">下载</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, watch } from 'vue';
import { getUpFileUrl } from '/@/utils/gfast';
import type { VOSessionInfoResult } from '/@/api/settings/workflow/session';
interface Props {
results: VOSessionInfoResult[];
loading?: boolean;
}
interface Emits {
(e: 'preview', url: string): void;
(e: 'download', url: string): void;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
});
const emit = defineEmits<Emits>();
// 状态键:1-运行中, 2-成功, 3-失败
const statusKey = (status: number): string => {
if (status === 2) return 'success';
if (status === 3) return 'failed';
return 'running';
};
const statusText = (status: number): string => {
if (status === 2) return '成功';
if (status === 3) return '失败';
return '运行中';
};
// 卡片问题/参数摘要:chat 取 requestParams.question,其余输出参数 JSON 摘要
const getQuestionText = (r: VOSessionInfoResult): string => {
const q = r.requestParams?.question;
if (q) return String(q);
const params = r.requestParams;
if (params && Object.keys(params).length > 0) {
try {
return JSON.stringify(params);
} catch {
return '';
}
}
return '';
};
// 结果卡片回复内容:进入自动 fetch resultFileUrl 指向的 txt 文件并展示
const replyMap = reactive<Record<string, { loading: boolean; text: string; error: boolean }>>({});
const getReplyState = (r: VOSessionInfoResult) => replyMap[r.id];
const loadReply = async (r: VOSessionInfoResult) => {
if (!r.resultFileUrl) return;
if (replyMap[r.id] && (replyMap[r.id].loading || replyMap[r.id].text)) return; // 加载中/已有内容不重复请求
replyMap[r.id] = { loading: true, text: '', error: false };
try {
const res = await fetch(getUpFileUrl(r.resultFileUrl));
if (!res.ok) throw new Error('HTTP ' + res.status);
const text = await res.text();
replyMap[r.id] = { loading: false, text, error: false };
} catch {
replyMap[r.id] = { loading: false, text: '', error: true };
}
};
watch(
() => props.results,
(list) => {
(list || []).forEach((r) => loadReply(r));
},
{ immediate: true }
);
</script>
<style scoped lang="scss">
.session-results {
width: 100%;
max-width: 880px;
margin: 0 auto;
padding: 12px 0 4px;
flex-shrink: 0;
}
.results-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.results-title {
font-size: 12px;
font-weight: 600;
color: #94a3b8;
letter-spacing: 0.4px;
}
.results-count {
font-size: 11px;
color: #cbd5e1;
}
.results-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.results-empty {
font-size: 12px;
color: #cbd5e1;
padding: 8px 0;
}
.result-card {
background: #fff;
border: 1px solid #eef0f3;
border-radius: 10px;
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 4px;
transition: border-color 0.15s, box-shadow 0.15s;
&.is-running {
border-color: #dbeafe;
background: #f8fbff;
}
&.is-failed {
border-color: #fee2e2;
background: #fef6f6;
}
}
.result-main {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.result-type {
font-size: 11px;
font-weight: 600;
color: #64748b;
background: #f1f5f9;
border-radius: 4px;
padding: 1px 6px;
}
.result-status {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
&.is-running {
color: #2563eb;
}
&.is-success {
color: #16a34a;
}
&.is-failed {
color: #dc2626;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #2563eb;
animation: status-pulse 1.2s ease-in-out infinite;
}
}
@keyframes status-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.result-meta {
font-size: 11px;
color: #94a3b8;
}
.result-time {
font-size: 11px;
color: #cbd5e1;
margin-left: auto;
}
.result-sub {
display: flex;
align-items: center;
gap: 10px;
}
.result-error {
font-size: 12px;
color: #dc2626;
flex: 1;
word-break: break-all;
}
.result-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.result-action {
border: 1px solid #e2e8f0;
background: #fff;
color: #3b82f6;
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: #eff6ff;
border-color: #bfdbfe;
}
}
/* 问题/参数摘要 */
.result-question {
font-size: 13px;
font-weight: 600;
color: #1e293b;
padding: 2px 0;
word-break: break-all;
}
/* AI 回复区(txt 内容) */
.result-reply {
margin-top: 2px;
}
.result-reply-text {
font-size: 13px;
color: #334155;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
background: #f8fafc;
border: 1px solid #f1f5f9;
border-radius: 8px;
padding: 8px 10px;
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
}
.result-reply-tip {
font-size: 12px;
color: #94a3b8;
padding: 4px 0;
&.is-error { color: #dc2626; }
}
</style>
+3 -3
View File
@@ -6,7 +6,7 @@
<el-icon class="brand-icon"><ChatDotRound /></el-icon>
<span class="brand-name">AI 助手</span>
</div>
<el-tooltip content="新建话" placement="right">
<el-tooltip content="新建话" placement="right">
<button class="new-chat-icon-btn" @click="handleNewChat">
<el-icon><EditPen /></el-icon>
</button>
@@ -17,7 +17,7 @@
<div class="sidebar-tabs">
<button class="tab-btn" :class="{ active: activeTab === 'history' }" @click="activeTab = 'history'">
<el-icon><ChatLineRound /></el-icon>
话记录
话记录
</button>
<button class="tab-btn" :class="{ active: activeTab === 'workspace' }" @click="activeTab = 'workspace'">
<el-icon><FolderOpened /></el-icon>
@@ -47,7 +47,7 @@
</div>
</div>
<div v-else class="panel-empty">暂无话记录</div>
<div v-else class="panel-empty">暂无话记录</div>
</div>
<!-- 工作空间面板 -->