会话相关
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 快捷工作流胶囊 */
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
<!-- 工作空间面板 -->
|
||||
|
||||
Reference in New Issue
Block a user