feat: AI 回复 Markdown 渲染 + SSE 解析优化

- 引入 markdown-it + highlight.js 渲染 AI 回复内容
- SSE 解析改用双换行分割,解决跨 chunk 数据丢失
- 用户消息保持纯文本,AI 回复用 Markdown 渲染
- 添加代码高亮、列表、表格等样式
This commit is contained in:
向宁 2026-05-20 21:07:52 +08:00
parent d513c76cd6
commit 9a50d06752
9 changed files with 1322 additions and 294 deletions

View File

@ -18,6 +18,12 @@ VITE_INJECT_APP_LOADING=true
# 打包后是否生成dist.zip
VITE_ARCHIVER=true
# 文件系统服务
VITE_FILE_API_URL=/file-api
# IM 即时通讯服务
VITE_IM_API_URL=/im-api
VITE_IM_SIGNALR_URL=/im-signalr/hubs/chat
# Workflow 工作流服务
VITE_WORKFLOW_API_URL=/workflow-api

View File

@ -1,8 +1,16 @@
import { fileRequestClient } from '#/api/request';
// --- 枚举 ---
export enum ResourceType {
File = 'file',
Folder = 'folder',
}
// --- 类型定义file_system Go 响应为 PascalCase ---
export namespace FileApi {
// S3 原始文件信息
export interface FileInfo {
Key: string;
Size: number;
@ -21,9 +29,90 @@ export namespace FileApi {
max_keys?: number;
token?: string;
}
// 数据库文件元数据
export interface FileMeta {
ID: string;
FolderID: string;
Name: string;
S3Key: string;
S3Bucket: string;
Size: number;
ContentType: string;
OwnerID: string;
CreatedAt: string;
UpdatedAt: string;
}
// 分片上传
export interface MultipartInitResult {
upload_id: string;
}
export interface UploadPartResult {
etag: string;
}
}
// --- 文件操作 ---
export namespace FolderApi {
export interface Folder {
ID: string;
ParentID: string | null;
Name: string;
OwnerID: string;
CreatedAt: string;
UpdatedAt: string;
}
export interface FolderWithChildren {
Folder: Folder;
SubFolders: Folder[];
Files: FileApi.FileMeta[];
}
export interface CreateFolderParams {
name: string;
parent_id?: string | null;
}
export interface RenameFolderParams {
name: string;
}
}
export namespace ShareApi {
export interface ShareLink {
ID: string;
ResourceType: string;
ResourceID: string;
Token: string;
Password: string | null;
ExpiresAt: string | null;
DownloadCount: number;
MaxDownloads: number | null;
CreatedBy: string;
CreatedAt: string;
}
export interface ShareInfo {
Token: string;
ResourceType: string;
FileName: string;
FileSize: number;
HasPassword: boolean;
ExpiresAt: string | null;
}
export interface CreateShareParams {
resource_type: string;
resource_id: string;
password?: string;
expires_at?: string;
max_downloads?: number;
}
}
// --- 文件操作S3 原始) ---
export async function listFilesApi(params: FileApi.ListFilesParams) {
return fileRequestClient.get<FileApi.ListFilesResult>('/files/list', {
@ -65,6 +154,61 @@ export async function getFileContentApi(bucketName: string, objectKey: string) {
});
}
// --- 分片上传 ---
export async function initMultipartUploadApi(
bucketName: string,
objectKey: string,
) {
return fileRequestClient.post<FileApi.MultipartInitResult>(
'/files/multipart/init',
{ bucket_name: bucketName, object_key: objectKey },
);
}
export async function uploadPartApi(
bucketName: string,
objectKey: string,
uploadId: string,
partNumber: number,
data: Blob,
) {
const formData = new FormData();
formData.append('bucket_name', bucketName);
formData.append('object_key', objectKey);
formData.append('upload_id', uploadId);
formData.append('part_number', String(partNumber));
formData.append('data', data);
return fileRequestClient.put<FileApi.UploadPartResult>(
'/files/multipart/part',
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } },
);
}
export async function completeMultipartUploadApi(
bucketName: string,
objectKey: string,
uploadId: string,
parts: { part_number: number; etag: string }[],
) {
return fileRequestClient.post<{ message: string }>(
'/files/multipart/complete',
{ bucket_name: bucketName, object_key: objectKey, upload_id: uploadId, parts },
);
}
export async function abortMultipartUploadApi(
bucketName: string,
objectKey: string,
uploadId: string,
) {
return fileRequestClient.post<{ message: string }>(
'/files/multipart/abort',
{ bucket_name: bucketName, object_key: objectKey, upload_id: uploadId },
);
}
// --- 存储桶操作 ---
export async function listBucketsApi() {
@ -82,3 +226,68 @@ export async function deleteBucketApi(bucketName: string) {
data: { bucket_name: bucketName },
});
}
// --- 文件夹操作 ---
export async function getFolderTreeApi() {
return fileRequestClient.get<FolderApi.Folder[]>('/folders/tree');
}
export async function getFolderApi(folderId: string) {
return fileRequestClient.get<FolderApi.FolderWithChildren>(
`/folders/${folderId}`,
);
}
export async function createFolderApi(params: FolderApi.CreateFolderParams) {
return fileRequestClient.post<FolderApi.Folder>('/folders', params);
}
export async function renameFolderApi(
folderId: string,
params: FolderApi.RenameFolderParams,
) {
return fileRequestClient.put<FolderApi.Folder>(
`/folders/${folderId}`,
params,
);
}
export async function deleteFolderApi(folderId: string) {
return fileRequestClient.delete<{ message: string }>(`/folders/${folderId}`);
}
export async function uploadToFolderApi(
folderId: string,
bucket: string,
file: File,
) {
const formData = new FormData();
formData.append('file', file);
formData.append('bucket', bucket);
return fileRequestClient.post<FileApi.FileMeta>(
`/folders/${folderId}/files`,
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } },
);
}
export async function moveFileApi(fileId: string, targetFolderId: string) {
return fileRequestClient.post<{ message: string }>(`/files/${fileId}/move`, {
target_folder_id: targetFolderId,
});
}
// --- 分享操作 ---
export async function createShareApi(params: ShareApi.CreateShareParams) {
return fileRequestClient.post<ShareApi.ShareLink>('/share', params);
}
export async function deleteShareApi(shareId: string) {
return fileRequestClient.delete<{ message: string }>(`/share/${shareId}`);
}
export async function getShareInfoApi(token: string) {
return fileRequestClient.get<ShareApi.ShareInfo>(`/share/${token}`);
}

