快乐的梦鱼 2 weeks ago
parent
commit
e69d9c5aa4
34 changed files with 4170 additions and 30 deletions
  1. 3 7
      src/api/RequestModules.ts
  2. 8 6
      src/api/agent/AgentWorks.ts
  3. 4 0
      src/common/style/icons.ts
  4. 3 1
      src/components/dialog/PopupTitle.vue
  5. 1 0
      src/components/layout/BaseView.ts
  6. 1 0
      src/components/layout/FlexView.vue
  7. 1 0
      src/components/nav/NavBar.vue
  8. 51 4
      src/pages/chat/components/ChatMessage.vue
  9. 12 1
      src/pages/chat/components/ChatMessageContainer.vue
  10. 29 0
      src/pages/chat/components/Session/ChatSessionRenameDialog.vue
  11. 172 0
      src/pages/chat/components/Session/ChatSessionSidebar.vue
  12. 7 0
      src/pages/chat/components/Session/ChatSessionSidebarEmpty.vue
  13. 19 0
      src/pages/chat/components/Session/ChatSessionSidebarGroupHeader.vue
  14. 61 0
      src/pages/chat/components/Session/ChatSessionSidebarItem.vue
  15. 24 0
      src/pages/chat/components/Session/ChatSessionSidebarLoadMore.vue
  16. 52 9
      src/pages/home/post/agent.vue
  17. 22 0
      src/pages/home/post/composables/agentTools.ts
  18. 7 2
      src/pages/home/post/publish.vue
  19. 22 0
      src/uni_modules/zero-markdown-view/changelog.md
  20. 6 0
      src/uni_modules/zero-markdown-view/components/mp-html/highlight/config.js
  21. 119 0
      src/uni_modules/zero-markdown-view/components/mp-html/highlight/index.js
  22. 15 0
      src/uni_modules/zero-markdown-view/components/mp-html/highlight/prism.min.js
  23. 80 0
      src/uni_modules/zero-markdown-view/components/mp-html/latex/index.js
  24. 1 0
      src/uni_modules/zero-markdown-view/components/mp-html/latex/katex.min.js
  25. 34 0
      src/uni_modules/zero-markdown-view/components/mp-html/markdown/index.js
  26. 6 0
      src/uni_modules/zero-markdown-view/components/mp-html/markdown/marked.min.js
  27. 504 0
      src/uni_modules/zero-markdown-view/components/mp-html/mp-html.vue
  28. 670 0
      src/uni_modules/zero-markdown-view/components/mp-html/node/node.vue
  29. 1400 0
      src/uni_modules/zero-markdown-view/components/mp-html/parser.js
  30. 129 0
      src/uni_modules/zero-markdown-view/components/mp-html/style/index.js
  31. 175 0
      src/uni_modules/zero-markdown-view/components/mp-html/style/parser.js
  32. 312 0
      src/uni_modules/zero-markdown-view/components/zero-markdown-view/zero-markdown-view.vue
  33. 87 0
      src/uni_modules/zero-markdown-view/package.json
  34. 133 0
      src/uni_modules/zero-markdown-view/readme.md

+ 3 - 7
src/api/RequestModules.ts

