Merge branch 'fix-error' of https://gitea.cirry.cn/cirry/electron-opencode into fix-error
This commit is contained in:
1564
package-lock.json
generated
1564
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -46,11 +46,21 @@
|
|||||||
"bonjour-service": "^1.3.0",
|
"bonjour-service": "^1.3.0",
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
"element-plus": "^2.13.6",
|
"element-plus": "^2.13.6",
|
||||||
|
"github-markdown-css": "^5.9.0",
|
||||||
"js-base64": "3.7.5",
|
"js-base64": "3.7.5",
|
||||||
"jsencrypt": "^3.5.4",
|
"jsencrypt": "^3.5.4",
|
||||||
|
"katex": "^0.16.25",
|
||||||
"lucide-vue-next": "^1.0.0",
|
"lucide-vue-next": "^1.0.0",
|
||||||
"pdfkit": "^0.18.0",
|
"pdfkit": "^0.18.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
"rehype-katex": "^7.0.1",
|
||||||
|
"rehype-raw": "^7.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"remark-math": "^6.0.0",
|
||||||
|
"remark-parse": "^11.0.0",
|
||||||
|
"remark-rehype": "^11.1.2",
|
||||||
|
"unified": "^11.0.5",
|
||||||
|
"unist-util-visit": "^5.0.0",
|
||||||
"vue": "^3.5.32",
|
"vue": "^3.5.32",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
|
|||||||
201
src/renderer/components/MarkdownRender/CitationList.vue
Normal file
201
src/renderer/components/MarkdownRender/CitationList.vue
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useIndexQaStore } from '@/stores/indexQa.js';
|
||||||
|
import { useDocReadingQa } from '@/stores/readQa.js';
|
||||||
|
import { useKbQaStore } from '@/stores/kbQa.js';
|
||||||
|
import { useThinkBase } from '@/stores/thinkBase.js';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
nums: { type: String, default: '' },
|
||||||
|
messageIndex: { type: Number, default: -1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const popoverVisible = ref(false);
|
||||||
|
|
||||||
|
// 根据路由判断使用哪个 store
|
||||||
|
const currentStore = computed(() => {
|
||||||
|
const path = route.path;
|
||||||
|
|
||||||
|
// 判断是 smart-answer 路由、doc 路由、repository 路由还是 deep-think 路由
|
||||||
|
if (path.startsWith('/smart-answer')) {
|
||||||
|
return useIndexQaStore();
|
||||||
|
} else if (path.startsWith('/doc/')) {
|
||||||
|
return useDocReadingQa();
|
||||||
|
} else if (path.startsWith('/repository')) {
|
||||||
|
return useKbQaStore();
|
||||||
|
} else if (path.startsWith('/deep-think')) {
|
||||||
|
return useThinkBase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取 reference 数组
|
||||||
|
const referenceList = computed(() => {
|
||||||
|
if (!currentStore.value || !currentStore.value.reference) return [];
|
||||||
|
return Array.isArray(currentStore.value.reference) ? currentStore.value.reference : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算当前消息对应 reference 的索引(只计算 assistant 消息)
|
||||||
|
const referenceIndex = computed(() => {
|
||||||
|
if (!currentStore.value || props.messageIndex < 0) return -1;
|
||||||
|
|
||||||
|
const messages = currentStore.value.message || [];
|
||||||
|
// 统计当前消息之前有多少条 assistant 消息
|
||||||
|
let assistantCount = 0;
|
||||||
|
for (let i = 0; i <= props.messageIndex; i++) {
|
||||||
|
if (messages[i]?.role === 'assistant') {
|
||||||
|
assistantCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 返回 reference 数组的索引(从 0 开始)
|
||||||
|
return assistantCount > 0 ? assistantCount - 1 : -1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取当前对话轮次的 reference 数据
|
||||||
|
const currentReference = computed(() => {
|
||||||
|
if (referenceIndex.value < 0 || referenceIndex.value >= referenceList.value.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return referenceList.value[referenceIndex.value];
|
||||||
|
});
|
||||||
|
|
||||||
|
// 根据 ID 查找对应的 chunk 数据
|
||||||
|
const citationData = computed(() => {
|
||||||
|
// 优先从 citations 字典中查找(新格式 CIT:n)
|
||||||
|
if (currentStore.value && currentStore.value.citations) {
|
||||||
|
const citations = currentStore.value.citations;
|
||||||
|
const key = `CIT:${props.nums}`;
|
||||||
|
const data = citations[key];
|
||||||
|
if (data) {
|
||||||
|
return {
|
||||||
|
content: data.content,
|
||||||
|
document_name: data.document_keyword,
|
||||||
|
page: data.page,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容旧格式:根据 props.nums 作为索引从 currentReference.chunks 中查找
|
||||||
|
const id = parseInt(props.nums);
|
||||||
|
if (!isNaN(id) && currentReference.value) {
|
||||||
|
const chunks = currentReference.value.chunks || [];
|
||||||
|
const data = chunks[id];
|
||||||
|
if (data) {
|
||||||
|
return {
|
||||||
|
content: data.content,
|
||||||
|
document_name: data.document_name,
|
||||||
|
page: data.page,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 点击时打印数据用于调试
|
||||||
|
const handlePopoverShow = () => {
|
||||||
|
console.log('Citation Clicked:', {
|
||||||
|
nums: props.nums,
|
||||||
|
messageIndex: props.messageIndex,
|
||||||
|
referenceIndex: referenceIndex.value,
|
||||||
|
currentStoreCitations: currentStore.value?.citations,
|
||||||
|
currentReference: currentReference.value,
|
||||||
|
citationData: citationData.value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!--<el-popover v-model:visible="popoverVisible" :width="300" trigger="click" @show="handlePopoverShow">-->
|
||||||
|
<el-popover v-model:visible="popoverVisible" :width="400" trigger="click" @show="handlePopoverShow">
|
||||||
|
<template #reference>
|
||||||
|
<sup class="ref-sup">
|
||||||
|
{{ Number(props.nums) + 1 }}
|
||||||
|
</sup>
|
||||||
|
</template>
|
||||||
|
<div class="popover-content">
|
||||||
|
<template v-if="citationData">
|
||||||
|
<div class="citation-info">
|
||||||
|
<p class="citation-title">引用 #{{ Number(props.nums) + 1 }}</p>
|
||||||
|
<div class="citation-detail">
|
||||||
|
<p v-if="citationData.content" class="citation-content">{{ citationData.content }}</p>
|
||||||
|
<p v-if="citationData.document_name" class="citation-meta truncate w-full">
|
||||||
|
<span class="text-gray-600">来自文档:</span>
|
||||||
|
<span class="font-mediumbreak-all" :title="citationData.document_name">{{ citationData.document_name }}</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="citationData.page" class="citation-meta">
|
||||||
|
<span class="text-gray-600">页码:</span>
|
||||||
|
<span>{{ citationData.page }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p class="text-gray-500">引用编号:{{ props.nums }}(暂无详细信息)</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ref-sup {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(76, 138, 253, 0.1);
|
||||||
|
color: #4c8afd;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 2px;
|
||||||
|
vertical-align: text-top;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-sup:hover {
|
||||||
|
background: #4c8afd;
|
||||||
|
color: #fff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-content {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-info {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-detail {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-content {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
background-color: #f9fafb;
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-meta {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
191
src/renderer/components/MarkdownRender/ThinkBlock.vue
Normal file
191
src/renderer/components/MarkdownRender/ThinkBlock.vue
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import LucideIcon from '@/components/base/LucideIcon.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
content: { type: String, default: '' },
|
||||||
|
isThinking: { type: Boolean, default: false },
|
||||||
|
thinkTime: { type: [Number, String], default: '' },
|
||||||
|
collapsed: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const isExpanded = ref(!props.collapsed); // 默认根据 collapsed 初始化
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.collapsed,
|
||||||
|
(newVal) => {
|
||||||
|
isExpanded.value = !newVal;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleExpand = () => {
|
||||||
|
isExpanded.value = !isExpanded.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const beforeEnter = (el) => {
|
||||||
|
el.style.height = '0';
|
||||||
|
el.style.opacity = '0';
|
||||||
|
el.style.marginTop = '0';
|
||||||
|
};
|
||||||
|
|
||||||
|
const enter = (el, done) => {
|
||||||
|
el.offsetHeight; // trigger reflow
|
||||||
|
el.style.transition = 'height 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s ease, margin-top 0.4s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||||
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
|
el.style.opacity = '1';
|
||||||
|
el.style.marginTop = '12px';
|
||||||
|
el.addEventListener('transitionend', done, { once: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const afterEnter = (el) => {
|
||||||
|
el.style.height = 'auto';
|
||||||
|
el.style.marginTop = '12px';
|
||||||
|
};
|
||||||
|
|
||||||
|
const beforeLeave = (el) => {
|
||||||
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
|
el.style.opacity = '1';
|
||||||
|
el.style.marginTop = '12px';
|
||||||
|
};
|
||||||
|
|
||||||
|
const leave = (el, done) => {
|
||||||
|
el.offsetHeight; // trigger reflow
|
||||||
|
el.style.transition = 'height 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s ease, margin-top 0.4s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||||
|
el.style.height = '0';
|
||||||
|
el.style.opacity = '0';
|
||||||
|
el.style.marginTop = '0';
|
||||||
|
el.addEventListener('transitionend', done, { once: true });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="cot-capsule" :class="{ open: isExpanded }">
|
||||||
|
<div class="cot-header" :class="{ thinking: isThinking }" @click="toggleExpand">
|
||||||
|
<LucideIcon v-if="isThinking" name="loader-2" class="cot-icon cot-icon-spin" />
|
||||||
|
<LucideIcon v-else name="lightbulb" class="cot-icon" style="color: #f59e0b" />
|
||||||
|
<span class="cot-status">
|
||||||
|
{{ isThinking ? '正在深度思考...' : '深度思考过程' }}
|
||||||
|
<span v-if="!isThinking && thinkTime" class="ml-1 opacity-60">({{ thinkTime }}s)</span>
|
||||||
|
</span>
|
||||||
|
<LucideIcon name="chevron-down" class="cot-chevron" />
|
||||||
|
</div>
|
||||||
|
<transition name="think-expand" @before-enter="beforeEnter" @enter="enter" @after-enter="afterEnter" @before-leave="beforeLeave" @leave="leave">
|
||||||
|
<div v-show="isExpanded" class="cot-body">
|
||||||
|
<div class="think-content">
|
||||||
|
{{ props.content }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cot-capsule {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cot-header {
|
||||||
|
height: 36px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cot-header:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cot-header.thinking {
|
||||||
|
border-color: rgba(76, 138, 253, 0.3);
|
||||||
|
color: #4c8afd;
|
||||||
|
background: linear-gradient(270deg, #ffffff, #eff6ff, #f5f3ff, #eff6ff, #ffffff);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
animation: gradient-flow 3s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradient-flow {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cot-icon-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cot-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cot-chevron {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
opacity: 0.4;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cot-capsule.open .cot-chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cot-body {
|
||||||
|
margin-top: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.think-content {
|
||||||
|
padding: 16px 20px;
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.think-expand-enter-from,
|
||||||
|
.think-expand-leave-to {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.think-expand-enter-to,
|
||||||
|
.think-expand-leave-from {
|
||||||
|
margin-top: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.think-expand-enter-active,
|
||||||
|
.think-expand-leave-active {
|
||||||
|
transition:
|
||||||
|
height 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
opacity 0.4s ease,
|
||||||
|
margin-top 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
131
src/renderer/components/MarkdownRender/ThumbDownDialog.vue
Normal file
131
src/renderer/components/MarkdownRender/ThumbDownDialog.vue
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { thumbUpAction } from '@/http/api.js';
|
||||||
|
// 受控组件:由父组件通过 v-model:visible 控制打开/关闭
|
||||||
|
const props = defineProps({
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
width: { type: [String, Number], default: '660' },
|
||||||
|
messageId: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
conversationId: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:visible', 'confirm']);
|
||||||
|
|
||||||
|
const btnNames = ['问题理解有误', '内容太浮夸', '逻辑不清晰', '重复输出', '遗忘上下文', '引用网页质量不高', '语言风格不喜欢'];
|
||||||
|
const feedback = ref('');
|
||||||
|
// 将外部的 visible 与内部 v-model 绑定
|
||||||
|
const dialogVisible = computed({
|
||||||
|
get: () => props.visible,
|
||||||
|
set: (val) => emit('update:visible', val),
|
||||||
|
});
|
||||||
|
|
||||||
|
const btnLoading = ref(false);
|
||||||
|
const handleClose = () => {
|
||||||
|
feedback.value = '';
|
||||||
|
// 关闭弹窗时清空表单,防止下次打开仍然显示旧数据
|
||||||
|
emit('update:visible', false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onConfirm = async () => {
|
||||||
|
// 执行表单校验
|
||||||
|
try {
|
||||||
|
// 例如:await api.createRepository({ name: form.email })
|
||||||
|
btnLoading.value = true;
|
||||||
|
let res = await thumbUpAction({
|
||||||
|
conversation_id: props.conversationId,
|
||||||
|
message_id: props.messageId,
|
||||||
|
feedback: feedback.value,
|
||||||
|
thumbup: false,
|
||||||
|
});
|
||||||
|
if (res.code === 0) {
|
||||||
|
emit('confirm');
|
||||||
|
ElMessage.success('感谢您的反馈');
|
||||||
|
handleClose();
|
||||||
|
// 保存成功后重置表单并关闭弹窗
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message);
|
||||||
|
}
|
||||||
|
// 通知父组件确认事件(如需外部继续处理)
|
||||||
|
} catch (error) {
|
||||||
|
const message = (error && (error.message || error.msg)) || '提交失败,请稍后重试';
|
||||||
|
ElMessage.error(message);
|
||||||
|
} finally {
|
||||||
|
btnLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:width="width"
|
||||||
|
align-center
|
||||||
|
:before-close="handleClose"
|
||||||
|
:append-to-body="true"
|
||||||
|
class="gradient-dialog"
|
||||||
|
style="background: linear-gradient(180deg, #e8f0ff 0%, #ffffff 44%)"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="dialog-title text-[20px]">抱歉,让你有不好的体验</div>
|
||||||
|
<div class="text-[14px] text-quaternary-text">你的反馈将帮助我们更好的进步。</div>
|
||||||
|
</template>
|
||||||
|
<div class="flex gap-4 flex-wrap">
|
||||||
|
<el-button v-for="(btn, index) in btnNames" :key="index" @click="feedback = btn">{{ btn }}</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea :rows="8" v-model="feedback" class="mt-8 w-full common-border rounded-lg p-2" placeholder="其他反馈建议内容" maxlength="300" />
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button color="#1570ef" :loading="btnLoading" @click="onConfirm">确 定</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.el-button + .el-button) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
/* 给当前对话框应用渐变背景色 */
|
||||||
|
.gradient-dialog {
|
||||||
|
background: linear-gradient(180deg, #e8f0ff 0%, #ffffff 44%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 让头部、主体、底部背景透明,以便显示整体渐变 */
|
||||||
|
:deep(.gradient-dialog .el-dialog__header),
|
||||||
|
:deep(.gradient-dialog .el-dialog__body),
|
||||||
|
:deep(.gradient-dialog .el-dialog__footer) {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 搜索编辑区文本域样式 */
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 60px;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #111827;
|
||||||
|
caret-color: #111827;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
textarea::placeholder {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
63
src/renderer/components/MarkdownRender/ThumbUp.vue
Normal file
63
src/renderer/components/MarkdownRender/ThumbUp.vue
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { copyText } from '@/utils/clipboard.js';
|
||||||
|
import { thumbUpAction } from '@/http/api.js';
|
||||||
|
import ThumbDownDialog from '@/components/MarkdownRenderer/ThumbDownDialog.vue';
|
||||||
|
const props = defineProps({
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
conversationId: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
|
||||||
|
const thumbUp = async () => {
|
||||||
|
if (!props.id) {
|
||||||
|
ElMessage.error('暂无相关会话信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = await thumbUpAction({ conversation_id: props.conversationId, message_id: props.id, thumbup: true });
|
||||||
|
if (res.code === 0) {
|
||||||
|
ElMessage.success('点赞成功');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await copyText(props.content);
|
||||||
|
ElMessage.success('复制成功');
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('复制失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="inline-flex items-center justify-center cursor-pointer rounded-full h-10 w-10 hover:bg-[#f2f3f4]" @click="handleCopy">
|
||||||
|
<IconPark name="copy-jci9dmeo" size="20"></IconPark>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex items-center justify-center cursor-pointer rounded-full h-10 w-10 hover:bg-[#f2f3f4]" @click="thumbUp">
|
||||||
|
<IconPark name="thumbs-up" size="20"></IconPark>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex items-center justify-center cursor-pointer rounded-full h-10 w-10 hover:bg-[#f2f3f4]" @click="dialogVisible = true">
|
||||||
|
<IconPark name="thumbs-down" size="20"></IconPark>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ThumbDownDialog v-model:visible="dialogVisible" :conversation-id="conversationId" :message-id="id"></ThumbDownDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
114
src/renderer/components/MarkdownRender/citation.js
Normal file
114
src/renderer/components/MarkdownRender/citation.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { visit } from 'unist-util-visit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义 remark 插件来处理 citation 标记
|
||||||
|
*/
|
||||||
|
export const remarkCitation = () => {
|
||||||
|
return (tree) => {
|
||||||
|
visit(tree, 'text', (node, index, parent) => {
|
||||||
|
const citationRegex = /\[CIT:(\d+)]/g;
|
||||||
|
const matches = [...node.value.matchAll(citationRegex)];
|
||||||
|
|
||||||
|
if (matches.length === 0) return;
|
||||||
|
|
||||||
|
const newChildren = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
matches.forEach((match) => {
|
||||||
|
const [fullMatch, num] = match;
|
||||||
|
const startIndex = match.index;
|
||||||
|
|
||||||
|
// 添加匹配前的文本
|
||||||
|
if (startIndex > lastIndex) {
|
||||||
|
newChildren.push({
|
||||||
|
type: 'text',
|
||||||
|
value: node.value.slice(lastIndex, startIndex),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加citations节点
|
||||||
|
newChildren.push({
|
||||||
|
type: 'citations',
|
||||||
|
data: {
|
||||||
|
hName: 'citations',
|
||||||
|
hProperties: {
|
||||||
|
dataNums: num,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
children: [{ type: 'text', value: num }],
|
||||||
|
});
|
||||||
|
|
||||||
|
lastIndex = startIndex + fullMatch.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加剩余文本
|
||||||
|
if (lastIndex < node.value.length) {
|
||||||
|
newChildren.push({
|
||||||
|
type: 'text',
|
||||||
|
value: node.value.slice(lastIndex),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 替换原节点
|
||||||
|
parent.children.splice(index, 1, ...newChildren);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义 remark 插件来处理 <think> 标签
|
||||||
|
* 将 <think>.....</think> 转换为自定义节点
|
||||||
|
*/
|
||||||
|
export const remarkThink = () => {
|
||||||
|
return (tree) => {
|
||||||
|
visit(tree, 'text', (node, index, parent) => {
|
||||||
|
const thinkRegex = /<think>([\s\S]*?)<\/think>/g;
|
||||||
|
const matches = [...node.value.matchAll(thinkRegex)];
|
||||||
|
|
||||||
|
if (matches.length === 0) return;
|
||||||
|
|
||||||
|
const newChildren = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
matches.forEach((match) => {
|
||||||
|
const [fullMatch, content] = match;
|
||||||
|
const startIndex = match.index;
|
||||||
|
|
||||||
|
// 添加匹配前的文本
|
||||||
|
if (startIndex > lastIndex) {
|
||||||
|
newChildren.push({
|
||||||
|
type: 'text',
|
||||||
|
value: node.value.slice(lastIndex, startIndex),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加 think 节点
|
||||||
|
newChildren.push({
|
||||||
|
type: 'thinkBlock',
|
||||||
|
data: {
|
||||||
|
hName: 'thinkBlock',
|
||||||
|
hProperties: {
|
||||||
|
content: content,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
children: [{ type: 'text', value: content }],
|
||||||
|
});
|
||||||
|
|
||||||
|
lastIndex = startIndex + fullMatch.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加剩余文本
|
||||||
|
if (lastIndex < node.value.length) {
|
||||||
|
newChildren.push({
|
||||||
|
type: 'text',
|
||||||
|
value: node.value.slice(lastIndex),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 替换原节点
|
||||||
|
parent.children.splice(index, 1, ...newChildren);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default remarkCitation;
|
||||||
37
src/renderer/components/MarkdownRender/deep-think.js
Normal file
37
src/renderer/components/MarkdownRender/deep-think.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { visit } from 'unist-util-visit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义 remark 插件来处理 think 代码块
|
||||||
|
* 将 ```think ``` 转换为自定义节点
|
||||||
|
*/
|
||||||
|
export const remarkThink = () => {
|
||||||
|
return (tree) => {
|
||||||
|
// console.log('🔍 remarkThink 开始处理')
|
||||||
|
|
||||||
|
visit(tree, 'code', (node, index, parent) => {
|
||||||
|
// console.log('📝 检查 code 节点, lang:', node.lang, 'value 预览:', node.value?.substring(0, 50))
|
||||||
|
|
||||||
|
// 检查是否是 think 代码块
|
||||||
|
if (node.lang !== 'think') return;
|
||||||
|
|
||||||
|
// console.log('✅ 找到 think 代码块,内容长度:', node.value?.length)
|
||||||
|
|
||||||
|
// 替换为 thinkBlock 节点
|
||||||
|
const thinkNode = {
|
||||||
|
type: 'thinkBlock',
|
||||||
|
data: {
|
||||||
|
hName: 'thinkBlock',
|
||||||
|
hProperties: {
|
||||||
|
content: node.value || '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
parent.children[index] = thinkNode;
|
||||||
|
// console.log('🔄 替换节点完成')
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default remarkThink;
|
||||||
234
src/renderer/components/MarkdownRender/index.vue
Normal file
234
src/renderer/components/MarkdownRender/index.vue
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
<script setup>
|
||||||
|
import { h, ref, watch, defineOptions, useAttrs, computed } from 'vue';
|
||||||
|
import { processMarkdown } from './processor';
|
||||||
|
// import CitationList from './CitationList.vue'
|
||||||
|
import ThinkBlock from './ThinkBlock.vue';
|
||||||
|
// import { getDocInfoAction } from '@/http/api.js'
|
||||||
|
// import { compact, isEqual, uniq } from 'lodash-es'
|
||||||
|
defineOptions({
|
||||||
|
name: 'MarkdownRender',
|
||||||
|
inheritAttrs: false,
|
||||||
|
});
|
||||||
|
const props = defineProps({
|
||||||
|
content: String,
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
content: '',
|
||||||
|
role: '',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// 差异化的doc_id
|
||||||
|
doc_ids: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
// 当前消息在 message 数组中的索引
|
||||||
|
messageIndex: {
|
||||||
|
type: Number,
|
||||||
|
default: -1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 资源图标(与 AnswerView 中保持一致)
|
||||||
|
const pdfIconUrl = new URL('@/assets/pdf.svg', import.meta.url).href;
|
||||||
|
const loadingIconUrl = new URL('@/assets/loading.svg', import.meta.url).href;
|
||||||
|
|
||||||
|
// 与 AnswerView/SearchView 保持一致的文件大小格式化
|
||||||
|
function formatSize(size) {
|
||||||
|
if (size === 0) return '0B';
|
||||||
|
if (!size || isNaN(size)) return '';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(size) / Math.log(1024));
|
||||||
|
const value = size / Math.pow(1024, i);
|
||||||
|
return `${value.toFixed(2)}${units[i]}`;
|
||||||
|
}
|
||||||
|
const localDocInfos = ref([]);
|
||||||
|
|
||||||
|
// 当外部传入 doc_ids 且不为空时,直接以其为参数查询 getDocInfo;否则不做任何处理
|
||||||
|
watch(
|
||||||
|
() => props.doc_ids,
|
||||||
|
// async (ids, newids) => {
|
||||||
|
// if (isEqual(ids, newids)) return
|
||||||
|
// const currIds = uniq(compact(Array.isArray(ids) ? ids : []))
|
||||||
|
//
|
||||||
|
// if (currIds.length === 0) {
|
||||||
|
// // 未传或为空:不展示
|
||||||
|
// localDocInfos.value = []
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// try {
|
||||||
|
// const res = await getDocInfoAction({ doc_ids: currIds })
|
||||||
|
// if (res && res.code === 0) {
|
||||||
|
// localDocInfos.value = Array.isArray(res.data) ? res.data : []
|
||||||
|
// } else {
|
||||||
|
// localDocInfos.value = []
|
||||||
|
// }
|
||||||
|
// } catch {
|
||||||
|
// // ignore fetch errors here; UI 仅为展示
|
||||||
|
// localDocInfos.value = []
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 渲染用的文档信息列表:仅当当前 doc_ids 与 pre_doc_ids 的差集非空时展示
|
||||||
|
const docsToRender = computed(() => {
|
||||||
|
return localDocInfos.value || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// incoming attrs from parent (e.g., class passed on <MarkdownRenderer class="..." />)
|
||||||
|
const attrs = useAttrs();
|
||||||
|
|
||||||
|
// 将 HAST 的属性映射为 Vue/DOM 可识别的属性
|
||||||
|
function mapProps(properties = {}) {
|
||||||
|
const mapped = { ...properties };
|
||||||
|
// HAST 使用 className(数组或字符串),Vue/DOM 需要 class
|
||||||
|
if (mapped.className) {
|
||||||
|
mapped.class = Array.isArray(mapped.className) ? mapped.className.join(' ') : mapped.className;
|
||||||
|
delete mapped.className;
|
||||||
|
}
|
||||||
|
// 兼容 htmlFor → for
|
||||||
|
if (mapped.htmlFor) {
|
||||||
|
mapped.for = mapped.htmlFor;
|
||||||
|
delete mapped.htmlFor;
|
||||||
|
}
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootNode = ref(null);
|
||||||
|
const containerRef = ref(null);
|
||||||
|
const astToVNode = (ast) => {
|
||||||
|
if (ast.type === 'text') {
|
||||||
|
return ast.value;
|
||||||
|
}
|
||||||
|
if (ast.type === 'element') {
|
||||||
|
//console.log('🔍 处理元素节点:', ast.tagName, '属性:', ast.properties)
|
||||||
|
if (ast.tagName === 'citations') {
|
||||||
|
return h(CitationList, {
|
||||||
|
nums: ast.properties?.dataNums || '',
|
||||||
|
messageIndex: props.messageIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 注意:HTML 标签名会被转换为小写
|
||||||
|
if (ast.tagName === 'thinkblock') {
|
||||||
|
//console.log('✨ 渲染 ThinkBlock 组件,content:', ast.properties?.content?.substring(0, 50))
|
||||||
|
return h(ThinkBlock, {
|
||||||
|
content: ast.properties?.content || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const elementProps = mapProps(ast.properties);
|
||||||
|
return h(ast.tagName, elementProps, ast.children?.map(astToVNode) || []);
|
||||||
|
}
|
||||||
|
if (ast.type === 'thinkBlock') {
|
||||||
|
//console.log('⚠️ 发现 thinkBlock 类型节点(非 element):', ast)
|
||||||
|
return h(ThinkBlock, {
|
||||||
|
content: ast.data?.hProperties?.content || ast.properties?.content || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
//console.log('⚠️ 未处理的节点类型:', ast.type)
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听 content 变化
|
||||||
|
watch(
|
||||||
|
() => props.content,
|
||||||
|
async (newContent) => {
|
||||||
|
//console.log('📥 收到内容:', newContent?.substring(0, 100), '包含<think>:', newContent?.includes('<think>'))
|
||||||
|
const ast = await processMarkdown(newContent || '');
|
||||||
|
// We bind the markdown-container class here.
|
||||||
|
// External attributes (like user-answer/robot-answer classes)
|
||||||
|
// are bound to the wrapper div in the template.
|
||||||
|
rootNode.value = h('div', { class: 'markdown-container' }, ast.children?.map(astToVNode) || []);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-bind="attrs" ref="containerRef">
|
||||||
|
<component :is="rootNode"></component>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import 'katex/dist/katex.min.css';
|
||||||
|
|
||||||
|
.markdown-container {
|
||||||
|
line-height: 1.6;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-container > *:last-child {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 基础 Markdown 样式 */
|
||||||
|
.markdown-container p {
|
||||||
|
//color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-container h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
margin: 0.67em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-container h2 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin: 0.75em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-container pre {
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-container code {
|
||||||
|
font-family: monospace;
|
||||||
|
background-color: rgba(175, 184, 193, 0.2);
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-container blockquote {
|
||||||
|
border-left: 4px solid #dfe2e5;
|
||||||
|
color: #6a737d;
|
||||||
|
padding: 0 1em;
|
||||||
|
margin: 0 0 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-container table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-container th,
|
||||||
|
.markdown-container td {
|
||||||
|
border: 1px solid #dfe2e5;
|
||||||
|
padding: 6px 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-container tr {
|
||||||
|
background-color: #fff;
|
||||||
|
border-top: 1px solid #c6cbd1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-container tr:nth-child(2n) {
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 数学公式样式微调,避免溢出并统一间距 */
|
||||||
|
.markdown-container .katex-display {
|
||||||
|
margin: 1em 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-container .katex {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
99
src/renderer/components/MarkdownRender/processor.js
Normal file
99
src/renderer/components/MarkdownRender/processor.js
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { unified } from 'unified';
|
||||||
|
import remarkParse from 'remark-parse';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import remarkMath from 'remark-math';
|
||||||
|
import remarkRehype from 'remark-rehype';
|
||||||
|
import rehypeKatex from 'rehype-katex';
|
||||||
|
import rehypeRaw from 'rehype-raw';
|
||||||
|
// 启用 KaTeX 的化学公式扩展(支持 \ce{})
|
||||||
|
import 'katex/contrib/mhchem';
|
||||||
|
|
||||||
|
import { remarkCitation } from '@/components/MarkdownRender/citation.js';
|
||||||
|
import { remarkThink } from '@/components/MarkdownRender/deep-think.js';
|
||||||
|
|
||||||
|
// 调试插件函数
|
||||||
|
// function createDebugPlugin(stepName) {
|
||||||
|
// return function debugPlugin() {
|
||||||
|
// return function transformer(tree,) {
|
||||||
|
// console.log(`=== ${stepName} ===`)
|
||||||
|
// console.log('Node type:', tree.type)
|
||||||
|
// console.log('Children count:', tree.children ? tree.children.length : 0)
|
||||||
|
// // 只输出前几个节点避免控制台爆炸
|
||||||
|
// if (tree.children) {
|
||||||
|
// console.log('First 5 children:', tree.children.slice(0, 5))
|
||||||
|
// }
|
||||||
|
// console.log('=================')
|
||||||
|
// return tree
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Markdown 内容处理为 HTML 字符串的异步函数
|
||||||
|
*
|
||||||
|
* @param content - 原始 Markdown 文本内容
|
||||||
|
* @returns 处理后的 HTML 字符串
|
||||||
|
*/
|
||||||
|
export async function processMarkdown(content) {
|
||||||
|
// 确保 content 是字符串,防止 replace 报错
|
||||||
|
const safeContent = typeof content === 'string' ? content : String(content || '');
|
||||||
|
|
||||||
|
// 预处理:在 Markdown 解析之前,将 <think> 标签转换为特殊的 Markdown 标记
|
||||||
|
// 这样可以确保它们不会被当作 HTML 处理
|
||||||
|
let processed = safeContent.replace(/<think>([\s\S]*?)<\/think>/g, (match, thinkContent) => {
|
||||||
|
// 使用特殊的代码块标记来保护内容
|
||||||
|
return `
|
||||||
|
|
||||||
|
~~~think
|
||||||
|
${thinkContent}
|
||||||
|
~~~
|
||||||
|
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// console.log('🔧 预处理后包含~~~think:', processed.includes('~~~think'))
|
||||||
|
|
||||||
|
// 预处理数学公式标记:
|
||||||
|
// 1. 将 \(公式\) 格式的行内数学公式转换为 $公式$ 格式,以便 remark-math 正确识别
|
||||||
|
// 2. 将 \[公式\] 格式的块级数学公式转换为 $$公式$$ 格式,以便 remark-math 正确识别
|
||||||
|
processed = processed.replace(/\\\(([^]*?)\\\)/g, (_, math) => `$${math}$`).replace(/\\\[([^]*?)\\\]/g, (_, math) => `$$${math}$$`);
|
||||||
|
|
||||||
|
// 创建统一处理器实例,并配置处理流水线
|
||||||
|
const processor = unified()
|
||||||
|
// 使用 remark-parse 将 Markdown 文本解析为 MDAST
|
||||||
|
.use(remarkParse)
|
||||||
|
// .use(createDebugPlugin('After remarkParse'))
|
||||||
|
// 先处理 <think> 标签,然后处理 citation
|
||||||
|
.use(remarkThink)
|
||||||
|
.use(remarkCitation)
|
||||||
|
// 使用 remark-gfm 添加对 GFM 扩展语法的支持
|
||||||
|
.use(remarkGfm)
|
||||||
|
// 使用 remark-math 识别和解析数学公式语法
|
||||||
|
.use(remarkMath)
|
||||||
|
// 使用 remark-rehype 将 MDAST 转换为 HAST (HTML AST)
|
||||||
|
.use(remarkRehype)
|
||||||
|
// .use(createDebugPlugin('After remarkRehype'))
|
||||||
|
// 使用 rehype-raw 允许保留原始 HTML 标签
|
||||||
|
.use(rehypeRaw)
|
||||||
|
// 使用 rehype-katex 将数学/化学公式渲染为 KaTeX
|
||||||
|
.use(rehypeKatex, { throwOnError: false, strict: 'ignore' });
|
||||||
|
// 添加 rehype-stringify 将 HAST 编译为 HTML 字符串
|
||||||
|
// .use(rehypeStringify)
|
||||||
|
|
||||||
|
// 执行处理流程,将预处理后的内容转换为 HTML
|
||||||
|
// const file = await processor.process(processed)
|
||||||
|
// 返回处理结果中的 HTML 字符串
|
||||||
|
// return file.value
|
||||||
|
|
||||||
|
// 先解析为MDAST
|
||||||
|
const mdast = processor.parse(processed);
|
||||||
|
// console.log('🌳 MDAST 节点类型统计:', mdast.children?.map((n) => n.type).join(', '))
|
||||||
|
// 在进行转换
|
||||||
|
const result = await processor.run(mdast);
|
||||||
|
// console.log('✅ HAST 结果:', result)
|
||||||
|
// console.log(
|
||||||
|
// '🎯 HAST 子节点:',
|
||||||
|
// result.children?.map((n) => ({ type: n.type, tagName: n.tagName })),
|
||||||
|
// )
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import App from './App.vue';
|
|||||||
import './styles/index.css';
|
import './styles/index.css';
|
||||||
import AppIcon from './components/base/AppIcon.vue';
|
import AppIcon from './components/base/AppIcon.vue';
|
||||||
import LucideIcon from './components/base/LucideIcon.vue';
|
import LucideIcon from './components/base/LucideIcon.vue';
|
||||||
|
import 'github-markdown-css/github-markdown-light.css';
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
// 注册所有 Element Plus 图标
|
// 注册所有 Element Plus 图标
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 文本内容 -->
|
<!-- 文本内容 -->
|
||||||
<pre v-if="msg.text" class="bubble-text">{{ msg.text }}</pre>
|
<MarkdownRender v-if="msg.text" :content="msg.text"></MarkdownRender>
|
||||||
<!-- question 类型 part 展示 -->
|
<!-- question 类型 part 展示 -->
|
||||||
<template v-if="msg.parts">
|
<template v-if="msg.parts">
|
||||||
<div v-for="(p, idx) in msg.parts.filter((p) => p.type === 'question')" :key="'question-' + idx" class="question-part">
|
<div v-for="(p, idx) in msg.parts.filter((p) => p.type === 'question')" :key="'question-' + idx" class="question-part">
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
placeholder="输入消息,Ctrl+Enter 发送"
|
placeholder="输入消息,Ctrl+Enter 发送"
|
||||||
:disabled="isSending"
|
:disabled="isSending"
|
||||||
resize="none"
|
resize="none"
|
||||||
@keydown.ctrl.enter.prevent="send"
|
@keydown.enter="send"
|
||||||
/>
|
/>
|
||||||
<el-button type="primary" :disabled="isSending || !inputText.trim()" :loading="isSending" @click="send"> 发送 </el-button>
|
<el-button type="primary" :disabled="isSending || !inputText.trim()" :loading="isSending" @click="send"> 发送 </el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,6 +127,7 @@ import { ChatDotRound, ArrowRight, ArrowDown } from '@element-plus/icons-vue';
|
|||||||
import { useAppStore } from '@/stores/app.js';
|
import { useAppStore } from '@/stores/app.js';
|
||||||
import { sseManager } from '@/http/sse.js';
|
import { sseManager } from '@/http/sse.js';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import MarkdownRender from '@/components/MarkdownRender/index.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
Reference in New Issue
Block a user