首页对话相关

This commit is contained in:
2026-08-14 11:25:13 +08:00
parent c088894546
commit 4ec05b507a
9 changed files with 830 additions and 179 deletions
+429 -20
View File
@@ -8,10 +8,41 @@
>
<div class="bubble-wrap">
<div class="bubble">
<template v-if="msg.loading">
<span class="loading-text">思考中</span><span class="loading-dots"><i></i><i></i><i></i></span>
<template v-if="msg.isUser">{{ msg.content }}</template>
<template v-else>
<!-- 初始加载动画 -->
<template v-if="msg.loading && !msg.thinking && !msg.content">
<span class="loading-text">思考中</span><span class="loading-dots"><i></i><i></i><i></i></span>
</template>
<!-- 思考区DeepSeek 风格折叠块 -->
<div v-if="msg.thinking" class="thinking-block">
<button class="thinking-toggle" type="button" @click="toggleThinking(msg)">
<span class="thinking-icon">🧠</span>
<span class="thinking-title">{{ thinkingTitle(msg) }}</span>
<span class="thinking-arrow" :class="{ collapsed: !isThinkingExpanded(msg) }"></span>
</button>
<div v-if="isThinkingExpanded(msg)" class="thinking-content">
<div class="thinking-text">{{ msg.thinking }}</div>
</div>
</div>
<!-- 回答区Markdown 渲染 -->
<div
v-if="msg.content"
class="answer-block"
v-html="renderAnswer(msg.content)"
@click="handleAnswerClick"
></div>
<!-- 失败重试 -->
<button v-if="isErrorMsg(msg)" type="button" class="retry-btn" @click="emit('retry', msg)">
<span class="retry-icon"></span> 重新生成
</button>
</template>
<template v-else>{{ msg.content }}</template>
</div>
<!-- AI 消息操作栏hover 显示 -->
<div v-if="!msg.isUser && msg.content && !isErrorMsg(msg)" class="msg-actions">
<button class="msg-action" title="复制回答" @click="copyText(msg.content)">
<el-icon><DocumentCopy /></el-icon>
</button>
</div>
<div class="time">{{ msg.time }}</div>
</div>
@@ -20,7 +51,11 @@
</template>
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue';
import { nextTick, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { DocumentCopy } from '@element-plus/icons-vue';
import 'highlight.js/styles/github-dark.css';
import { renderMarkdown } from '../utils/markdown';
interface ChatMessage {
id: string;
@@ -28,6 +63,13 @@ interface ChatMessage {
time: string;
isUser: boolean;
loading?: boolean;
thinking?: string;
thinkingSeconds?: number;
retryQuestion?: string;
}
interface Emits {
(e: 'retry', msg: ChatMessage): void;
}
interface Props {
@@ -35,13 +77,63 @@ interface Props {
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const chatListRef = ref<HTMLElement | null>(null);
const isErrorMsg = (msg: ChatMessage): boolean => !msg.isUser && String(msg.content || '').startsWith('❌');
// 思考区折叠:未手动操作时,流式中展开、完成(出现答案)后自动收起(DeepSeek 形态)
const thinkingExpanded = reactive<Record<string, boolean>>({});
const isThinkingExpanded = (msg: ChatMessage): boolean =>
msg.id in thinkingExpanded ? thinkingExpanded[msg.id] : !msg.content;
const toggleThinking = (msg: ChatMessage) => {
thinkingExpanded[msg.id] = !isThinkingExpanded(msg);
};
// Markdown 渲染缓存(content 在作答完成后固定,避免重复解析)
const mdCache = new Map<string, string>();
const renderAnswer = (content: string): string => {
if (!mdCache.has(content)) mdCache.set(content, renderMarkdown(content));
return mdCache.get(content)!;
};
// 复制文本(剪贴板 API + 兼容回退)
const copyText = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
ElMessage.success('已复制');
};
// 代码块复制(事件委托:点击渲染出的 .code-copy-btn
const handleAnswerClick = (e: MouseEvent) => {
const el = e.target as HTMLElement;
const btn = el.closest('.code-copy-btn');
if (!btn) return;
const code = btn.closest('.code-wrapper')?.querySelector('code');
if (code) copyText(code.textContent || '');
};
// 思考区折叠标题:完成(有答案)显示"已深度思考(用时 X 秒)",流式中显示"思考中…"
const thinkingTitle = (msg: ChatMessage): string => {
if (!msg.content) return '思考中…';
return msg.thinkingSeconds != null ? `已深度思考(用时 ${msg.thinkingSeconds} 秒)` : '已深度思考';
};
// 消息新增或内容变化时自动滚动到底部
watch(
() => props.messages.map((m) => `${m.id}|${m.content}|${m.loading ? 1 : 0}`).join('\n'),
() => props.messages.map((m) => `${m.id}|${m.content}|${m.thinking || ''}|${m.loading ? 1 : 0}`).join('\n'),
() => {
nextTick(() => {
const el = chatListRef.value;
@@ -79,35 +171,352 @@ watch(
display: flex;
flex-direction: column;
gap: 5px;
max-width: min(84%, 1120px);
max-width: 100%;
align-items: flex-start;
.message-row.is-user & {
align-items: flex-end;
/* 明确宽度基准:打断 fit-content 与百分比 max-width 的循环,避免气泡被压得过窄导致文字竖排 */
width: 100%;
}
}
.bubble {
font-size: 14px;
font-size: 15px;
line-height: 1.75;
padding: 14px 16px;
border-radius: 16px;
padding: 0;
border-radius: 0;
word-break: break-word;
color: #1f2937;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.96) 0%, rgba(248, 250, 255, 0.92) 100%);
border: 1px solid rgba(226, 233, 244, 0.95);
backdrop-filter: blur(8px);
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.05);
color: #1f2328;
background: transparent;
border: none;
box-shadow: none;
.message-row.is-user & {
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
border-color: rgba(59, 130, 246, 0.4);
color: #fff;
box-shadow: 0 8px 24px rgba(37, 99, 235, 0.35);
width: fit-content;
max-width: 85%;
padding: 10px 14px;
border-radius: 16px 16px 4px 16px;
background: #d3f5d8;
color: #1f2328;
box-shadow: none;
}
.message-row.is-error & {
background: #fef2f2;
border-color: rgba(254, 226, 226, 0.95);
color: #dc2626;
}
}
/* AI 消息操作栏(hover 显示) */
.msg-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.15s;
.message-row:hover & {
opacity: 1;
}
}
.msg-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: transparent;
color: #9ca3af;
cursor: pointer;
transition: background 0.15s, color 0.15s;
&:hover {
background: #f1f2f4;
color: #4b5563;
}
.el-icon {
font-size: 14px;
}
}
/* 失败重试 */
.retry-btn {
display: inline-flex;
align-items: center;
gap: 5px;
margin-top: 10px;
padding: 5px 12px;
border: 1px solid #fecaca;
border-radius: 8px;
background: #fef2f2;
color: #dc2626;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: #fee2e2;
border-color: #fca5a5;
}
.retry-icon {
font-size: 14px;
line-height: 1;
}
}
/* 思考区:DeepSeek 风格折叠块 */
.thinking-block {
margin: 2px 0 10px;
background: #f5f5f6;
border-radius: 10px;
overflow: hidden;
}
.thinking-toggle {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
font-size: 13px;
font-weight: 500;
color: #8b8b98;
cursor: pointer;
text-align: left;
user-select: none;
transition: color 0.15s;
&:hover {
color: #6b7280;
}
}
.thinking-icon {
font-size: 14px;
line-height: 1;
}
.thinking-title {
font-size: 13px;
}
.thinking-arrow {
margin-left: auto;
display: inline-flex;
font-size: 11px;
line-height: 1;
opacity: 0.7;
transition: transform 0.2s;
&.collapsed {
transform: rotate(-90deg);
}
}
.thinking-content {
padding: 0 12px 12px;
animation: thinking-fade-in 0.2s ease;
}
.thinking-text {
font-size: 13px;
line-height: 1.7;
color: #9a9aab;
font-style: italic;
white-space: pre-wrap;
word-break: break-word;
border-top: 1px solid #ececee;
padding-top: 10px;
}
@keyframes thinking-fade-in {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 回答区:Markdown 渲染(DeepSeek 风格排版) */
.answer-block {
white-space: normal;
line-height: 1.75;
font-size: 15px;
word-break: break-word;
& > :first-child {
margin-top: 0;
}
& > :last-child {
margin-bottom: 0;
}
:deep(p) {
margin: 0 0 12px;
}
:deep(h1),
:deep(h2),
:deep(h3),
:deep(h4),
:deep(h5),
:deep(h6) {
font-weight: 600;
line-height: 1.4;
margin: 20px 0 10px;
color: #1f2328;
}
:deep(h1) {
font-size: 20px;
border-bottom: 1px solid #eaecef;
padding-bottom: 8px;
}
:deep(h2) {
font-size: 18px;
}
:deep(h3) {
font-size: 16px;
}
:deep(h4),
:deep(h5),
:deep(h6) {
font-size: 15px;
}
:deep(ul),
:deep(ol) {
margin: 0 0 12px;
padding-left: 22px;
}
:deep(li) {
margin: 3px 0;
}
:deep(li > ul),
:deep(li > ol) {
margin-bottom: 0;
}
:deep(blockquote) {
margin: 0 0 12px;
padding: 6px 14px;
border-left: 4px solid #e0e0e0;
color: #6a737d;
background: #fafafa;
border-radius: 0 8px 8px 0;
}
:deep(code) {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 13px;
padding: 2px 6px;
border-radius: 4px;
background: #f2f3f5;
color: #c7254e;
}
:deep(.code-wrapper) {
position: relative;
margin: 0 0 14px;
&:hover .code-copy-btn {
opacity: 1;
}
}
:deep(.code-copy-btn) {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
padding: 2px 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
background: rgba(255, 255, 255, 0.08);
color: #8b949e;
font-size: 12px;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s;
&:hover {
background: rgba(255, 255, 255, 0.16);
color: #c9d1d9;
}
}
:deep(pre) {
margin: 0;
padding: 12px 14px;
border-radius: 10px;
background: #0d1117;
overflow-x: auto;
line-height: 1.6;
code {
display: block;
padding: 0;
background: transparent;
color: inherit;
font-size: 13px;
}
}
:deep(table) {
width: 100%;
margin: 0 0 14px;
border-collapse: collapse;
font-size: 14px;
th,
td {
border: 1px solid #e2e8f0;
padding: 6px 12px;
text-align: left;
}
th {
background: #f5f7fa;
font-weight: 600;
}
}
:deep(a) {
color: #2563eb;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
:deep(hr) {
border: none;
border-top: 1px solid #eaecef;
margin: 16px 0;
}
:deep(img) {
max-width: 100%;
border-radius: 6px;
}
}
/* 执行中占位 */
.loading-text {
color: #94a3b8;
+2 -2
View File
@@ -177,7 +177,7 @@ onMounted(() => {
}
.input-card {
max-width: 860px;
max-width: 800px;
margin: 0 auto;
background: #fff;
border: 1.5px solid #e2e8f0;
@@ -347,7 +347,7 @@ onMounted(() => {
/* 快捷工作流胶囊 */
.workflow-shortcuts {
max-width: 860px;
max-width: 800px;
margin: 10px auto 0;
display: flex;
flex-wrap: wrap;
+97 -120
View File
@@ -131,71 +131,42 @@
<!-- 对话页 — 无工作流但有消息时展示消息流 -->
<div v-else-if="hasMessages" class="content-body chat-body">
<ChatList :messages="messages" />
<ChatList :messages="messages" @retry="emit('retry', $event)" />
</div>
<!-- 默认占位 — 无工作流时显示引导 -->
<div v-else class="content-body placeholder-body">
<div class="placeholder-content">
<div class="placeholder-icon">
<div class="icon-ring ring-outer">
<div class="icon-ring ring-inner">
<div class="icon-ring ring-core">
<svg class="icon-sparkle" width="28" height="28" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2l1.09 6.26L18 4.5l-3.41 4.74L20 10l-5.41.76L18 15.5l-4.91-2.76L12 19l-1.09-6.26L6 15.5l3.41-4.74L4 10l5.41-.76L6 4.5l4.91 2.74L12 2z" fill="currentColor"/>
</svg>
</div>
</div>
</div>
</div>
<h2 class="placeholder-title">你好,我能帮你解决什么问题?</h2>
<p class="placeholder-desc">选择一个工作流,填写参数后一键生成</p>
<h2 class="placeholder-title">请选择下方工作流进行创作</h2>
<p class="placeholder-desc">点击工作流胶囊快速切换填写参数后一键生成内容</p>
<div class="placeholder-steps">
<div class="step-item">
<div class="step-num">1</div>
<div class="step-text">
<span class="step-label">选择工作流</span>
<span class="step-hint">点击胶囊切换不同创作模式</span>
</div>
</div>
<div class="step-arrow">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>
</div>
<div class="step-item">
<div class="step-num">2</div>
<div class="step-text">
<span class="step-label">填写参数</span>
<span class="step-hint">按需配置内容与样式选项</span>
</div>
</div>
<div class="step-arrow">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>
</div>
<div class="step-item">
<div class="step-num">3</div>
<div class="step-text">
<span class="step-label">一键生成</span>
<span class="step-hint">AI 自动产出结果同步到工作空间</span>
</div>
</div>
</div>
<div class="placeholder-footer">
<span class="footer-hint">还可从左侧对话记录查看历史或工作空间管理作品</span>
<div v-if="placeWorkflows.length" class="workflow-cards">
<button
v-for="wf in placeWorkflows"
:key="wf.id"
type="button"
class="workflow-card"
@click="handlePlaceSelect(wf)"
>
<el-icon class="wf-card-icon"><Promotion /></el-icon>
<span class="wf-card-name">{{ wf.name }}</span>
<span v-if="wf.isTemplate" class="wf-card-tag">模板</span>
</button>
</div>
<div v-else class="placeholder-empty">暂无工作流,可在工作流管理中创建</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import PatchTemplateEditor from '/@/components/patchTemplate/PatchTemplateEditor.vue';
import { uploadFile } from '/@/api/common/upload';
import { collectHomeFormFields } from '../utils/flowDsl';
import { getWorkflowList } from '/@/api/settings/creation';
import { Promotion } from '@element-plus/icons-vue';
import ChatList from './ChatList.vue';
interface Props {
@@ -205,10 +176,16 @@ interface Props {
messages?: any[];
}
interface Emits {
(e: 'retry', msg: any): void;
(e: 'workflow-select', id: string, isTemplate?: boolean): void;
}
const props = withDefaults(defineProps<Props>(), {
activeHistoryId: null,
messages: () => [],
});
const emit = defineEmits<Emits>();
const hasMessages = computed(() => Array.isArray(props.messages) && props.messages.length > 0);
const formValues = reactive<Record<string, any>>({});
@@ -427,6 +404,28 @@ watch(
},
{ immediate: true }
);
// 占位页工作流卡片(DeepSeek 式引导:居中标题 + 工作流卡片)
const placeWorkflows = ref<any[]>([]);
const loadPlaceWorkflows = async () => {
try {
const res: any = await getWorkflowList();
const userList = res.data?.listFlowUserRes?.list || [];
const tplList = res.data?.listFlowTemplateRes?.list || [];
placeWorkflows.value = [
...userList.map((w: any) => ({ id: String(w.id), name: w.flowName || '未命名', isTemplate: false })),
...tplList.map((w: any) => ({ id: String(w.id), name: w.flowTemplateName || '未命名', isTemplate: true })),
];
} catch {
placeWorkflows.value = [];
}
};
const handlePlaceSelect = (wf: any) => {
emit('workflow-select', wf.id, wf.isTemplate);
};
onMounted(() => {
loadPlaceWorkflows();
});
defineExpose({ formValues, fieldFiles, templates, validateFormFields });
</script>
@@ -447,7 +446,7 @@ defineExpose({ formValues, fieldFiles, templates, validateFormFields });
/* ===== 工作流表单 ===== */
.workflow-form-body {
width: min(820px, 100%);
width: min(800px, 100%);
margin: 0 auto;
height: 100%;
display: flex;
@@ -570,12 +569,12 @@ defineExpose({ formValues, fieldFiles, templates, validateFormFields });
/* ===== 对话页 ===== */
.chat-body {
width: min(1060px, 82%);
width: min(800px, 92%);
margin: 0 auto;
height: 100%;
display: flex;
flex-direction: column;
padding: 0 20px;
padding: 0 16px;
box-sizing: border-box;
overflow-y: auto;
scrollbar-width: none;
@@ -583,84 +582,62 @@ defineExpose({ formValues, fieldFiles, templates, validateFormFields });
}
.placeholder-body { display: flex; align-items: center; justify-content: center; }
.placeholder-content { text-align: center; padding: 20px; max-width: 480px; }
/* ===== 图标:层叠圆环 + 星形 ===== */
.placeholder-icon { margin-bottom: 28px; display: flex; justify-content: center; }
.icon-ring {
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s ease;
}
.ring-outer {
width: 88px; height: 88px;
background: radial-gradient(circle at 35% 30%, rgba(59, 130, 246, 0.10) 0%, rgba(139, 92, 246, 0.06) 100%);
border: 1.5px solid rgba(59, 130, 246, 0.15);
&:hover { transform: scale(1.05); }
}
.ring-inner {
width: 62px; height: 62px;
background: radial-gradient(circle at 35% 30%, rgba(59, 130, 246, 0.14) 0%, rgba(139, 92, 246, 0.08) 100%);
border: 1.5px solid rgba(59, 130, 246, 0.20);
}
.ring-core {
width: 42px; height: 42px;
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.35);
}
.icon-sparkle { color: #fff; }
.placeholder-content { text-align: center; padding: 24px 20px; width: 100%; max-width: 640px; }
/* ===== 标题与描述 ===== */
.placeholder-title { font-size: 18px; font-weight: 700; color: #0f172a; margin: 0 0 6px; letter-spacing: -0.2px; line-height: 1.4; }
.placeholder-desc { font-size: 14px; color: #94a3b8; margin: 0 0 32px; line-height: 1.5; }
.placeholder-title { font-size: 20px; font-weight: 700; color: #0f172a; margin: 0 0 8px; letter-spacing: -0.2px; line-height: 1.4; }
.placeholder-desc { font-size: 14px; color: #94a3b8; margin: 0 0 28px; line-height: 1.5; }
/* ===== 步骤引导 ===== */
.placeholder-steps {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
margin-bottom: 28px;
/* ===== 工作流功能卡片(DeepSeek 式引导 ===== */
.workflow-cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
text-align: left;
}
.step-item {
.workflow-card {
display: flex;
align-items: center;
gap: 10px;
text-align: left;
}
.step-num {
width: 28px; height: 28px;
border-radius: 50%;
background: linear-gradient(135deg, #eff6ff 0%, #f0f0ff 100%);
border: 1.5px solid #bfdbfe;
color: #3b82f6;
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s;
.step-item:hover & {
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
color: #fff;
border-color: transparent;
padding: 14px 16px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
cursor: pointer;
transition: all 0.15s;
outline: none;
&:hover {
border-color: #93c5fd;
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.12);
transform: translateY(-1px);
}
}
.step-text { display: flex; flex-direction: column; gap: 1px; }
.step-label { font-size: 13px; font-weight: 600; color: #334155; line-height: 1.3; white-space: nowrap; }
.step-hint { font-size: 11px; color: #94a3b8; line-height: 1.3; white-space: nowrap; }
.step-arrow {
color: #cbd5e1;
display: flex;
align-items: center;
margin: 0 6px;
.wf-card-icon {
font-size: 18px;
color: #3b82f6;
flex-shrink: 0;
}
/* ===== 底部辅助提示 ===== */
.placeholder-footer { margin-top: 4px; }
.footer-hint { font-size: 12px; color: #cbd5e1; line-height: 1.5; }
.wf-card-name {
flex: 1;
font-size: 14px;
font-weight: 600;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wf-card-tag {
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
background: #fff7e6;
color: #d97706;
border: 1px solid #fde68a;
white-space: nowrap;
}
.placeholder-empty {
font-size: 13px;
color: #cbd5e1;
padding: 24px 0;
}
</style>
+101 -26
View File
@@ -21,6 +21,8 @@
:workflow-detail="selectedWorkflowDetail"
:active-history-id="activeHistoryId"
:messages="currentMessages"
@retry="handleRetry"
@workflow-select="handlePlaceWorkflowSelect"
/>
<InputBar ref="inputBarRef" :send-disabled="isSendDisabled" :workflow-locked="isHistoryWorkflow" @send="handleSend" @workflow-select="handleWorkflowSelect" />
</div>
@@ -60,6 +62,7 @@ import TemplateCompleteDialog from './components/TemplateCompleteDialog.vue';
import { applyHomeFormValues } from './utils/flowDsl';
import { getChatModel } from '/@/api/settings/modelConfigV2';
import { openWsExecute } from './utils/wsExecute';
import { parseWsMessage, getDelta, getAnswer, getErrorText } from './utils/wsMessage';
import { getSessionMessages } from '/@/api/settings/session';
import type { ExecutionTreeItem } from '/@/api/settings/creation';
import {
@@ -86,6 +89,9 @@ interface ChatMessage {
time: string;
isUser: boolean;
loading?: boolean;
thinking?: string;
thinkingSeconds?: number;
retryQuestion?: string;
}
interface TreeNode {
@@ -331,6 +337,15 @@ const handleTemplateSaved = async (newId: string) => {
ib?.selectWorkflow?.(newId);
};
// 占位页工作流卡片点击:模板走补全弹窗;用户工作流同步选中输入框,进入表单页
const handlePlaceWorkflowSelect = (id: string, isTemplate?: boolean) => {
if (isTemplate) {
handleWorkflowSelect(id, true);
} else {
inputBarRef.value?.selectWorkflow?.(id);
}
};
const handleMenuChange = (menu: string) => {
activeMenu.value = menu;
};
@@ -427,6 +442,47 @@ const handleSend = async (message: string) => {
}
};
// ===== 失败重试:重新发送同一条用户消息 =====
const handleRetry = async (msg: ChatMessage) => {
let sid = '';
for (const [k, list] of sessionMessages.value) {
if (list.some((m) => m.id === msg.id)) {
sid = k;
break;
}
}
if (!sid || sendingSessions[sid]) return;
const session = historyList.value.find((h) => h.id === sid);
if (!session) return;
const list = sessionMessages.value.get(sid) || [];
const idx = list.findIndex((m) => m.id === msg.id);
if (idx < 0) return;
// 待重发的用户问题:优先失败消息上记录的原文,其次向上找最近一条用户消息
let question = msg.retryQuestion || '';
if (!question) {
for (let i = idx - 1; i >= 0; i--) {
if (list[i].isUser) {
question = list[i].content;
break;
}
}
}
if (!question) return;
// 移除失败消息,重新执行
list.splice(idx, 1);
saveChatCache(sid, list);
sendingSessions[sid] = true;
session.status = 'executing';
const sessionId = session.sessionId || getSessionId();
try {
await runChat(sid, sessionId, question);
} catch {
session.status = 'failed';
} finally {
delete sendingSessions[sid];
}
};
// ===== 工作流执行:选中工作流 → 表单页 WS 执行 → 完成后切回对话页 =====
const runWorkflow = async (sid: string, sessionId: string, message: string, mc: any) => {
const curSession = historyList.value.find((h) => h.id === sid);
@@ -551,6 +607,8 @@ const runChat = async (sid: string, sessionId: string, message: string) => {
let done = false;
let failed = false;
// 思考/生成过程计时起点(收到首帧时开始)
let thinkStart = 0;
const finishChat = (success: boolean, errorMsg?: string) => {
if (done) return;
done = true;
@@ -561,6 +619,8 @@ const runChat = async (sid: string, sessionId: string, message: string) => {
if (list && aiIdx >= 0) {
list[aiIdx].loading = false;
list[aiIdx].content = content;
list[aiIdx].thinking = '';
list[aiIdx].retryQuestion = message;
} else {
addMessage({ id: 'msg-' + Date.now() + '-fail', content, time: formatTime(new Date()), isUser: false });
}
@@ -612,28 +672,50 @@ const runChat = async (sid: string, sessionId: string, message: string) => {
question: message || '',
systemPrompt: '',
onMessage: (raw) => {
let data: any = raw;
if (typeof raw === 'string') {
try { data = JSON.parse(raw); } catch { data = raw; }
}
// 提取回复文本(兼容常见字段)
let text = '';
if (typeof data === 'string') text = data;
else if (data && typeof data === 'object') {
text = data.content ?? data.message ?? data.reply ?? data.text ?? data.answer ?? '';
}
const str = text ? text : typeof data === 'string' ? data : JSON.stringify(data || '');
const msg = parseWsMessage(raw);
if (!msg) return;
const list = sessionMessages.value.get(sid);
const aiIdx = list ? list.findIndex((m) => m.id === aiMsgId) : -1;
if (aiIdx >= 0 && str) {
const ai = list[aiIdx];
if (ai.loading) ai.loading = false;
ai.content = ai.content ? ai.content + (str.startsWith(' ') ? str : '\n' + str) : str;
saveChatCache(sid, list || []);
const ai = aiIdx >= 0 && list ? list[aiIdx] : null;
// 思考步骤:思考区为空时给出占位提示(虚化)
if (msg.type === 'agent_step') {
if (ai && !ai.thinking && !ai.content) {
ai.loading = false;
ai.thinking = '思考中…';
}
if (!thinkStart) thinkStart = Date.now();
return;
}
if (/done|finish|complete|end|success/i.test(str)) {
// 流式 token:思考区逐字累加(虚化显示)
if (msg.type === 'agent_token') {
const delta = getDelta(msg);
if (ai && delta) {
ai.loading = false;
ai.thinking = ai.thinking && ai.thinking !== '思考中…' ? ai.thinking + delta : delta;
saveChatCache(sid, list || []);
}
if (!thinkStart) thinkStart = Date.now();
return;
}
// 作答完成:回答区输出完整答案,记录思考用时并结束
if (msg.type === 'agent_answer') {
const answer = getAnswer(msg);
if (ai && answer) {
ai.loading = false;
ai.content = answer;
ai.thinkingSeconds = thinkStart ? Math.max(1, Math.round((Date.now() - thinkStart) / 1000)) : undefined;
saveChatCache(sid, list || []);
}
finishChat(true);
return;
}
// 错误:展示错误信息并结束
if (msg.type === 'error') {
finishChat(false, getErrorText(msg));
return;
}
// 其余(ack / agent / 未知)忽略
},
onError: () => {
failed = true;
@@ -784,12 +866,7 @@ onMounted(() => {
bottom: 0;
display: flex;
overflow: hidden;
background:
radial-gradient(1200px 600px at 0% 0%, rgba(59, 130, 246, 0.18) 0%, rgba(59, 130, 246, 0) 45%),
radial-gradient(1000px 500px at 100% 0%, rgba(139, 92, 246, 0.15) 0%, rgba(139, 92, 246, 0) 50%),
radial-gradient(800px 400px at 0% 100%, rgba(236, 72, 153, 0.12) 0%, rgba(236, 72, 153, 0) 55%),
radial-gradient(600px 300px at 100% 100%, rgba(16, 185, 129, 0.1) 0%, rgba(16, 185, 129, 0) 60%),
linear-gradient(135deg, #f8fbff 0%, #e6f0ff 60%, #f0f4fa 100%);
background: linear-gradient(180deg, #f7f7f8 0%, #ffffff 100%);
}
.main-wrapper {
@@ -805,9 +882,7 @@ onMounted(() => {
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0) 32%),
radial-gradient(900px 360px at 15% 115%, rgba(99, 102, 241, 0.06) 0%, rgba(99, 102, 241, 0) 70%);
background: transparent;
}
}
+22
View File
@@ -0,0 +1,22 @@
// ===== 首页回答区 Markdown 渲染(DeepSeek 风格)=====
// marked 负责 Markdown 语法,自定义 renderer.code 用 highlight.js 做代码语法高亮,
// 并为代码块包一层带"复制"按钮的结构;DOMPurify 消毒避免 v-html 注入 XSS。
import { marked } from 'marked';
import hljs from 'highlight.js/lib/common';
import DOMPurify from 'dompurify';
const renderer = new marked.Renderer();
renderer.code = ({ text, lang }) => {
const language = lang && hljs.getLanguage(lang) ? lang : 'plaintext';
const highlighted = hljs.highlight(text, { language }).value;
return `<div class="code-wrapper"><button type="button" class="code-copy-btn" data-copy>复制</button><pre><code class="hljs language-${language}">${highlighted}</code></pre></div>`;
};
marked.use({ renderer });
// gfm:GitHub 风格(表格、任务列表等);breaks:单换行转 <br>,贴近对话输出
marked.setOptions({ gfm: true, breaks: true });
/** 将 Markdown 渲染为安全 HTML(含代码高亮与复制按钮,已消毒) */
export function renderMarkdown(content: string): string {
return DOMPurify.sanitize(marked.parse(content) as string);
}
+9
View File
@@ -56,7 +56,16 @@ export function openWsExecute(opts: WsExecuteOptions): WebSocket | null {
ws.onopen = () => {
if (opts.flowContent) {
// 工作流执行:连接后发送 flowContentworkflow_exec 的完整 payload 结构待后端确认)
ws.send(JSON.stringify({ flowContent: opts.flowContent, systemPrompt: opts.systemPrompt || '' }));
} else {
// 普通对话:连接后需发送启动消息(type=agent),后端据此开始推流
ws.send(
JSON.stringify({
type: 'agent',
payload: { modelId: opts.modelId != null ? String(opts.modelId) : '', question: opts.question || '' },
})
);
}
opts.onOpen?.(ws);
};
+63
View File
@@ -0,0 +1,63 @@
// ===== 首页 WebSocket 流式消息解析工具 =====
// 服务端推送统一为 JSON 文本帧:
// {"type":"ack","message":"WebSocket连接成功"} 连接/流程确认(message 为提示文案)
// {"type":"agent","payload":{"modelId":"...","question":"..."}} agent 启动信息
// {"type":"agent_step","message":"模型思考中","data":{"maxStep":15,"step":1}} 思考步骤进度
// {"type":"agent_token","message":"思考中","data":{"delta":"你"}} 流式 token 增量
// {"type":"agent_answer","message":"作答完成","data":{"answer":"..."}} 作答完成(最终答案)
// {"type":"error","message":"..."} 执行失败
// 完成/失败判定均以 type 为准,不再用正则猜测文本。
export interface WsStreamMessage {
type: string;
message?: string;
data?: any;
payload?: any;
}
/**
* 解析服务端推送帧。
* 非 JSON、无 type 的帧返回 null(调用方忽略)。
*/
export function parseWsMessage(raw: any): WsStreamMessage | null {
let data: any = raw;
if (typeof raw === 'string') {
try {
data = JSON.parse(raw);
} catch {
return null;
}
}
if (!data || typeof data !== 'object' || typeof data.type !== 'string') return null;
return data;
}
/** agent_token → data.delta 流式增量 */
export function getDelta(msg: WsStreamMessage): string {
return typeof msg?.data?.delta === 'string' ? msg.data.delta : '';
}
/** agent_answer → data.answer 最终答案 */
export function getAnswer(msg: WsStreamMessage): string {
return typeof msg?.data?.answer === 'string' ? msg.data.answer : '';
}
/** agent_step → { step, maxStep },缺失时 maxStep 为 0 */
export function getStepInfo(msg: WsStreamMessage): { step: number; maxStep: number } | null {
const step = msg?.data?.step;
if (typeof step !== 'number') return null;
const maxStep = msg?.data?.maxStep;
return { step, maxStep: typeof maxStep === 'number' ? maxStep : 0 };
}
/** error → 错误文案(message 优先,其次 data.message / data.error / data.msg */
export function getErrorText(msg: WsStreamMessage): string {
if (typeof msg?.message === 'string' && msg.message) return msg.message;
const d = msg?.data;
if (typeof d === 'string') return d;
if (d && typeof d === 'object') {
const nested = d.message ?? d.error ?? d.msg;
if (typeof nested === 'string' && nested) return nested;
}
return '执行失败,请重试';
}