@@ -25,15 +25,11 @@ export class AppServerRequestModule<T extends DataModel> extends BaseAppServerRe
  */
 export class MengyuServerRequestModule<T extends DataModel> extends BaseAppServerRequestModule<T> {
   constructor() {
-    super('https://update-server1.imengyu.top');
-    //super('http://localhost:3012');
+    //super('https://update-server1.imengyu.top');
+    super('http://localhost:3012');
     this.config.requestInterceptor = (url, req) => {
       req.headers['token'] = getApp().globalData?.token ?? '';
-      if (req.method == 'GET') {
-        url = appendGetUrlParams(url, 'deviceUid', this.getDeviceUid());
-      } else {
-        req.data = appendPostParams(req.data, 'deviceUid', this.getDeviceUid());
-      }
+      url = appendGetUrlParams(url, 'identifier', this.getDeviceUid());
       return { newUrl: url, newReq: req };
     };
     this.config.responseDataHandler = async function responseDataHandler<T extends DataModel>(response: RequestResponse, req: RequestOptions, resultModelClass: NewDataModel | undefined, instance: RequestCoreInstance<T>, apiInfo: RequestApiInfoStruct): Promise<RequestApiResult<T>> {

+ 8 - 6
src/api/agent/AgentWorks.ts

@@ -7,6 +7,8 @@ export class AgentWorkApi extends RestfulApi<DataModel> {
     super("content/agent", DataModel);
   }
 
+  private static readonly WORK_MINI_MODEL_NAME = 'hunyuan-2.0-instruct-20251111';
+
   private static readonly QUESTION_RELATED_CACHE_TTL_MS = 5 * 60 * 1000;
   private static readonly QUESTION_RELATED_CACHE_MAX_SIZE = 200;
   private questionRelatedCache = new Map<string, { value: boolean; expireAt: number }>();
@@ -76,7 +78,7 @@ export class AgentWorkApi extends RestfulApi<DataModel> {
   async getQuestionOverview(question: string) {
     try {
       const result = await AgentApi.chat({
-        model: "qwen-flash",
+        model: AgentWorkApi.WORK_MINI_MODEL_NAME,
         messages: [
           { role: "system", content: `你是一个专业的概览生成器,请根据用户的问题生成一个简要概览,
             注意,你只对用户输入进行简要概括,不要回答问题。输出语言与用户输入语言一致。
@@ -104,7 +106,7 @@ export class AgentWorkApi extends RestfulApi<DataModel> {
   async getTitle(userContent: string) {
     try {
       const result = await AgentApi.chat({
-        model: "qwen-flash",
+        model: AgentWorkApi.WORK_MINI_MODEL_NAME,
         messages: [
           { role: "system", content: `你是一个专业的标题生成器,请根据内容概括一个标题,
             输出格式为 JSON,结构为 { "title": "标题" }`
@@ -129,7 +131,7 @@ export class AgentWorkApi extends RestfulApi<DataModel> {
   async autoGeneratePossibleQuestions(question: string, aiAnswer: string) {
     try {
       const result = await AgentApi.chat({
-        model: "qwen-flash",
+        model: AgentWorkApi.WORK_MINI_MODEL_NAME,
         messages: [
           { role: "system", content: `你是一个专业的问题联想生成器,请根据用户的问题和AI回答生成2~4个用户可能感兴趣的问题,
             输出格式为 JSON,结构为 ["可能的问题1", "可能的问题2", "可能的问题3"]
@@ -174,7 +176,7 @@ export class AgentWorkApi extends RestfulApi<DataModel> {
         a: prevAnswers[index]?.slice(0, 500) || '',
       }));
       const result = await AgentApi.chat({
-        model: "qwen-flash",
+        model: AgentWorkApi.WORK_MINI_MODEL_NAME,
         messages: [
           {
             role: "system",
@@ -220,7 +222,7 @@ export class AgentWorkApi extends RestfulApi<DataModel> {
       return prevSummary;
     try {
       const result = await AgentApi.chat({
-        model: "qwen-flash",
+        model: AgentWorkApi.WORK_MINI_MODEL_NAME,
         messages: [
           {
             role: "system",
@@ -257,7 +259,7 @@ export class AgentWorkApi extends RestfulApi<DataModel> {
       return existingLongMemory;
     try {
       const result = await AgentApi.chat({
-        model: "qwen-flash",
+        model: AgentWorkApi.WORK_MINI_MODEL_NAME,
         messages: [
           {
             role: "system",

File diff suppressed because it is too large
+ 4 - 0
src/common/style/icons.ts


+ 3 - 1
src/components/dialog/PopupTitle.vue

@@ -50,7 +50,7 @@ defineOptions({
 
 <template>
   <FlexRow
-    pointerEvents="box-none"
+    pointerEvents="none"
     innerClass="nana-popup-title"
     :innerStyle="{
       position: relative ? 'relative' : 'absolute',
@@ -65,6 +65,7 @@ defineOptions({
     <IconButton 
       v-if="closeable === true && closeIcon"
       :size="closeIconSize || theme.resolveThemeSize('PopupCloseIconSize', 25)"
+      :innerStyle="{ pointerEvents: 'auto' }"
     />
     <Text :text="title" />
     <IconButton 
@@ -72,6 +73,7 @@ defineOptions({
       :icon="(closeIcon as string) || theme.resolveThemeSize('PopupCloseIconName', 'close')!"
       :size="closeIconSize || theme.resolveThemeSize('PopupCloseIconSize', 25)"
       :color="theme.resolveThemeSize('PopupCloseIconColor', 'text.content')"
+      :innerStyle="{ pointerEvents: 'auto' }"
       @click="emit('close')"
     />
   </FlexRow>

+ 1 - 0
src/components/layout/BaseView.ts

@@ -33,6 +33,7 @@ export function useBaseViewStyleBuilder(props: FlexProps) {
       borderStyle: props.borderStyle,
       boxShadow: props.shadow ? themeContext.getVar('shadow.' + props.shadow, undefined) : undefined,
       zIndex: props.zIndex,
+      pointerEvents: props.pointerEvents,
     }
 
     //内边距样式

+ 1 - 0
src/components/layout/FlexView.vue

@@ -145,6 +145,7 @@ export interface FlexProps {
    */
   height?: ThemeSizeType,
   overflow?: 'visible'|'hidden'|'scroll'|'auto'
+  pointerEvents?: 'auto'|'box-none'|'box-only'|'none',
   /**
    * 层级
    */

+ 1 - 0
src/components/nav/NavBar.vue

@@ -175,6 +175,7 @@ function getButton(type: NavBarButtonTypes|string) {
     case 'menu': button = 'elipsis'; break;
     case 'search': button = 'search'; break;
     case 'setting': button = 'setting'; break;
+    default: button = type; break;
   }
   return button;
 }

+ 51 - 4
src/pages/chat/components/ChatMessage.vue

@@ -33,13 +33,32 @@
           </span>
 
           <!-- 思考内容 -->
-          <text v-if="message.reasoningContent" class="reasoning-content">
-            {{ message.reasoningContent }}
-          </text>
+          <FlexRow v-if="message.reasoningContent" >
+            <scroll-view
+              scroll-y 
+              :style="{
+                maxHeight: reasoningContentExpanded ? '' : '200px'
+              }" 
+              :scroll-top="reasoningContentScrollTop">
+              <FlexRow align="center" gap="gap.md" :padding="[10,0]">
+                <Icon icon="ai-thinking" :size="26" />
+                <text class="reasoning-content">
+                  {{ reasoningContentExpanded ? message.reasoningContent : reasoningUnExpandContent }}
+                </text>
+              </FlexRow>
+            </scroll-view>
+            <IconButton 
+              icon="arrow-down-bold"
+              :rotate="reasoningContentExpanded ? 180 : 0"
+              :size="26"
+              @click="reasoningContentExpanded = !reasoningContentExpanded"
+            />
+          </FlexRow>
 
           <!-- 文本内容 -->
           <div v-if="message.type === 'text' && content" class="text-content">
-            {{ content }}
+            <zero-markdown-view v-if="contentHasMarkdown(content)" :markdown="content" :aiMode='true'></zero-markdown-view>
+            <text v-else>{{ content }}</text>
           </div>
           <!-- 图片内容 -->
           <div v-if="message.type === 'image_url' && message.image_url" class="image-content">
@@ -111,6 +130,8 @@ import Button from '@/components/basic/Button.vue';
 import FlexRow from '@/components/layout/FlexRow.vue';
 import FlexCol from '@/components/layout/FlexCol.vue';
 import { actionSheet } from '@/components/dialog/CommonRoot';
+import ZeroMarkdownView from '@/uni_modules/zero-markdown-view/components/zero-markdown-view/zero-markdown-view.vue';
+import IconButton from '@/components/basic/IconButton.vue';
 
 const props = defineProps({
   message: {
@@ -159,6 +180,9 @@ const reasoningUnExpandContent = computed(() => {
       );
   }
 })
+const reasoningContentScrollTop = computed(() => {
+  return props.message.reasoningContent ? props.message.reasoningContent.length * 10 : 0;
+})
 const contentExpanded = ref(true);
 const contentExpandable = computed(() => {
   return props.message.content && 
@@ -173,6 +197,29 @@ const content = computed(() => {
 })
 const extraExpanded = ref(false);
 
+function contentHasMarkdown(content: string): boolean {
+  if (!content) 
+    return false;
+  // 标题
+  if (/^ {0,3}#{1,6} /.test(content) || /(^|\n) {0,3}#{1,6} /.test(content)) return true;
+  // 加粗/斜体(*或_成对包裹)
+  if (/\*\*[^*]+?\*\*/.test(content) || /__[^_]+?__/.test(content)) return true;
+  if (/\*[^*]+?\*/.test(content) || /_[^_]+?_/.test(content)) return true;
+  // 行内代码块或代码块
+  if (/`[^`]+?`/.test(content) || /```[\s\S]*?```/.test(content)) return true;
+  // 链接/图片
+  if (/\[([^\]]+)\]\(([^)]+)\)/.test(content) || /!\[([^\]]*)\]\(([^)]+)\)/.test(content)) return true;
+  // 列表
+  if (/^ {0,3}([-*+]|\d+\.) /m.test(content)) return true;
+  // 引用
+  if (/^ {0,3}> /m.test(content)) return true;
+  // 分割线
+  if (/^ {0,3}(-{3,}|\*{3,}|_{3,})$/m.test(content)) return true;
+  // 表格
+  if (/^\|(.+)\|/.test(content) && /\|/.test(content)) return true;
+  return false;
+}
+
 //是否显示操作
 
 const canShowActions = computed(() => {

+ 12 - 1
src/pages/chat/components/ChatMessageContainer.vue

@@ -25,6 +25,15 @@
         :scroll-top="scrollY"
       >
         <FlexCol gap="gap.sm">
+          <FlexRow 
+            v-if="sessionManager.currentSessionId.value === CHAT_SESSION_LOCAL_ID" 
+            align="center"
+            gap="gap.sm"
+            :padding="[10,0]"
+          >
+            <Icon icon="warning" />
+            您处于临时会话,离开后消息将丢失,请注意保存
+          </FlexRow>
           <ChatMessage
             v-for="message in chatManager.messages.value"
             :key="message.id"
@@ -39,6 +48,7 @@
     </FlexCol>
     <!--聊天输入框-->
     <slot name="footer" />
+    <slot />
   </FlexCol>
 </template>
 
@@ -47,11 +57,12 @@ import { onMounted, ref } from 'vue';
 import ChatMessage from './ChatMessage.vue';
 import type { ChatInterfaceManager, ChatManager } from '../core/Chat';
 import type { ChatHistoryItemsPagerManager } from '../composables/useChatHistoryItemsPager';
-import { type ChatSessionManager } from '../composables/useChatSession';
+import { CHAT_SESSION_LOCAL_ID, type ChatSessionManager } from '../composables/useChatSession';
 import FlexCol from '@/components/layout/FlexCol.vue';
 import FlexRow from '@/components/layout/FlexRow.vue';
 import Button from '@/components/basic/Button.vue';
 import ActivityIndicator from '@/components/basic/ActivityIndicator.vue';
+import Icon from '@/components/basic/Icon.vue';
 
 export interface ChatMessageContainerExpose {
   scrollToBottom: () => void;

+ 29 - 0
src/pages/chat/components/Session/ChatSessionRenameDialog.vue

@@ -0,0 +1,29 @@
+<template>
+  <Dialog
+    v-model:show="sessionManager.renameVisible.value"
+    title="重命名会话"
+    showCancel
+    :onConfirm="() => sessionManager.submitRename()"
+    :onCancel="() => { sessionManager.renameVisible.value = false }"
+  >
+    <template #content>
+      <Field
+        v-model="sessionManager.renameTitle.value" 
+        placeholder="会话标题"
+        allowClear 
+        showWordLimit
+        :maxlength="100"
+      />
+    </template>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import Dialog from '@/components/dialog/Dialog.vue';
+import type { ChatSessionManager } from '../../composables/useChatSession';
+import Field from '@/components/form/Field.vue';
+
+defineProps<{
+  sessionManager: ChatSessionManager;
+}>();
+</script>

+ 172 - 0
src/pages/chat/components/Session/ChatSessionSidebar.vue

@@ -0,0 +1,172 @@
+<template>
+  <FlexCol 
+    flex="1"
+    radius="radius.md"
+    backgroundColor="background.white"
+    width="100%"
+    height="100%"
+  >
+    <LoadingPage v-if="sessionManager.sessionsLoading.value" />
+    <scroll-view 
+      scroll-y
+      :style="{ height: '100%' }"
+      refresher-enabled
+      :refresher-triggered="sessionManager.sessionsLoading.value"
+      @refresherrefresh="sessionManager.loadSessions()"
+    >
+      <FlexCol padding="space.md" gap="gap.md">
+        <slot name="header"></slot>
+        <SearchBar 
+          v-model="sessionManager.searchSession.value"
+          inputBackgroundColor="background.bar"
+          placeholder="搜索会话"
+          @clear="sessionManager.loadSessions()"
+          @search="sessionManager.loadSessions()"
+        />
+        <ChatSessionSidebarItem 
+          name="new" 
+          desc="新对话" 
+          icon="chat-dot" 
+          rightIcon="add"
+          :showMore="false"
+          @click="onMenuClick('new')"
+        />
+        <ChatSessionSidebarItem 
+          name="local" 
+          desc="临时会话" 
+          title="开启临时会话,不会保存聊天记录" 
+          icon="chat" 
+          rightIcon="arrow-right"
+          :showMore="false"
+          @click="onMenuClick('local')"
+        />
+        <ChatSessionSidebarGroupHeader title="历史会话" />
+        <template v-for="group in sessionList" :key="group.key">
+          <ChatSessionSidebarGroupHeader :title="group.title" :desc="group.date" second />
+          <ChatSessionSidebarItem
+            v-for="s in group.items"
+            :key="String(s.id)"
+            :name="String(s.id)"
+            :desc="s.desc || '未命名'"
+            icon="chat-double"
+            showMore
+            @click="onMenuClick(String(s.id))"
+            @moreClick="onMoreClick(s as AgentChatHistory)"
+          />
+        </template>
+        <ChatSessionSidebarEmpty v-if="sessionList.length === 0" />
+        <ChatSessionSidebarLoadMore v-if="sessionList.length > 0" 
+          :loading="sessionManager.sessionsLoading.value" 
+          :hasMore="sessionManager.sessionsHasMore.value" 
+          @loadMore="sessionManager.loadMoreSessions()"
+        />
+      </FlexCol>
+    </scroll-view>
+
+    <!--重命名会话-->
+    <ChatSessionRenameDialog :session-manager="sessionManager" />
+  </FlexCol>
+</template>
+
+<script setup lang="ts">
+import { computed, provide } from 'vue';
+import { CHAT_SESSION_LOCAL_ID, CHAT_SESSION_NEW_ID, type ChatSessionManager } from '../../composables/useChatSession';
+import { DateUtils, TimeUtils } from '@imengyu/imengyu-utils';
+import { actionSheet } from '@/components/dialog/CommonRoot';
+import ChatSessionSidebarItem from './ChatSessionSidebarItem.vue';
+import ChatSessionSidebarGroupHeader from './ChatSessionSidebarGroupHeader.vue';
+import ChatSessionSidebarEmpty from './ChatSessionSidebarEmpty.vue';
+import ChatSessionSidebarLoadMore from './ChatSessionSidebarLoadMore.vue';
+import ChatSessionRenameDialog from './ChatSessionRenameDialog.vue';
+import FlexCol from '@/components/layout/FlexCol.vue';
+import LoadingPage from '@/components/display/loading/LoadingPage.vue';
+import SearchBar from '@/components/form/SearchBar.vue';
+import type { AgentChatHistory } from '@/api/agent/Agent';
+
+const props = withDefaults(defineProps<{
+  sessionManager: ChatSessionManager;
+}>(), {
+});
+
+const emit = defineEmits(['close']);
+
+const selectedKeys = computed(() => {
+  switch (props.sessionManager.currentSessionId.value) {
+    case CHAT_SESSION_NEW_ID:
+      return ['new'];
+    case CHAT_SESSION_LOCAL_ID:
+      return ['local'];
+    default:
+      return [String(props.sessionManager.currentSessionId.value)];
+  }
+});
+
+provide('selectedKeys', selectedKeys);
+
+type SessionDateGroup = {
+  key: string;
+  title: string;
+  date: string,
+  items: SessionListItem[];
+}
+
+type SessionListItem = (typeof props.sessionManager.sessions.value)[number];
+
+const sessionList = computed<SessionDateGroup[]>(() => {
+  const groups = new Map<string, SessionDateGroup>();
+  for (const session of props.sessionManager.sessions.value) {
+    const date = session.createdAt;
+    const key = DateUtils.formatDate(date, 'YYYY-MM-DD');
+    const title = TimeUtils.getTimeAgo(date)!;
+    const existing = groups.get(key);
+    if (existing) {
+      existing.items.push(session);
+      continue;
+    }
+    groups.set(key, {
+      key,
+      title,
+      date: DateUtils.formatDate(date, 'YYYY-MM-DD HH:mm'),
+      items: [session],
+    });
+  }
+  return Array.from(groups.values());
+});
+
+async function onMoreClick(row: AgentChatHistory) {
+  const res = await actionSheet({
+    actions: [
+      {
+        name: '重命名',
+      },
+      {
+        name: '删除会话',
+        color: 'red',
+      },
+    ],
+  });
+  if (res === undefined) 
+    return;
+  switch (res) {
+    case 0:
+      props.sessionManager.onRenameSession(row);
+      break;
+    case 1:
+      props.sessionManager.onDeleteSession(row);
+      break;
+  }
+}
+function onMenuClick(key: string) {
+  switch (key) {
+    case 'new':
+      props.sessionManager.onSelectNew();
+      break;
+    case 'local':
+      props.sessionManager.onSelectLocal();
+      break;
+    default:
+      props.sessionManager.onSelectSession(Number(key));
+  }
+  emit('close');
+}
+</script>

+ 7 - 0
src/pages/chat/components/Session/ChatSessionSidebarEmpty.vue

@@ -0,0 +1,7 @@
+<script setup lang="ts">
+import Empty from '@/components/feedback/Empty.vue';
+</script>
+
+<template>
+  <Empty description="暂无历史对话数据" />
+</template>

+ 19 - 0
src/pages/chat/components/Session/ChatSessionSidebarGroupHeader.vue

@@ -0,0 +1,19 @@
+<template>
+  <FlexRow :padding="[10,0,10,10]">
+    <Text 
+      :fontConfig="second ? 'subSecondText' : 'subTitle'"
+      :text="title"
+    />
+  </FlexRow>
+</template>
+
+<script setup lang="ts">
+import Text from '@/components/basic/Text.vue';
+import FlexRow from '@/components/layout/FlexRow.vue';
+
+const props = defineProps<{
+  title: string;
+  desc?: string;
+  second?: boolean;
+}>();
+</script>

+ 61 - 0
src/pages/chat/components/Session/ChatSessionSidebarItem.vue

@@ -0,0 +1,61 @@
+<template>
+  <Touchable 
+    align="center" 
+    justify="space-between" 
+    radius="radius.md"
+    :padding="[16,20]" 
+    :backgroundColor="isSelected ? 'background.bar' : ''" 
+    touchable
+    @click="emit('click')"
+  >
+    <FlexRow align="center" gap="gap.md">
+      <Icon :icon="icon" />
+      <FlexCol>
+        <Text 
+          :text="desc || '未命名'"
+          :ellipsis="true"
+          :style="{ flex: 1, maxWidth: '500rpx' }"
+          :wrap="false"
+        />
+        <Text 
+          v-if="title"
+          :text="title"
+          fontConfig="secondText"
+        />
+      </FlexCol>
+    </FlexRow>
+    <FlexRow>
+      <IconButton 
+        v-if="showMore" 
+        icon="elipsis"
+        @click="emit('moreClick', $event)"
+      />
+      <Icon v-if="rightIcon" :icon="rightIcon" />
+    </FlexRow>
+  </Touchable>
+</template>
+
+<script setup lang="ts">
+import Icon from '@/components/basic/Icon.vue';
+import IconButton from '@/components/basic/IconButton.vue';
+import Text from '@/components/basic/Text.vue';
+import Touchable from '@/components/feedback/Touchable.vue';
+import FlexCol from '@/components/layout/FlexCol.vue';
+import FlexRow from '@/components/layout/FlexRow.vue';
+import { computed, inject, type Ref } from 'vue';
+
+const props = defineProps<{
+  name: string;
+  desc: string;
+  title?: string;
+  icon?: string;
+  rightIcon?: string;
+  showMore?: boolean;
+}>();
+
+const emit = defineEmits(['click', 'moreClick']);
+
+const currentSelectedKey = inject<Ref<string>>('selectedKeys');
+const isSelected = computed(() => currentSelectedKey?.value == props.name);
+
+</script>

+ 24 - 0
src/pages/chat/components/Session/ChatSessionSidebarLoadMore.vue

@@ -0,0 +1,24 @@
+<template>
+  <FlexRow justify="center" align="center" :padding="[10,20]">
+    <FlexRow v-if="loading" align="center" gap="gap.sm">
+      <ActivityIndicator />
+      加载中...
+    </FlexRow>
+    <Text v-else-if="hasMore" fontConfig="subText" touchable @click="emit('loadMore')">加载更多</Text>
+    <Text v-else fontConfig="subText">没有更多了</Text>
+  </FlexRow>
+</template>
+
+<script setup lang="ts">
+import ActivityIndicator from '@/components/basic/ActivityIndicator.vue';
+import FlexRow from '@/components/layout/FlexRow.vue';
+import Text from '@/components/basic/Text.vue';
+
+defineProps<{
+  loading: boolean;
+  hasMore: boolean;
+}>()
+const emit = defineEmits<{
+  (e: 'loadMore'): void;
+}>()
+</script>

+ 52 - 9
src/pages/home/post/agent.vue

@@ -1,5 +1,14 @@
 <template>
+  <FlexCol v-if="showSessionSidebar" position="relative" width="100%" height="100%">
+    <NavBar 
+      title="历史会话" 
+      leftButton="close-bold" 
+      @leftButtonPressed="showSessionSidebar=false"
+    />
+    <ChatSessionSidebar :sessionManager="sessionManager" @close="showSessionSidebar = false" />
+  </FlexCol>
   <ChatMessageContainer
+    v-else
     :chatManager="chatManager"
     :sessionManager="sessionManager"
     :historyItemsPagerManager="historyItemsPagerManager"
@@ -7,7 +16,13 @@
     @intoSelectMode="intoSelectMode"
   >
     <template #header>
-      <NavBar title="AI伴写" leftButton="menu" @leftButtonPressed="" />
+      <NavBar 
+        title="AI伴写" 
+        leftButton="menu"
+        rightButton="close-bold"
+        @leftButtonPressed="showSessionSidebar = true" 
+        @rightButtonPressed="emit('close')"
+      />
     </template>
     <template #footer>
       <ChatFooter 
@@ -22,13 +37,15 @@ import { onMounted, ref, type Ref } from 'vue';
 import { useChatSession } from '@/pages/chat/composables/useChatSession';
 import { useChat, type ChatInterfaceManager } from '@/pages/chat/core/Chat';
 import { useChatHistoryItemsPager } from '@/pages/chat/composables/useChatHistoryItemsPager';
+import { useChatSelection } from '@/pages/chat/composables/useChatSelection';
+import { useAgentTools } from './composables/agentTools';
 import { ChatMessage as ChatMessageModel } from '@/pages/chat/model/Message';
 import { ChatGroups } from '@/pages/chat/core/Groups';
 import ChatFooter from '../../chat/components/ChatFooter.vue';
 import ChatMessageContainer from '../../chat/components/ChatMessageContainer.vue';
-import FlexCol from '@/components/layout/FlexCol.vue';
 import NavBar from '@/components/nav/NavBar.vue';
-import { useChatSelection } from '@/pages/chat/composables/useChatSelection';
+import ChatSessionSidebar from '@/pages/chat/components/Session/ChatSessionSidebar.vue';
+import FlexCol from '@/components/layout/FlexCol.vue';
 
 const messages = ref<ChatMessageModel[]>([]) as Ref<ChatMessageModel[]>;
 const interfaceManager: ChatInterfaceManager = {
@@ -38,6 +55,23 @@ const interfaceManager: ChatInterfaceManager = {
   },
 };
 
+const props = defineProps<{
+  title: string;
+  content: string;
+  images: {
+    url: string;
+    localUrl: string;
+  }[];
+}>();
+const emit = defineEmits([ 
+  'update:title',
+  'update:content',
+  'update:images',
+  'close'
+]);
+
+const showSessionSidebar = ref(true);
+
 const sessionManager = useChatSession({
   messages,
   enableSession: true,
@@ -50,19 +84,28 @@ const historyItemsPagerManager = useChatHistoryItemsPager({
   sessionManager,
 });
 
+const { registerTools } = useAgentTools({
+  title: props.title,
+  content: props.content,
+  images: props.images,
+  onUpdateTitle: (title) => emit('update:title', title),
+  onUpdateContent: (content) => emit('update:content', content),
+  onUpdateImages: (images) => emit('update:images', images),
+});
+
 const chatManager = useChat({
   interfaceManager,
   sessionManager,
   config: {
     onGetSendOptions: () => ({
-      enableThinking: false,
-      enableSearch: true,
+      enableThinking: true,
+      enableSearch: false,
       modelInfo: {
-        name: 'hunyuan-2.0-instruct-20251111',
-        value: 'hunyuan-2.0-instruct-20251111',
+        name: 'hunyuan-2.0-thinking-20251109',
+        value: 'hunyuan-2.0-thinking-20251109',
         max_tokens: 10000,
       },
-      model: 'hunyuan-2.0-instruct-20251111',
+      model: 'hunyuan-2.0-thinking-20251109',
       chatOptions: {
         temperature: 0.5,
         top_p: 1,
@@ -97,7 +140,7 @@ const chatManager = useChat({
       }
     },
     onInitTools: (toolsManager) => {
-      
+      registerTools(toolsManager);
     },
   }
 });

+ 22 - 0
src/pages/home/post/composables/agentTools.ts

@@ -0,0 +1,22 @@
+import type { ChatToolsManager } from "@/pages/chat/core/Tools";
+
+export function useAgentTools(options: {
+  title: string;
+  content: string;
+  images: {
+    url: string;
+    localUrl: string;
+  }[];
+  onUpdateTitle: (title: string) => void;
+  onUpdateContent: (content: string) => void;
+  onUpdateImages: (images: string[]) => void;
+}) {
+
+  function registerTools(tools: ChatToolsManager) { 
+
+  }
+
+  return {
+    registerTools,
+  };
+}

+ 7 - 2
src/pages/home/post/publish.vue

@@ -69,8 +69,13 @@
         <XBarSpace />
       </BackgroundBox>
     </FlexCol>
-    <Popup v-model:show="showAgentPopup" position="bottom" closeable size="60vh" round>
-      <Agent />
+    <Popup v-model:show="showAgentPopup" position="bottom" closeable :closeIcon="false" size="60vh" round>
+      <Agent 
+        v-model:title="title"
+        v-model:content="content"
+        v-model:images="images"
+        @close="showAgentPopup = false" 
+      />
     </Popup>
   </CommonRoot>
 </template>

+ 22 - 0
src/uni_modules/zero-markdown-view/changelog.md

@@ -0,0 +1,22 @@
+## 3.0.0(2025-07-17)
+### 新增 aiMode 模式
+### aiMode模式 优化流式输出以及排版
+## 2.1.0(2025-05-23)
+- 新增latex公式支持
+- 代码块高亮支持增加除js外的语法
+- 注意:包体积增加了,建议在分包内使用
+## 2.0.5(2024-04-24)
+- 流式输出代码块解决方案
+## 2.0.4(2023-12-06)
+- 长按复制代码改为点击代码块复制
+## 2.0.3(2023-10-30)
+doc: 文档说明
+## 2.0.2(2023-10-30)
+- 新增长按复制代码-仅小程序可用
+- 新增代码块语言显示
+## 2.0.1(2023-10-27)
+##支持vue2,vue3
+## 2.0.0(2022-11-01)
+使用mp-html自带的插件,重新生成uniapp包,大幅减少插件体积
+## 1.0.0(2022-09-13)
+首次发布

+ 6 - 0
src/uni_modules/zero-markdown-view/components/mp-html/highlight/config.js

@@ -0,0 +1,6 @@
+export default {
+  // copyByLongPress: false, // 是否需要长按代码块时显示复制代码内容菜单
+  copyByClickCode: true, // 点击代码块复制
+  showLanguageName: true, // 是否在代码块右上角显示语言的名称
+  // showLineNumber: false // 是否显示行号,需要重新打包mp-html
+}

+ 119 - 0
src/uni_modules/zero-markdown-view/components/mp-html/highlight/index.js

@@ -0,0 +1,119 @@
+/**
+ * @fileoverview highlight 插件
+ * Include prismjs (https://prismjs.com)
+ */
+import prism from "./prism.min";
+import config from "./config";
+import Parser from "../parser";
+
+function Highlight(vm) {
+  this.vm = vm;
+}
+
+Highlight.prototype.onParse = function (node, vm) {
+  if (node.name === "pre") {
+    if (vm.options.editable) {
+      node.attrs.class = (node.attrs.class || "") + " hl-pre";
+      return;
+    }
+    let i;
+    for (i = node.children.length; i--; ) {
+      if (node.children[i].name === "code") break;
+    }
+    if (i === -1) return;
+    const code = node.children[i];
+    let className = code.attrs.class + " " + node.attrs.class;
+    i = className.indexOf("language-");
+    if (i === -1) {
+      i = className.indexOf("lang-");
+      if (i === -1) {
+        className = "language-text";
+        i = 9;
+      } else {
+        i += 5;
+      }
+    } else {
+      i += 9;
+    }
+    let j;
+    for (j = i; j < className.length; j++) {
+      if (className[j] === " ") break;
+    }
+    const lang = className.substring(i, j);
+    if (code.children.length) {
+      const text = this.vm.getText(code.children).replace(/&amp;/g, "&");
+      if (!text) return;
+      if (node.c) {
+        node.c = undefined;
+      }
+      if (prism.languages[lang]) {
+        code.children = new Parser(this.vm).parse(
+          // 加一层 pre 保留空白符
+          "<pre>" +
+            prism
+              .highlight(text, prism.languages[lang], lang)
+              .replace(/token /g, "hl-") +
+            "</pre>"
+        )[0].children;
+      }
+      node.attrs.class = "hl-pre";
+      code.attrs.class = "hl-code";
+      code.attrs.style = "display:block;overflow: auto;";
+
+      if (config.showLanguageName) {
+        node.children.push({
+          name: "div",
+          attrs: {
+            class: "hl-language",
+            style:
+              "user-select:none;position:absolute;top:0;right:2px;font-size:10px;",
+          },
+          children: [
+            {
+              type: "text",
+              text: lang,
+            },
+          ],
+        });
+      }
+      if (config.copyByClickCode) {
+        node.attrs.style += (node.attrs.style || "") + ";user-select:none";
+        node.attrs["data-content"] = text;
+        node.children.push({
+          name: "div",
+          attrs: {
+            class: "hl-copy",
+            style:
+              "user-select:none;position:absolute;top:0;right:3px;font-size:10px;",
+          },
+          // children: [{
+          //   type: 'text',
+          //   text: '复制'
+          // }]
+        });
+        vm.expose();
+      }
+      if (config.showLineNumber) {
+        const line = text.split("\n").length;
+        const children = [];
+        for (let k = line; k--; ) {
+          children.push({
+            name: "span",
+            attrs: {
+              class: "span",
+            },
+          });
+        }
+        node.children.push({
+          name: "span",
+          attrs: {
+            class: "line-numbers-rows",
+          },
+          children,
+        });
+      }
+    }
+  }
+};
+
+export default Highlight;

File diff suppressed because it is too large
+ 15 - 0
src/uni_modules/zero-markdown-view/components/mp-html/highlight/prism.min.js


+ 80 - 0
src/uni_modules/zero-markdown-view/components/mp-html/latex/index.js

@@ -0,0 +1,80 @@
+/**
+ * @fileoverview latex 插件
+ * katex.min.js来源 https://github.com/rojer95/katex-mini
+ */
+import parse from './katex.min'
+
+function Latex () {
+
+}
+
+Latex.prototype.onParse = function (node, vm) {
+  // $...$包裹的内容为latex公式
+  if (!vm.options.editable && node.type === 'text' && node.text.includes('$')) {
+    const part = node.text.split(/(\${1,2})/)
+    const children = []
+    let status = 0
+    for (let i = 0; i < part.length; i++) {
+      if (i % 2 === 0) {
+        // 文本内容
+        if (part[i]) {
+          if (status === 0) {
+            children.push({
+              type: 'text',
+              text: part[i]
+            })
+          } else {
+            if (status === 1) {
+              // 行内公式
+              const nodes = parse.default(part[i])
+              children.push({
+                name: 'span',
+                attrs: {},
+                l: 'T',
+                f: 'display:inline-block',
+                children: nodes
+              })
+            } else {
+              // 块公式
+              const nodes = parse.default(part[i], {
+                displayMode: true
+              })
+              children.push({
+                name: 'div',
+                attrs: {
+                  style: 'text-align:center'
+                },
+                children: nodes
+              })
+            }
+          }
+        }
+      } else {
+        // 分隔符
+        if (part[i] === '$' && part[i + 2] === '$') {
+          // 行内公式
+          status = 1
+          part[i + 2] = ''
+        } else if (part[i] === '$$' && part[i + 2] === '$$') {
+          // 块公式
+          status = 2
+          part[i + 2] = ''
+        } else {
+          if (part[i] && part[i] !== '$$') {
+            // 普通$符号
+            part[i + 1] = part[i] + part[i + 1]
+          }
+          // 重置状态
+          status = 0
+        }
+      }
+    }
+    delete node.type
+    delete node.text
+    node.name = 'span'
+    node.attrs = {}
+    node.children = children
+  }
+}
+
+export default Latex

File diff suppressed because it is too large
+ 1 - 0
src/uni_modules/zero-markdown-view/components/mp-html/latex/katex.min.js


+ 34 - 0
src/uni_modules/zero-markdown-view/components/mp-html/markdown/index.js

@@ -0,0 +1,34 @@
+/**
+ * @fileoverview markdown 插件
+ * Include marked (https://github.com/markedjs/marked)
+ * Include github-markdown-css (https://github.com/sindresorhus/github-markdown-css)
+ */
+import marked from './marked.min'
+let index = 0
+
+function Markdown (vm) {
+  this.vm = vm
+  vm._ids = {}
+}
+
+Markdown.prototype.onUpdate = function (content) {
+  if (this.vm.markdown) {
+    return marked(content)
+  }
+}
+
+Markdown.prototype.onParse = function (node, vm) {
+  if (vm.options.markdown) {
+    // 中文 id 需要转换,否则无法跳转
+    if (vm.options.useAnchor && node.attrs && /[\u4e00-\u9fa5]/.test(node.attrs.id)) {
+      const id = 't' + index++
+      this.vm._ids[node.attrs.id] = id
+      node.attrs.id = id
+    }
+    if (node.name === 'p' || node.name === 'table' || node.name === 'tr' || node.name === 'th' || node.name === 'td' || node.name === 'blockquote' || node.name === 'pre' || node.name === 'code') {
+      node.attrs.class = `md-${node.name} ${node.attrs.class || ''}`
+    }
+  }
+}
+
+export default Markdown

File diff suppressed because it is too large
+ 6 - 0
src/uni_modules/zero-markdown-view/components/mp-html/markdown/marked.min.js


+ 504 - 0
src/uni_modules/zero-markdown-view/components/mp-html/mp-html.vue

@@ -0,0 +1,504 @@
+<template>
+  <view id="_root" :class="(selectable?'_select ':'')+'_root'" :style="containerStyle">
+    <slot v-if="!nodes[0]" />
+    <!-- #ifndef APP-PLUS-NVUE -->
+    <node v-else :childs="nodes" :opts="[lazyLoad,loadingImg,errorImg,showImgMenu,selectable]" name="span" />
+    <!-- #endif -->
+    <!-- #ifdef APP-PLUS-NVUE -->
+    <web-view ref="web" src="/static/app-plus/mp-html/local.html" :style="'margin-top:-2px;height:' + height + 'px'" @onPostMessage="_onMessage" />
+    <!-- #endif -->
+  </view>
+</template>
+
+<script>
+/**
+ * mp-html v2.5.1
+ * @description 富文本组件
+ * @tutorial https://github.com/jin-yufeng/mp-html
+ * @property {String} container-style 容器的样式
+ * @property {String} content 用于渲染的 html 字符串
+ * @property {Boolean} copy-link 是否允许外部链接被点击时自动复制
+ * @property {String} domain 主域名,用于拼接链接
+ * @property {String} error-img 图片出错时的占位图链接
+ * @property {Boolean} lazy-load 是否开启图片懒加载
+ * @property {string} loading-img 图片加载过程中的占位图链接
+ * @property {Boolean} pause-video 是否在播放一个视频时自动暂停其他视频
+ * @property {Boolean} preview-img 是否允许图片被点击时自动预览
+ * @property {Boolean} scroll-table 是否给每个表格添加一个滚动层使其能单独横向滚动
+ * @property {Boolean | String} selectable 是否开启长按复制
+ * @property {Boolean} set-title 是否将 title 标签的内容设置到页面标题
+ * @property {Boolean} show-img-menu 是否允许图片被长按时显示菜单
+ * @property {Object} tag-style 标签的默认样式
+ * @property {Boolean | Number} use-anchor 是否使用锚点链接
+ * @event {Function} load dom 结构加载完毕时触发
+ * @event {Function} ready 所有图片加载完毕时触发
+ * @event {Function} imgtap 图片被点击时触发
+ * @event {Function} linktap 链接被点击时触发
+ * @event {Function} play 音视频播放时触发
+ * @event {Function} error 媒体加载出错时触发
+ */
+// #ifndef APP-PLUS-NVUE
+import node from './node/node'
+// #endif
+import Parser from './parser'
+import markdown from './markdown/index.js'
+import highlight from './highlight/index.js'
+import latex from './latex/index.js'
+import style from './style/index.js'
+const plugins=[markdown,highlight,latex,style,]
+// #ifdef APP-PLUS-NVUE
+const dom = weex.requireModule('dom')
+// #endif
+export default {
+  name: 'mp-html',
+  data () {
+    return {
+      nodes: [],
+      // #ifdef APP-PLUS-NVUE
+      height: 3
+      // #endif
+    }
+  },
+  props: {
+    markdown: Boolean,
+    containerStyle: {
+      type: String,
+      default: ''
+    },
+    content: {
+      type: String,
+      default: ''
+    },
+    copyLink: {
+      type: [Boolean, String],
+      default: true
+    },
+    domain: String,
+    errorImg: {
+      type: String,
+      default: ''
+    },
+    lazyLoad: {
+      type: [Boolean, String],
+      default: false
+    },
+    loadingImg: {
+      type: String,
+      default: ''
+    },
+    pauseVideo: {
+      type: [Boolean, String],
+      default: true
+    },
+    previewImg: {
+      type: [Boolean, String],
+      default: true
+    },
+    scrollTable: [Boolean, String],
+    selectable: [Boolean, String],
+    setTitle: {
+      type: [Boolean, String],
+      default: true
+    },
+    showImgMenu: {
+      type: [Boolean, String],
+      default: true
+    },
+    tagStyle: Object,
+    useAnchor: [Boolean, Number]
+  },
+  // #ifdef VUE3
+  emits: ['load', 'ready', 'imgtap', 'linktap', 'play', 'error'],
+  // #endif
+  // #ifndef APP-PLUS-NVUE
+  components: {
+    node
+  },
+  // #endif
+  watch: {
+    content (content) {
+      this.setContent(content)
+    }
+  },
+  created () {
+    this.plugins = []
+    for (let i = plugins.length; i--;) {
+      this.plugins.push(new plugins[i](this))
+    }
+  },
+  mounted () {
+    if (this.content && !this.nodes.length) {
+      this.setContent(this.content)
+    }
+  },
+  beforeDestroy () {
+    this._hook('onDetached')
+  },
+  methods: {
+    /**
+     * @description 将锚点跳转的范围限定在一个 scroll-view 内
+     * @param {Object} page scroll-view 所在页面的示例
+     * @param {String} selector scroll-view 的选择器
+     * @param {String} scrollTop scroll-view scroll-top 属性绑定的变量名
+     */
+    in (page, selector, scrollTop) {
+      // #ifndef APP-PLUS-NVUE
+      if (page && selector && scrollTop) {
+        this._in = {
+          page,
+          selector,
+          scrollTop
+        }
+      }
+      // #endif
+    },
+
+    /**
+     * @description 锚点跳转
+     * @param {String} id 要跳转的锚点 id
+     * @param {Number} offset 跳转位置的偏移量
+     * @returns {Promise}
+     */
+    navigateTo (id, offset) {
+      id = this._ids[decodeURI(id)] || id
+      return new Promise((resolve, reject) => {
+        if (!this.useAnchor) {
+          reject(Error('Anchor is disabled'))
+          return
+        }
+        offset = offset || parseInt(this.useAnchor) || 0
+        // #ifdef APP-PLUS-NVUE
+        if (!id) {
+          dom.scrollToElement(this.$refs.web, {
+            offset
+          })
+          resolve()
+        } else {
+          this._navigateTo = {
+            resolve,
+            reject,
+            offset
+          }
+          this.$refs.web.evalJs('uni.postMessage({data:{action:"getOffset",offset:(document.getElementById(' + id + ')||{}).offsetTop}})')
+        }
+        // #endif
+        // #ifndef APP-PLUS-NVUE
+        let deep = ' '
+        // #ifdef MP-WEIXIN || MP-QQ || MP-TOUTIAO
+        deep = '>>>'
+        // #endif
+        const selector = uni.createSelectorQuery()
+          // #ifndef MP-ALIPAY
+          .in(this._in ? this._in.page : this)
+          // #endif
+          .select((this._in ? this._in.selector : '._root') + (id ? `${deep}#${id}` : '')).boundingClientRect()
+        if (this._in) {
+          selector.select(this._in.selector).scrollOffset()
+            .select(this._in.selector).boundingClientRect()
+        } else {
+          // 获取 scroll-view 的位置和滚动距离
+          selector.selectViewport().scrollOffset() // 获取窗口的滚动距离
+        }
+        selector.exec(res => {
+          if (!res[0]) {
+            reject(Error('Label not found'))
+            return
+          }
+          const scrollTop = res[1].scrollTop + res[0].top - (res[2] ? res[2].top : 0) + offset
+          if (this._in) {
+            // scroll-view 跳转
+            this._in.page[this._in.scrollTop] = scrollTop
+          } else {
+            // 页面跳转
+            uni.pageScrollTo({
+              scrollTop,
+              duration: 300
+            })
+          }
+          resolve()
+        })
+        // #endif
+      })
+    },
+
+    /**
+     * @description 获取文本内容
+     * @return {String}
+     */
+    getText (nodes) {
+      let text = '';
+      (function traversal (nodes) {
+        for (let i = 0; i < nodes.length; i++) {
+          const node = nodes[i]
+          if (node.type === 'text') {
+            text += node.text.replace(/&amp;/g, '&')
+          } else if (node.name === 'br') {
+            text += '\n'
+          } else {
+            // 块级标签前后加换行
+            const isBlock = node.name === 'p' || node.name === 'div' || node.name === 'tr' || node.name === 'li' || (node.name[0] === 'h' && node.name[1] > '0' && node.name[1] < '7')
+            if (isBlock && text && text[text.length - 1] !== '\n') {
+              text += '\n'
+            }
+            // 递归获取子节点的文本
+            if (node.children) {
+              traversal(node.children)
+            }
+            if (isBlock && text[text.length - 1] !== '\n') {
+              text += '\n'
+            } else if (node.name === 'td' || node.name === 'th') {
+              text += '\t'
+            }
+          }
+        }
+      })(nodes || this.nodes)
+      return text
+    },
+
+    /**
+     * @description 获取内容大小和位置
+     * @return {Promise}
+     */
+    getRect () {
+      return new Promise((resolve, reject) => {
+        uni.createSelectorQuery()
+          // #ifndef MP-ALIPAY
+          .in(this)
+          // #endif
+          .select('#_root').boundingClientRect().exec(res => res[0] ? resolve(res[0]) : reject(Error('Root label not found')))
+      })
+    },
+
+    /**
+     * @description 暂停播放媒体
+     */
+    pauseMedia () {
+      for (let i = (this._videos || []).length; i--;) {
+        this._videos[i].pause()
+      }
+      // #ifdef APP-PLUS
+      const command = 'for(var e=document.getElementsByTagName("video"),i=e.length;i--;)e[i].pause()'
+      // #ifndef APP-PLUS-NVUE
+      let page = this.$parent
+      while (!page.$scope) page = page.$parent
+      page.$scope.$getAppWebview().evalJS(command)
+      // #endif
+      // #ifdef APP-PLUS-NVUE
+      this.$refs.web.evalJs(command)
+      // #endif
+      // #endif
+    },
+
+    /**
+     * @description 设置媒体播放速率
+     * @param {Number} rate 播放速率
+     */
+    setPlaybackRate (rate) {
+      this.playbackRate = rate
+      for (let i = (this._videos || []).length; i--;) {
+        this._videos[i].playbackRate(rate)
+      }
+      // #ifdef APP-PLUS
+      const command = 'for(var e=document.getElementsByTagName("video"),i=e.length;i--;)e[i].playbackRate=' + rate
+      // #ifndef APP-PLUS-NVUE
+      let page = this.$parent
+      while (!page.$scope) page = page.$parent
+      page.$scope.$getAppWebview().evalJS(command)
+      // #endif
+      // #ifdef APP-PLUS-NVUE
+      this.$refs.web.evalJs(command)
+      // #endif
+      // #endif
+    },
+
+    /**
+     * @description 设置内容
+     * @param {String} content html 内容
+     * @param {Boolean} append 是否在尾部追加
+     */
+    setContent (content, append) {
+      if (!append || !this.imgList) {
+        this.imgList = []
+      }
+      const nodes = new Parser(this).parse(content)
+      // #ifdef APP-PLUS-NVUE
+      if (this._ready) {
+        this._set(nodes, append)
+      }
+      // #endif
+      this.$set(this, 'nodes', append ? (this.nodes || []).concat(nodes) : nodes)
+
+      // #ifndef APP-PLUS-NVUE
+      this._videos = []
+      this.$nextTick(() => {
+        this._hook('onLoad')
+        this.$emit('load')
+      })
+
+      if (this.lazyLoad || this.imgList._unloadimgs < this.imgList.length / 2) {
+        // 设置懒加载,每 350ms 获取高度,不变则认为加载完毕
+        let height = 0
+        const callback = rect => {
+          if (!rect || !rect.height) rect = {}
+          // 350ms 总高度无变化就触发 ready 事件
+          if (rect.height === height) {
+            this.$emit('ready', rect)
+          } else {
+            height = rect.height
+            setTimeout(() => {
+              this.getRect().then(callback).catch(callback)
+            }, 350)
+          }
+        }
+        this.getRect().then(callback).catch(callback)
+      } else {
+        // 未设置懒加载,等待所有图片加载完毕
+        if (!this.imgList._unloadimgs) {
+          this.getRect().then(rect => {
+            this.$emit('ready', rect)
+          }).catch(() => {
+            this.$emit('ready', {})
+          })
+        }
+      }
+      // #endif
+    },
+
+    /**
+     * @description 调用插件钩子函数
+     */
+    _hook (name) {
+      for (let i = plugins.length; i--;) {
+        if (this.plugins[i][name]) {
+          this.plugins[i][name]()
+        }
+      }
+    },
+
+    // #ifdef APP-PLUS-NVUE
+    /**
+     * @description 设置内容
+     */
+    _set (nodes, append) {
+      this.$refs.web.evalJs('setContent(' + JSON.stringify(nodes).replace(/%22/g, '') + ',' + JSON.stringify([this.containerStyle.replace(/(?:margin|padding)[^;]+/g, ''), this.errorImg, this.loadingImg, this.pauseVideo, this.scrollTable, this.selectable]) + ',' + append + ')')
+    },
+
+    /**
+     * @description 接收到 web-view 消息
+     */
+    _onMessage (e) {
+      const message = e.detail.data[0]
+      switch (message.action) {
+        // web-view 初始化完毕
+        case 'onJSBridgeReady':
+          this._ready = true
+          if (this.nodes) {
+            this._set(this.nodes)
+          }
+          break
+        // 内容 dom 加载完毕
+        case 'onLoad':
+          this.height = message.height
+          this._hook('onLoad')
+          this.$emit('load')
+          break
+        // 所有图片加载完毕
+        case 'onReady':
+          this.getRect().then(res => {
+            this.$emit('ready', res)
+          }).catch(() => {
+            this.$emit('ready', {})
+          })
+          break
+        // 总高度发生变化
+        case 'onHeightChange':
+          this.height = message.height
+          break
+        // 图片点击
+        case 'onImgTap':
+          this.$emit('imgtap', message.attrs)
+          if (this.previewImg) {
+            uni.previewImage({
+              current: parseInt(message.attrs.i),
+              urls: this.imgList
+            })
+          }
+          break
+        // 链接点击
+        case 'onLinkTap': {
+          const href = message.attrs.href
+          this.$emit('linktap', message.attrs)
+          if (href) {
+            // 锚点跳转
+            if (href[0] === '#') {
+              if (this.useAnchor) {
+                dom.scrollToElement(this.$refs.web, {
+                  offset: message.offset
+                })
+              }
+            } else if (href.includes('://')) {
+              // 打开外链
+              if (this.copyLink) {
+                plus.runtime.openWeb(href)
+              }
+            } else {
+              uni.navigateTo({
+                url: href,
+                fail () {
+                  uni.switchTab({
+                    url: href
+                  })
+                }
+              })
+            }
+          }
+          break
+        }
+        case 'onPlay':
+          this.$emit('play')
+          break
+        // 获取到锚点的偏移量
+        case 'getOffset':
+          if (typeof message.offset === 'number') {
+            dom.scrollToElement(this.$refs.web, {
+              offset: message.offset + this._navigateTo.offset
+            })
+            this._navigateTo.resolve()
+          } else {
+            this._navigateTo.reject(Error('Label not found'))
+          }
+          break
+        // 点击
+        case 'onClick':
+          this.$emit('tap')
+          this.$emit('click')
+          break
+        // 出错
+        case 'onError':
+          this.$emit('error', {
+            source: message.source,
+            attrs: message.attrs
+          })
+      }
+    }
+    // #endif
+  }
+}
+</script>
+
+<style>
+/* #ifndef APP-PLUS-NVUE */
+/* 根节点样式 */
+._root {
+  padding: 1px 0;
+  overflow-x: auto;
+  overflow-y: hidden;
+  -webkit-overflow-scrolling: touch;
+}
+
+/* 长按复制 */
+._select {
+  user-select: text;
+}
+/* #endif */
+</style>

File diff suppressed because it is too large
+ 670 - 0
src/uni_modules/zero-markdown-view/components/mp-html/node/node.vue


File diff suppressed because it is too large
+ 1400 - 0
src/uni_modules/zero-markdown-view/components/mp-html/parser.js


+ 129 - 0
src/uni_modules/zero-markdown-view/components/mp-html/style/index.js

@@ -0,0 +1,129 @@
+/**
+ * @fileoverview style 插件
+ */
+// #ifndef APP-PLUS-NVUE
+import Parser from './parser'
+// #endif
+
+function Style () {
+  this.styles = []
+}
+
+// #ifndef APP-PLUS-NVUE
+Style.prototype.onParse = function (node, vm) {
+  // 获取样式
+  if (node.name === 'style' && node.children.length && node.children[0].type === 'text') {
+    this.styles = this.styles.concat(new Parser().parse(node.children[0].text))
+  } else if (node.name) {
+    // 匹配样式(对非文本标签)
+    // 存储不同优先级的样式 name < class < id < 后代
+    let matched = ['', '', '', '']
+    for (let i = 0, len = this.styles.length; i < len; i++) {
+      const item = this.styles[i]
+      let res = match(node, item.key || item.list[item.list.length - 1])
+      let j
+      if (res) {
+        // 后代选择器
+        if (!item.key) {
+          j = item.list.length - 2
+          for (let k = vm.stack.length; j >= 0 && k--;) {
+            // 子选择器
+            if (item.list[j] === '>') {
+              // 错误情况
+              if (j < 1 || j > item.list.length - 2) break
+              if (match(vm.stack[k], item.list[j - 1])) {
+                j -= 2
+              } else {
+                j++
+              }
+            } else if (match(vm.stack[k], item.list[j])) {
+              j--
+            }
+          }
+          res = 4
+        }
+        if (item.key || j < 0) {
+          // 添加伪类
+          if (item.pseudo && node.children) {
+            let text
+            item.style = item.style.replace(/content:([^;]+)/, (_, $1) => {
+              text = $1.replace(/['"]/g, '')
+                // 处理 attr 函数
+                .replace(/attr\((.+?)\)/, (_, $1) => node.attrs[$1.trim()] || '')
+                // 编码 \xxx
+                .replace(/\\(\w{4})/, (_, $1) => String.fromCharCode(parseInt($1, 16)))
+              return ''
+            })
+            const pseudo = {
+              name: 'span',
+              attrs: {
+                style: item.style
+              },
+              children: [{
+                type: 'text',
+                text
+              }]
+            }
+            if (item.pseudo === 'before') {
+              node.children.unshift(pseudo)
+            } else {
+              node.children.push(pseudo)
+            }
+          } else {
+            matched[res - 1] += item.style + (item.style[item.style.length - 1] === ';' ? '' : ';')
+          }
+        }
+      }
+    }
+    matched = matched.join('')
+    if (matched.length > 2) {
+      node.attrs.style = matched + (node.attrs.style || '')
+    }
+  }
+}
+
+/**
+ * @description 匹配样式
+ * @param {object} node 要匹配的标签
+ * @param {string|string[]} keys 选择器
+ * @returns {number} 0:不匹配;1:name 匹配;2:class 匹配;3:id 匹配
+ */
+function match (node, keys) {
+  function matchItem (key) {
+    if (key[0] === '#') {
+      // 匹配 id
+      if (node.attrs.id && node.attrs.id.trim() === key.substr(1)) return 3
+    } else if (key[0] === '.') {
+      // 匹配 class
+      key = key.substr(1)
+      const selectors = (node.attrs.class || '').split(' ')
+      for (let i = 0; i < selectors.length; i++) {
+        if (selectors[i].trim() === key) return 2
+      }
+    } else if (node.name === key) {
+      // 匹配 name
+      return 1
+    }
+    return 0
+  }
+
+  // 多选择器交集
+  if (keys instanceof Array) {
+    let res = 0
+    for (let j = 0; j < keys.length; j++) {
+      const tmp = matchItem(keys[j])
+      // 任意一个不匹配就失败
+      if (!tmp) return 0
+      // 优先级最大的一个作为最终优先级
+      if (tmp > res) {
+        res = tmp
+      }
+    }
+    return res
+  }
+
+  return matchItem(keys)
+}
+// #endif
+
+export default Style

+ 175 - 0
src/uni_modules/zero-markdown-view/components/mp-html/style/parser.js

@@ -0,0 +1,175 @@
+const blank = {
+  ' ': true,
+  '\n': true,
+  '\t': true,
+  '\r': true,
+  '\f': true
+}
+
+function Parser () {
+  this.styles = []
+  this.selectors = []
+}
+
+/**
+ * @description 解析 css 字符串
+ * @param {string} content css 内容
+ */
+Parser.prototype.parse = function (content) {
+  new Lexer(this).parse(content)
+  return this.styles
+}
+
+/**
+ * @description 解析到一个选择器
+ * @param {string} name 名称
+ */
+Parser.prototype.onSelector = function (name) {
+  // 不支持的选择器
+  if (name.includes('[') || name.includes('*') || name.includes('@')) return
+  const selector = {}
+  // 伪类
+  if (name.includes(':')) {
+    const info = name.split(':')
+    const pseudo = info.pop()
+    if (pseudo === 'before' || pseudo === 'after') {
+      selector.pseudo = pseudo
+      name = info[0]
+    } else return
+  }
+
+  // 分割交集选择器
+  function splitItem (str) {
+    const arr = []
+    let i, start
+    for (i = 1, start = 0; i < str.length; i++) {
+      if (str[i] === '.' || str[i] === '#') {
+        arr.push(str.substring(start, i))
+        start = i
+      }
+    }
+    if (!arr.length) {
+      return str
+    } else {
+      arr.push(str.substring(start, i))
+      return arr
+    }
+  }
+
+  // 后代选择器
+  if (name.includes(' ')) {
+    selector.list = []
+    const list = name.split(' ')
+    for (let i = 0; i < list.length; i++) {
+      if (list[i].length) {
+        // 拆分子选择器
+        const arr = list[i].split('>')
+        for (let j = 0; j < arr.length; j++) {
+          selector.list.push(splitItem(arr[j]))
+          if (j < arr.length - 1) {
+            selector.list.push('>')
+          }
+        }
+      }
+    }
+  } else {
+    selector.key = splitItem(name)
+  }
+
+  this.selectors.push(selector)
+}
+
+/**
+ * @description 解析到选择器内容
+ * @param {string} content 内容
+ */
+Parser.prototype.onContent = function (content) {
+  // 并集选择器
+  for (let i = 0; i < this.selectors.length; i++) {
+    this.selectors[i].style = content
+  }
+  this.styles = this.styles.concat(this.selectors)
+  this.selectors = []
+}
+
+/**
+ * @description css 词法分析器
+ * @param {object} handler 高层处理器
+ */
+function Lexer (handler) {
+  this.selector = ''
+  this.style = ''
+  this.handler = handler
+}
+
+Lexer.prototype.parse = function (content) {
+  this.i = 0
+  this.content = content
+  this.state = this.blank
+  for (let len = content.length; this.i < len; this.i++) {
+    this.state(content[this.i])
+  }
+}
+
+Lexer.prototype.comment = function () {
+  this.i = this.content.indexOf('*/', this.i) + 1
+  if (!this.i) {
+    this.i = this.content.length
+  }
+}
+
+Lexer.prototype.blank = function (c) {
+  if (!blank[c]) {
+    if (c === '/' && this.content[this.i + 1] === '*') {
+      this.comment()
+      return
+    }
+    this.selector += c
+    this.state = this.name
+  }
+}
+
+Lexer.prototype.name = function (c) {
+  if (c === '/' && this.content[this.i + 1] === '*') {
+    this.comment()
+    return
+  }
+  if (c === '{' || c === ',' || c === ';') {
+    this.handler.onSelector(this.selector.trimEnd())
+    this.selector = ''
+    if (c !== '{') {
+      while (blank[this.content[++this.i]]);
+    }
+    if (this.content[this.i] === '{') {
+      this.floor = 1
+      this.state = this.val
+    } else {
+      this.selector += this.content[this.i]
+    }
+  } else if (blank[c]) {
+    this.selector += ' '
+  } else {
+    this.selector += c
+  }
+}
+
+Lexer.prototype.val = function (c) {
+  if (c === '/' && this.content[this.i + 1] === '*') {
+    this.comment()
+    return
+  }
+  if (c === '{') {
+    this.floor++
+  } else if (c === '}') {
+    this.floor--
+    if (!this.floor) {
+      this.handler.onContent(this.style)
+      this.style = ''
+      this.state = this.blank
+      return
+    }
+  }
+  this.style += c
+}
+
+export default Parser

+ 312 - 0
src/uni_modules/zero-markdown-view/components/zero-markdown-view/zero-markdown-view.vue

@@ -0,0 +1,312 @@
+<template>
+	<view class="zero-markdown-view">
+		<mp-html :key="mpkey" :selectable="selectable" :scroll-table='scrollTable' :tag-style="tagStyle"
+			:markdown="true" :content="contentAi">
+		</mp-html>
+	</view>
+</template>
+
+<script>
+import mpHtml from '../mp-html/mp-html';
+
+
+export default {
+	name: 'zero-markdown-view',
+	components: {
+		mpHtml
+	},
+	props: {
+		markdown: {
+			type: String,
+			default: ''
+		},
+		selectable: {
+			type: [Boolean, String],
+			default: true
+		},
+		scrollTable: {
+			type: Boolean,
+			default: true
+		},
+		themeColor: {
+			type: String,
+			default: '#007AFF'
+		},
+		codeBgColor: {
+			type: String,
+			default: '#2d2d2d'
+		},
+		aiMode: {
+			type: Boolean,
+			default: false
+		}
+	},
+	data() {
+		return {
+			content: '',
+			tagStyle: '',
+			mpkey: 'zero'
+		};
+	},
+	computed: {
+		contentAi() {
+			if (!this.content) {
+				return //处理特殊情况,比如网络异常导致的响应的 content 的值为空
+			}
+			let htmlString = ''
+			// 检查是否有未闭合的代码块
+			const codeBlocks = this.content.match(/```[\s\S]*?```|```[\s\S]*?$/g) || []
+			const lastBlock = codeBlocks[codeBlocks.length - 1]
+			if (lastBlock && !lastBlock.endsWith('```')) {
+				// 最后一个代码块未闭合,需要补上结束标识符
+				htmlString = this.content + '\n'
+			} else {
+				htmlString = this.content
+			}
+			return htmlString
+		},
+	},
+	watch: {
+		markdown: function (val) {
+			this.content = this.markdown
+		}
+	},
+	created() {
+		if (this.aiMode) {
+			this.initTagStyleForAi();
+		} else {
+			this.initTagStyle();
+		}
+	},
+	mounted() {
+		this.content = this.markdown
+	},
+
+	methods: {
+
+		initTagStyle() {
+			const themeColor = this.themeColor
+			const codeBgColor = this.codeBgColor
+			let zeroStyle = {
+				p: `
+				margin:5px 5px;
+				font-size: 15px;
+				line-height:1.75;
+				letter-spacing:0.2em;
+				word-spacing:0.1em;
+				`,
+				// 一级标题
+				h1: `
+				margin:25px 0;
+				font-size: 24px;
+				text-align: center;
+				font-weight: bold;
+				color: ${themeColor};
+				padding:3px 10px 1px;
+				border-bottom: 2px solid ${themeColor};
+				border-top-right-radius:3px;
+				border-top-left-radius:3px;
+				`,
+				// 二级标题
+				h2: `
+				margin:40px 0 20px 0;	
+				font-size: 20px;
+				text-align:center;
+				color:${themeColor};
+				font-weight:bolder;
+				padding-left:10px;
+				// border:1px solid ${themeColor};
+				`,
+				// 三级标题
+				h3: `
+				margin:30px 0 10px 0;
+				font-size: 18px;
+				color: ${themeColor};
+				padding-left:10px;
+				border-left:3px solid ${themeColor};
+				`,
+				// 引用
+				blockquote: `
+				margin:15px 0;
+				font-size:15px;
+				color: #777777;
+				border-left: 4px solid #dddddd;
+				padding: 0 10px;
+				 `,
+				// 列表 
+				ul: `
+				margin: 10px 0;
+				color: #555;
+				`,
+				li: `
+				margin: 5px 0;
+				color: #555;
+				`,
+				// 链接
+				a: `
+				// color: ${themeColor};
+				`,
+				// 加粗
+				strong: `
+				font-weight: border;
+				color: ${themeColor};
+				`,
+				// 斜体
+				em: `
+				color: ${themeColor};
+				letter-spacing:0.3em;
+				`,
+				// 分割线
+				hr: `
+				height:1px;
+				padding:0;
+				border:none;
+				text-align:center;
+				background-image:linear-gradient(to right,rgba(248,57,41,0),${themeColor},rgba(248,57,41,0));
+				margin:10px 0;
+				`,
+				// 表格
+				table: `
+				border-spacing:0;
+				overflow:auto;
+				min-width:100%;
+				margin:10px 0;
+				border-collapse: collapse;
+				`,
+				th: `
+				border: 1px solid #202121;
+				color: #555;
+				`,
+				td: `
+				color:#555;
+				border: 1px solid #555555;
+				`,
+				pre: `
+				border-radius: 5px;
+				white-space: pre;
+				background: ${codeBgColor};
+				font-size:12px;
+				position: relative;
+				`,
+			}
+			this.tagStyle = zeroStyle
+		},
+		initTagStyleForAi() {
+			const themeColor = this.themeColor
+			const codeBgColor = this.codeBgColor
+			let zeroStyle = {
+				p: `
+				font-size: 16px;
+				`,
+				// 一级标题
+				h1: `
+				margin:18px 0 10px 0;
+				font-size: 24px;
+				color: ${themeColor};
+				`,
+				// 二级标题
+				h2: `
+				margin:14px 0 10px 0;
+				font-size: 20px;
+				color: ${themeColor};
+				`,
+				// 三级标题
+				h3: `
+				margin:12x 0 8px 0;
+				font-size: 18px;
+				color: ${themeColor};
+				`,
+				// 四级标题
+				h4: `
+				margin:12px 0 8px 0;
+					font-size: 16px;
+				color: ${themeColor};
+				`,
+				// 五级标题
+				h5: `
+				margin:10px 0 8px 0;
+					font-size: 16px;
+				color: ${themeColor};
+				`,
+				// 六级标题
+				h6: `
+				margin:8px 0 8px 0;
+					font-size: 16px;
+				color: ${themeColor}
+				`,
+				// 引用
+				blockquote: `
+				margin:15px 0;
+				font-size:15px;
+				color: #777777;
+				border-left: 4px solid #dddddd;
+				padding: 0 10px;
+				 `,
+				// 列表 
+				ul: `
+				margin: 10px 0;
+				color: #555;
+				`,
+				li: `
+				margin: 5px 0;
+				color: #555;
+				`,
+				// 链接
+				a: `
+				// color: ${themeColor};
+				`,
+				// 加粗
+				strong: `
+				font-weight: border;
+				color: ${themeColor};
+				`,
+				// 斜体
+				em: `
+				color: ${themeColor};
+				letter-spacing:0.3em;
+				`,
+				// 分割线
+				hr: `
+				height:1px;
+				padding:0;
+				border:none;
+				text-align:center;
+				background-image:linear-gradient(to right,rgba(248,57,41,0),${themeColor},rgba(248,57,41,0));
+				margin:10px 0;
+				`,
+				// 表格
+				table: `
+				border-spacing:0;
+				overflow:auto;
+				min-width:100%;
+				margin:10px 0;
+				border-collapse: collapse;
+				`,
+				th: `
+				border: 1px solid #202121;
+				color: #555;
+				`,
+				td: `
+				color:#555;
+				border: 1px solid #555555;
+				`,
+				pre: `
+				border-radius: 5px;
+				white-space: pre;
+				background: ${codeBgColor};
+				font-size:12px;
+				position: relative;
+				`,
+			}
+			this.tagStyle = zeroStyle
+		},
+	}
+};
+</script>
+
+<style lang="scss">
+.zero-markdown-view {
+	padding: 15rpx;
+	position: relative;
+}
+</style>

+ 87 - 0
src/uni_modules/zero-markdown-view/package.json

@@ -0,0 +1,87 @@
+{
+  "id": "zero-markdown-view",
+  "displayName": "zero-markdown-view(markdown解析)",
+  "version": "3.0.0",
+  "description": "一行代码即可实现markdown解析,支持自定义主题色,支持vue2,vue3,支持流式输出。",
+  "keywords": [
+    "markdown",
+    "markdown解析",
+    "代码块",
+    "代码高亮",
+    "mp-html"
+],
+  "repository": "",
+"engines": {
+  },
+  "dcloudext": {
+    "type": "component-vue",
+    "sale": {
+      "regular": {
+        "price": "0.00"
+      },
+      "sourcecode": {
+        "price": "0.00"
+      }
+    },
+    "contact": {
+      "qq": ""
+    },
+    "declaration": {
+      "ads": "无",
+      "data": "插件不采集任何数据",
+      "permissions": "无"
+    },
+    "npmurl": ""
+  },
+  "uni_modules": {
+    "dependencies": [],
+    "encrypt": [],
+    "platforms": {
+      "cloud": {
+        "tcb": "y",
+        "aliyun": "y",
+        "alipay": "n"
+      },
+      "client": {
+        "Vue": {
+          "vue2": "y",
+          "vue3": "y"
+        },
+        "App": {
+            "app-vue": "u",
+            "app-nvue": "u",
+            "app-harmony": "u",
+            "app-uvue": "u"
+        },
+        "H5-mobile": {
+          "Safari": "y",
+          "Android Browser": "y",
+          "微信浏览器(Android)": "y",
+          "QQ浏览器(Android)": "y"
+        },
+        "H5-pc": {
+          "Chrome": "y",
+          "IE": "u",
+          "Edge": "y",
+          "Firefox": "y",
+          "Safari": "y"
+        },
+        "小程序": {
+          "微信": "y",
+          "阿里": "u",
+          "百度": "u",
+          "字节跳动": "u",
+          "QQ": "u",
+          "钉钉": "u",
+          "快手": "u",
+          "飞书": "u",
+          "京东": "u"
+        },
+        "快应用": {
+          "华为": "u",
+          "联盟": "u"
+        }
+      }
+    }
+  }
+}

+ 133 - 0
src/uni_modules/zero-markdown-view/readme.md

@@ -0,0 +1,133 @@
+# zero-markdown-view
+
+## 一. 重要更新说明
+
+### v3.0.0
+
+- 优化了代码块流式输出
+- 增加aiMode模式(内容靠左对齐的,样式区别于普通模式)
+
+
+### v2.1.0
+
+- 新增 latex 公式支持
+- 代码块高亮支持: languages=markup+css+clike+javascript+c+cpp+go+java+markup-templating+php+python+rust
+- 代码包体积增加了不少(注意)
+
+### v2.0.4 (仅需要基础功能可以使用此版本)
+
+- 新增点击代码块复制代码-仅小程序可用
+
+### v2.0.0
+
+- 省去了 npm install marked
+- 省去了 npm install highlight.js
+- 使用 mp-html 自带的插件,重新生成 uniapp 包,大幅减少插件体积
+  传送门: [优化思路及详细过程](https://juejin.cn/post/7160995270476431373/) https://juejin.cn/post/7160995270476431373/
+
+## 二. 使用方法
+
+> 建议放到分包里手动引入该插件
+
+**符合`easycom`组件模式, 导入 `uni_modules` 后直接使用即可 **
+
+```html
+<template>
+  <view class="container">
+    <!-- 默认用法 直接传入md文本即可 普通md展示-->
+    <zero-markdown-view :markdown="content"></zero-markdown-view>
+
+	<!-- ai对话模式 -->
+	 <zero-markdown-view :markdown="msgContent" :aiMode='true'></zero-markdown-view>
+  </view>
+</template>
+<script>
+  export default {
+    data() {
+      return {
+        content: "## md格式的文本内容",
+		msgContent: "## ai回复的内容,兼容流式输出",
+      };
+    },
+    created() {},
+    methods: {},
+  };
+</script>
+<style lang="scss" scoped></style>
+```
+
+## 三. 参数说明
+
+| 参数        | 类型   | 默认值    | 描述          |
+| ----------- | ------ | --------- | ------------- |
+| markdown    | String |           | markdown 文本 |
+| themeColor  | String | '#007AFF' | 主题色        |
+| codeBgColor | String | '#2d2d2d' | 代码块背景色  |
+| aiMode      | Boolean| false     | 是否为 AI 模式(内容靠左对齐) |
+
+
+## 四. 注意事项
+
+### 关于代码块高亮
+
+如果需要更多语言或更换主题请前往 prism 官网 下载对应的 prism.min.js 和 prism.css 并替换 plugins/highlight/ 目录下的文件,重新运行打包命令并替换 mp-html. 不建议这样做,因为本插件还修改过其他地方,可能会导致插件不能正常使用.
+具体请参考 mp-html 的文档,在文末有链接
+
+### 关于代码块流式输出闪烁,可以尝试 给代码块后增加 `\n`
+
+#### 已经在组件内的 `aiMode` 解决,不需要自行处理,也就是说,如何你是是用在ai上,直接把ai回复内容传给组件就可以了
+#### 已经在组件内的 `aiMode` 解决,不需要自行处理,也就是说,如何你是是用在ai上,直接把ai回复内容传给组件就可以了
+#### 已经在组件内的 `aiMode` 解决,不需要自行处理,也就是说,如何你是是用在ai上,直接把ai回复内容传给组件就可以了
+
+
+> 注意小程序上的流逝输出需要结合unipush2使用
+
+> 感谢评论区的一个朋友提供的代码
+
+````javascript
+		computed: {
+						// 流式输出时代码块处理 , 这时候请使用 msgContent 传入组件中
+		msgContent() {
+			if (!this.markdown) {
+				return
+			}
+			let htmlString = ''
+			// 检查是否有未闭合的代码块
+			const codeBlocks = this.markdown.match(/```[\s\S]*?```|```[\s\S]*?$/g) || []
+			const lastBlock = codeBlocks[codeBlocks.length - 1]
+			if (lastBlock && !lastBlock.endsWith('```')) {
+				// 最后一个代码块未闭合,需要补上结束标识符
+				htmlString = this.markdown + '\n'
+			} else {
+				htmlString = this.markdown
+			}
+			return htmlString
+		},
+		},
+````
+
+### 如何关闭点击代码块复制功能?
+
+找到组件文件夹 `zero-markdown-view`-`mp-html`-`highlight`-`config.js`
+
+**把 `copyByClickCode` 改成 false 即可**
+
+```
+export default {
+  copyByClickCode: true, // 点击代码块复制
+  showLanguageName: true, // 是否在代码块右上角显示语言的名称
+}
+```
+
+### 感谢 mp-html 插件
+
+插件地址: [https://ext.dcloud.net.cn/plugin?id=805](https://ext.dcloud.net.cn/plugin?id=805)
+
+文档地址: [https://jin-yufeng.gitee.io/mp-html/#/overview/quickstart](https://jin-yufeng.gitee.io/mp-html/#/overview/quickstart)
+
+插件预览:
+![code](https://cdn.zerojs.cn/image/common/code-z_1722414660881_1.jpg?imageMogr2/thumbnail/200x)
+
+> 小程序搜索: 零技术
+
+> 预览的小程序不一定能及时更新当前插件