32
apps/web-antd/src/shims.d.ts vendored Normal file
View File

@ -0,0 +1,32 @@
declare module 'markdown-it' {
import type { PluginSimple, Renderer, ParserRules, PresetCores } from 'markdown-it';
interface Options {
html?: boolean;
xhtmlOut?: boolean;
breaks?: boolean;
langPrefix?: string;
linkify?: boolean;
typographer?: boolean;
quotes?: string;
highlight?: (str: string, lang: string) => string;
}
type RenderRule = (tokens: any[], idx: number, options: Options, env: any, self: any) => string;
interface MarkdownIt {
render(text: string, env?: any): string;
utils: {
escapeHtml(str: string): string;
};
set(options: Options): MarkdownIt;
}
interface MarkdownItConstructor {
new (preset?: string | PresetCores, options?: Options): MarkdownIt;
(preset?: string | PresetCores, options?: Options): MarkdownIt;
}
const MarkdownIt: MarkdownItConstructor;
export default MarkdownIt;
}

View File

@ -13,6 +13,9 @@ import {
Spin,
} from 'ant-design-vue';
import MarkdownIt from 'markdown-it';
import hljs from 'highlight.js';
import {
createConversationApi,
deleteConversationApi,
@ -21,6 +24,22 @@ import {
streamMessageApi,
} from '#/api/core/ai-chat';
const md = new MarkdownIt({
html: false,
linkify: true,
breaks: true,
highlight(str: string, lang: string) {
if (lang && hljs.getLanguage(lang)) {
try {
return `<pre class="hljs"><code>${hljs.highlight(str, { language: lang }).value}</code></pre>`;
} catch {
// fallback
}
}
return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`;
},
});
const inputMessage = ref('');
const sending = ref(false);
const loading = ref(false);
@ -46,6 +65,10 @@ function formatTime(dateStr: string) {
});
}
function renderMarkdown(text: string) {
return md.render(text);
}
function scrollToBottom() {
nextTick(() => {
if (messageListRef.value) {
@ -140,23 +163,35 @@ async function handleSend(e?: any) {
const reader = stream.getReader();
const decoder = new TextDecoder();
let aiContent = '';
let buffer = '';
// eslint-disable-next-line no-constant-condition
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
const lines = text.split('\n');
buffer += decoder.decode(value, { stream: true });
for (const line of lines) {
if (line.startsWith('event: done')) continue;
if (!line.startsWith('data: ')) continue;
const data = line.slice(6).trim();
if (data === '[DONE]') continue;
// SSE
const parts = buffer.split('\n\n');
//
buffer = parts.pop() ?? '';
for (const part of parts) {
const lines = part.split('\n');
let dataLine = '';
for (const line of lines) {
if (line.startsWith('event: done')) {
break;
}
if (line.startsWith('data: ')) {
dataLine = line.slice(6).trim();
}
}
if (!dataLine || dataLine === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
const parsed = JSON.parse(dataLine);
if (parsed.content) {
aiContent += parsed.content;
const msg = messages.value.find((m) => m.id === aiMsgId);
@ -164,7 +199,7 @@ async function handleSend(e?: any) {
scrollToBottom();
}
} catch {
// skip
// skip malformed JSON
}
}
}
@ -270,15 +305,17 @@ onMounted(loadConversations);
{{ msg.role === 'User' ? '你' : 'AI 助手' }}
</div>
<div
class="inline-block max-w-[70%] whitespace-pre-wrap break-words rounded-lg px-3 py-2 text-left"
class="inline-block max-w-[70%] break-words rounded-lg px-3 py-2 text-left"
:class="
msg.role === 'User'
? 'bg-primary text-primary-foreground'
? 'bg-primary text-primary-foreground whitespace-pre-wrap'
: 'bg-muted text-foreground'
"
>
<Spin v-if="!msg.content" size="small" />
<template v-else>{{ msg.content }}</template>
<template v-else-if="msg.role === 'User'">{{ msg.content }}</template>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-else class="markdown-body" v-html="renderMarkdown(msg.content)" />
</div>
</div>
</div>
@ -324,3 +361,69 @@ onMounted(loadConversations);
</Modal>
</div>
</template>
<style scoped>
.markdown-body {
font-size: 14px;
line-height: 1.6;
word-wrap: break-word;
}
.markdown-body :deep(p) {
margin: 0 0 8px;
}
.markdown-body :deep(p:last-child) {
margin-bottom: 0;
}
.markdown-body :deep(pre) {
margin: 8px 0;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
background: #1e1e2e;
}
.markdown-body :deep(code) {
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
font-size: 13px;
}
.markdown-body :deep(:not(pre) > code) {
padding: 2px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.1);
font-size: 0.9em;
}
.markdown-body :deep(ul),
.markdown-body :deep(ol) {
margin: 8px 0;
padding-left: 20px;
}
.markdown-body :deep(blockquote) {
margin: 8px 0;
padding-left: 12px;
border-left: 3px solid rgba(0, 0, 0, 0.15);
color: rgba(0, 0, 0, 0.6);
}
.markdown-body :deep(table) {
border-collapse: collapse;
margin: 8px 0;
width: 100%;
}
.markdown-body :deep(th),
.markdown-body :deep(td) {
border: 1px solid rgba(0, 0, 0, 0.15);
padding: 6px 12px;
text-align: left;
}
.markdown-body :deep(th) {
background: rgba(0, 0, 0, 0.05);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -35,8 +35,11 @@ const pageSize = ref(20);
const createVisible = ref(false);
const editVisible = ref(false);
const startVisible = ref(false);
const formState = ref({ name: '', code: '', description: '' });
const editingId = ref('');
const startFormState = ref({ title: '', variables: '' });
const startDef = ref<WorkflowDefinitionDto | null>(null);
const statusMap: Record<number, { color: string; text: string }> = {
[DefinitionStatus.Draft]: { color: 'blue', text: '草稿' },
@ -147,13 +150,26 @@ async function handleDisable(id: string) {
}
}
async function handleStart(def: WorkflowDefinitionDto) {
function openStartForm(def: WorkflowDefinitionDto) {
startDef.value = def;
startFormState.value = { title: def.name, variables: '' };
startVisible.value = true;
}
async function handleStart() {
if (!startDef.value) return;
if (!startFormState.value.title.trim()) {
message.warning('请填写流程标题');
return;
}
try {
await startWorkflowInstanceApi({
definitionCode: def.code,
title: def.name,
definitionCode: startDef.value.code,
title: startFormState.value.title,
variables: startFormState.value.variables.trim() || undefined,
});
message.success(`${def.name}」流程已发起`);
message.success(`${startFormState.value.title}」流程已发起`);
startVisible.value = false;
} catch {
message.error('发起失败');
}
@ -194,7 +210,7 @@ onMounted(() => loadData());
v-if="(record as WorkflowDefinitionDto).status === DefinitionStatus.Published && (record as WorkflowDefinitionDto).isEnabled"
size="small"
type="primary"
@click="handleStart(record as WorkflowDefinitionDto)"
@click="openStartForm(record as WorkflowDefinitionDto)"
>
发起
</Button>
@ -233,5 +249,24 @@ onMounted(() => loadData());
</Form.Item>
</Form>
</Modal>
<Modal
v-model:open="startVisible"
:title="`发起流程 - ${startDef?.name ?? ''}`"
@ok="handleStart"
>
<Form layout="vertical">
<Form.Item label="流程标题" required>
<Input v-model:value="startFormState.title" placeholder="请输入流程标题" />
</Form.Item>
<Form.Item label="流程变量JSON">
<Input.TextArea
v-model:value="startFormState.variables"
placeholder='可选,例如:{"reason": "出差申请", "amount": 5000}'
:rows="4"
/>
</Form.Item>
</Form>
</Modal>
</Page>
</template>

View File

@ -1,9 +1,11 @@
<script lang="ts" setup>
import { h, onMounted, ref } from 'vue';
import { computed, h, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import {
Button,
message,
@ -22,14 +24,18 @@ import {
withdrawWorkflowInstanceApi,
type WorkflowInstanceDto,
} from '#/api/core';
import { requestClient } from '#/api/request';
const router = useRouter();
const userStore = useUserStore();
const userId = computed(() => userStore.userInfo?.userId ?? '');
const loading = ref(false);
const data = ref<WorkflowInstanceDto[]>([]);
const total = ref(0);
const pageIndex = ref(1);
const pageSize = ref(20);
const statusFilter = ref<InstanceStatus | undefined>(undefined);
const statusFilter = ref<number | null>(null);
const userMap = ref<Record<string, string>>({});
const instanceStatusMap: Record<number, { color: string; text: string }> = {
[InstanceStatus.Pending]: { color: 'blue', text: '待启动' },
@ -40,7 +46,7 @@ const instanceStatusMap: Record<number, { color: string; text: string }> = {
};
const statusOptions = [
{ value: undefined, label: '全部状态' },
{ value: null, label: '全部状态' },
{ value: InstanceStatus.Pending, label: '待启动' },
{ value: InstanceStatus.Running, label: '运行中' },
{ value: InstanceStatus.Suspended, label: '已挂起' },
@ -66,6 +72,7 @@ const columns = [
key: 'initiatorId',
ellipsis: true,
width: 200,
customRender: ({ text }: { text: string }) => userMap.value[text] || text,
},
{
title: '启动时间',
@ -84,15 +91,32 @@ const columns = [
{ title: '操作', key: 'actions', width: 240 },
];
async function loadUserNames(ids: string[]) {
if (ids.length === 0) return;
const missing = ids.filter((id) => !userMap.value[id]);
if (missing.length === 0) return;
try {
const users = await requestClient.get<{ id: string; username: string }[]>('/users');
for (const u of users) {
userMap.value[u.id] = u.username;
}
} catch {
// ignore will show raw ID
}
}
async function loadData() {
loading.value = true;
try {
const res = await getWorkflowInstancesApi({
pageIndex: pageIndex.value,
pageSize: pageSize.value,
status: statusFilter.value,
status: statusFilter.value ?? undefined,
});
data.value = res.items ?? [];
const items = res.items ?? [];
const ids = [...new Set(items.map((d) => d.initiatorId))];
await loadUserNames(ids);
data.value = items;
total.value = res.total ?? 0;
} catch {
message.error('加载流程实例失败');
@ -134,7 +158,7 @@ async function handleResume(id: string) {
async function handleWithdraw(id: string) {
try {
await withdrawWorkflowInstanceApi(id, '');
await withdrawWorkflowInstanceApi(id, userId.value);
message.success('已撤回');
loadData();
} catch {

View File

@ -1,12 +1,15 @@
<script lang="ts" setup>
import { h, onMounted, ref } from 'vue';
import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import {
Button,
Card,
Form,
Input,
message,
Modal,
Spin,
Tag,
} from 'ant-design-vue';
@ -21,6 +24,10 @@ import {
const loading = ref(false);
const definitions = ref<WorkflowDefinitionDto[]>([]);
const startVisible = ref(false);
const startFormState = ref({ title: '', variables: '' });
const currentDef = ref<WorkflowDefinitionDto | null>(null);
const statusMap: Record<number, { color: string; text: string }> = {
[DefinitionStatus.Draft]: { color: 'blue', text: '草稿' },
[DefinitionStatus.Published]: { color: 'green', text: '已发布' },
@ -41,13 +48,26 @@ async function loadDefinitions() {
}
}
async function handleStart(def: WorkflowDefinitionDto) {
function openStartForm(def: WorkflowDefinitionDto) {
currentDef.value = def;
startFormState.value = { title: def.name, variables: '' };
startVisible.value = true;
}
async function handleStart() {
if (!currentDef.value) return;
if (!startFormState.value.title.trim()) {
message.warning('请填写流程标题');
return;
}
try {
await startWorkflowInstanceApi({
definitionCode: def.code,
title: def.name,
definitionCode: currentDef.value.code,
title: startFormState.value.title,
variables: startFormState.value.variables.trim() || undefined,
});
message.success(`${def.name}」流程已发起`);
message.success(`${startFormState.value.title}」流程已发起`);
startVisible.value = false;
} catch {
message.error('发起失败');
}
@ -60,7 +80,7 @@ onMounted(() => loadDefinitions());
<Page auto-content-height>
<div class="mb-4">
<h3 class="text-lg font-semibold">发起流程</h3>
<p class="mt-1 text-sm text-gray-500">点击已发布的流程即可发起</p>
<p class="mt-1 text-sm text-gray-500">选择需要发起的流程填写信息后提交</p>
</div>
<Spin :spinning="loading">
@ -68,9 +88,7 @@ onMounted(() => loadDefinitions());
<Card
v-for="def in definitions"
:key="def.id"
hoverable
class="cursor-pointer transition-shadow hover:shadow-md"
@click="handleStart(def)"
class="transition-shadow hover:shadow-md"
>
<template #title>
<span class="text-base">{{ def.name }}</span>
@ -82,7 +100,7 @@ onMounted(() => loadDefinitions());
{{ def.description || '暂无描述' }}
</p>
<div class="mt-3 text-right">
<Button type="primary" size="small">发起</Button>
<Button type="primary" size="small" @click="openStartForm(def)">发起</Button>
</div>
</Card>
</div>
@ -90,5 +108,24 @@ onMounted(() => loadDefinitions());
暂无可发起的流程
</div>
</Spin>
<Modal
v-model:open="startVisible"
:title="`发起流程 - ${currentDef?.name ?? ''}`"
@ok="handleStart"
>
<Form layout="vertical">
<Form.Item label="流程标题" required>
<Input v-model:value="startFormState.title" placeholder="请输入流程标题" />
</Form.Item>
<Form.Item label="流程变量JSON">
<Input.TextArea
v-model:value="startFormState.variables"
placeholder='可选,例如:{"reason": "出差申请", "amount": 5000}'
:rows="4"
/>
</Form.Item>
</Form>
</Modal>
</Page>
</template>

48
pnpm-lock.yaml generated
View File

@ -645,6 +645,12 @@ importers:
dayjs:
specifier: 'catalog:'
version: 1.11.20
highlight.js:
specifier: ^11.11.1
version: 11.11.1
markdown-it:
specifier: ^14.1.1
version: 14.1.1
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3))
@ -6628,6 +6634,10 @@ packages:
hey-listen@1.0.8:
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
homedir-polyfill@1.0.3:
resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==}
engines: {node: '>=0.10.0'}
@ -7272,6 +7282,9 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
linkifyjs@4.3.2:
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
@ -7398,6 +7411,10 @@ packages:
resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==}
engines: {node: '>=6'}
markdown-it@14.1.1:
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
hasBin: true
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@ -7414,6 +7431,9 @@ packages:
mdn-data@2.28.0:
resolution: {integrity: sha512-uy9AS1yt+wW5eUEefgE3lOpqPghanUttycV0GXKbiXyBjwvbeE8XPj4u1C+voRfz7dEjwU4NDHTMfZ/s/JtZrQ==}
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
meow@13.2.0:
resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==}
engines: {node: '>=18'}
@ -8081,6 +8101,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@ -9114,6 +9138,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
ufo@1.6.3:
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
@ -15117,6 +15144,8 @@ snapshots:
hey-listen@1.0.8: {}
highlight.js@11.11.1: {}
homedir-polyfill@1.0.3:
dependencies:
parse-passwd: 1.0.0
@ -15673,6 +15702,10 @@ snapshots:
lines-and-columns@1.2.4: {}
linkify-it@5.0.0:
dependencies:
uc.micro: 2.1.0
linkifyjs@4.3.2: {}
listhen@1.9.1:
@ -15814,6 +15847,15 @@ snapshots:
semver: 5.7.2
optional: true
markdown-it@14.1.1:
dependencies:
argparse: 2.0.1
entities: 4.5.0
linkify-it: 5.0.0
mdurl: 2.0.0
punycode.js: 2.3.1
uc.micro: 2.1.0
math-intrinsics@1.1.0: {}
mathml-tag-names@4.0.0: {}
@ -15824,6 +15866,8 @@ snapshots:
mdn-data@2.28.0: {}
mdurl@2.0.0: {}
meow@13.2.0: {}
meow@14.1.0: {}
@ -16587,6 +16631,8 @@ snapshots:
picocolors: 1.1.1
sade: 1.8.1
punycode.js@2.3.1: {}
punycode@2.3.1: {}
pupa@3.3.0:
@ -17726,6 +17772,8 @@ snapshots:
typescript@6.0.3: {}
uc.micro@2.1.0: {}
ufo@1.6.3: {}
ultrahtml@1.6.0: {}