Merge branch 'fix-error' of https://gitea.cirry.cn/cirry/electron-opencode into fix-error

This commit is contained in:
houakang
2026-04-12 15:59:27 +08:00
12 changed files with 2645 additions and 5 deletions

1564
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -46,11 +46,21 @@
"bonjour-service": "^1.3.0",
"electron-squirrel-startup": "^1.0.1",
"element-plus": "^2.13.6",
"github-markdown-css": "^5.9.0",
"js-base64": "3.7.5",
"jsencrypt": "^3.5.4",
"katex": "^0.16.25",
"lucide-vue-next": "^1.0.0",
"pdfkit": "^0.18.0",
"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-router": "^4.6.4"
},

View 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>

View 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>

View 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>

View 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>

View 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;

View 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;

View 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>

View 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;
}

View File

@@ -8,6 +8,7 @@ import App from './App.vue';
import './styles/index.css';
import AppIcon from './components/base/AppIcon.vue';
import LucideIcon from './components/base/LucideIcon.vue';
import 'github-markdown-css/github-markdown-light.css';
const app = createApp(App);
// 注册所有 Element Plus 图标

View File

@@ -21,7 +21,7 @@
</div>
</div>
<!-- 文本内容 -->
<pre v-if="msg.text" class="bubble-text">{{ msg.text }}</pre>
<MarkdownRender v-if="msg.text" :content="msg.text"></MarkdownRender>
<!-- question 类型 part 展示 -->
<template v-if="msg.parts">
<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 发送"
:disabled="isSending"
resize="none"
@keydown.ctrl.enter.prevent="send"
@keydown.enter="send"
/>
<el-button type="primary" :disabled="isSending || !inputText.trim()" :loading="isSending" @click="send"> 发送 </el-button>
</div>
@@ -127,6 +127,7 @@ import { ChatDotRound, ArrowRight, ArrowDown } from '@element-plus/icons-vue';
import { useAppStore } from '@/stores/app.js';
import { sseManager } from '@/http/sse.js';
import axios from 'axios';
import MarkdownRender from '@/components/MarkdownRender/index.vue';
const route = useRoute();
const router = useRouter();