快乐的梦鱼 1 month ago
parent
commit
995f36d0c0

+ 1 - 1
src/api/RequestModules.ts

@@ -98,7 +98,7 @@ export class MengyuServerRequestModule<T extends DataModel> extends BaseAppServe
     if (!this.uid) {
       this.uid = uni.getStorageSync(MengyuServerRequestModule.DEVICE_UID_KEY);
       if (!this.uid) {
-        this.uid = RandomUtils.genNonDuplicateIDHEX(10);
+        this.uid = RandomUtils.genNonDuplicateID(20);
         uni.setStorageSync(MengyuServerRequestModule.DEVICE_UID_KEY, this.uid);
       }
     }

+ 0 - 1
src/components/theme/ThemeDefine.ts

@@ -78,7 +78,6 @@ export function provideSomeThemeVar(record: Record<string, any>) {
         ...record
       }
     } as ThemeConfig;
-    console.log('provideSomeThemeVar', v);
     return v;
   });
   provide(ThemeKey, newTheme);

+ 47 - 0
src/pages/chat/components/ChatFooter.vue

@@ -0,0 +1,47 @@
+<template>
+  <FlexRow
+    position="relative" 
+    border="1px solid #e0e0e0" 
+    :radius="30"
+    :padding="20"
+    :margin="20"
+    align="center"
+  >
+    <textarea 
+      class="input"
+      v-model="input"
+      placeholder="给智能助手提问题,交流灵感..."
+      auto-height
+      confirm-type="send"
+      style="flex: 1;"
+    >
+    </textarea>
+    <IconButton 
+      icon="navigation"
+      :touchable="Boolean(input)"
+      :loading="props.chatManager.isLoading.value"
+      :innerStyle="{
+        backgroundColor: '#000',
+        borderRadius: '50%',
+        padding: '10rpx',
+      }"
+      color="white"
+      @click="props.chatManager.send(input)"
+    />
+  </FlexRow>
+</template>
+
+<script setup lang="ts">
+import IconButton from '@/components/basic/IconButton.vue';
+import FlexCol from '@/components/layout/FlexCol.vue';
+import FlexRow from '@/components/layout/FlexRow.vue';
+import { ref } from 'vue';
+import type { ChatManager } from '../core/Chat';
+
+const props = defineProps<{
+  chatManager: ChatManager;
+}>();
+
+const input = ref('');
+
+</script>

+ 544 - 0
src/pages/chat/components/ChatMessage.vue

@@ -0,0 +1,544 @@
+<template>
+  <div 
+    class="message-item mobile"
+    :class="[ 'type-' + message.role ]"
+    @longpress="onLongpress"
+  >
+    <!-- 多选模式 - 左 -->
+    <div v-if="!message.isUser && isSelectMode" class="select-container">
+      <CheckBox 
+        :modelValue="isThisItemSelected" 
+        @update:modelValue="setSelected($event as boolean)"
+      />
+    </div>
+
+    <!-- 消息内容 -->
+    <div class="message-content-container">
+
+      <!-- 主消息内容 -->
+      <div 
+        class="message"
+        :class="{ 'expanded': contentExpanded }"
+      >
+        <!-- 工具图标 -->
+        <i v-if="message.role === 'tool'" class="prefix icon iconfont icon-Jigsaw"></i>
+        <!-- 系统图标 -->
+        <i v-else-if="message.role === 'system'" class="prefix icon iconfont icon-a-ConfusedMind "></i>
+        <!-- 加载和失败状态 -->
+        <ActivityIndicator v-if="message.state === 'loading'" class="prefix icon" />
+        <Icon icon="warning" v-else-if="message.state === 'error'" class="prefix icon" />
+
+        <div class="content">
+
+          <span v-if="message.statusText" :class="[ 'status-text', message.state ]">
+            {{ message.statusText }}
+          </span>
+
+          <!-- 思考内容 -->
+          <text v-if="message.reasoningContent" class="reasoning-content">
+            {{ message.reasoningContent }}
+          </text>
+
+          <!-- 文本内容 -->
+          <div v-if="message.type === 'text' && content" class="text-content">
+            {{ content }}
+          </div>
+          <!-- 图片内容 -->
+          <div v-if="message.type === 'image_url' && message.image_url" class="image-content">
+            <image :src="message.image_url" />
+          </div>
+          <!-- 音频内容 -->
+          <div v-if="message.type === 'input_audio' && message.input_audio?.data" class="audio-content">
+            <audio :src="message.input_audio?.data" controls />
+          </div>
+          <!-- 视频内容 -->
+          <div v-if="message.type === 'video' && message.content" class="video-content">
+            <video :src="message.content" controls />
+          </div>
+          <!-- 视频内容 -->
+          <div v-if="message.type === 'video_url' && message.video_url" class="video-content">
+            <video :src="message.video_url" controls />
+          </div>
+        </div>
+      </div>
+
+      <!-- 额外内容 -->
+      <div 
+        v-if="message.extra" 
+        :class="{
+          'extra-container': true,
+          'text-content': true,
+          'expanded': extraExpanded
+        }" 
+      >
+        <FlexCol v-if="extraExpanded" backgroundColor="background.tertiary" padding="15" radius="radius.sm">
+          <text class="extra-content" >
+            {{ message.extra }}
+          </text>
+        </FlexCol>
+        <div class="button-container">
+          <ExpandButton v-model:expanded="extraExpanded" />
+        </div>
+      </div>
+        
+      <!-- 额外快捷操作 -->
+      <div v-if="message.actions" class="actions-container">
+        <Button v-for="action in message.actions" :key="action" @click="emit('action', action)">
+          {{ action }}
+        </Button>
+      </div>
+    </div>
+
+    <!-- 多选模式 - 右 -->
+    <div v-if="message.isUser && isSelectMode" class="select-container">
+      <CheckBox
+        :modelValue="isThisItemSelected" 
+        @update:modelValue="setSelected($event as boolean)"
+      />
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import type { ChatMessage } from '../model/Message';
+import type { PropType } from 'vue';
+import { computed, ref, toRef, watch } from 'vue';
+import { useChatSelectionChild } from '../composables/useChatSelection';
+import { TimeUtils } from '@imengyu/imengyu-utils';
+import ExpandButton from './Compoents/ExpandButton.vue';
+import CheckBox from '@/components/form/CheckBox.vue';
+import ActivityIndicator from '@/components/basic/ActivityIndicator.vue';
+import Icon from '@/components/basic/Icon.vue';
+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';
+
+const props = defineProps({
+  message: {
+    type: Object as PropType<ChatMessage>,
+    default: () => ({})
+  },
+  isGlobalLoading: {
+    type: Boolean,
+    default: false
+  }
+})
+
+const emit = defineEmits([
+  'delete', 'save', 'mulitSelect', 
+  'action', 'confirmEdit', 'regenerate'
+]);
+
+//多选
+
+const {
+  isSelectMode,
+  isThisItemSelected,
+  setSelected,
+} = useChatSelectionChild(toRef(props, 'message'));
+
+//时间
+
+const time = computed(() => {
+  return TimeUtils.getBetterDate(props.message.timestamp)
+})
+
+//内容显示
+
+const reasoningContentExpanded = ref(false);
+const reasoningUnExpandContent = computed(() => {
+  if (!props.message.reasoningContent)
+    return '';
+  switch (props.message.state) {
+    case 'loading':
+    case 'error':
+      return '';
+    case 'success':
+      return '深度思考完成' + (props.message.reasoningTime ? 
+        `,已思考 ${(props.message.reasoningTime / 1000).toFixed(1)}s` : 
+        ''
+      );
+  }
+})
+const contentExpanded = ref(true);
+const contentExpandable = computed(() => {
+  return props.message.content && 
+    props.message.content.length > 70 &&
+    props.message.type === 'text'
+})
+const content = computed(() => {
+  const content = props.message.content;
+  return (contentExpanded.value && contentExpandable.value) || content.length < 70 ? 
+    content : 
+    (content.slice(0, 70) + '...')
+})
+const extraExpanded = ref(false);
+
+//是否显示操作
+
+const canShowActions = computed(() => {
+  return !props.isGlobalLoading && (props.message.role === 'user'
+    ? true
+    : (!props.message.isEphemeral && props.message.state !== 'loading'))
+})
+
+const isUser = computed(() => {
+  return props.message.role === 'user'
+})
+const isAI = computed(() => {
+  return props.message.role === 'assistant'
+})
+
+watch(() => props.message, (m) => {
+  //工具消息默认不展开内容
+  if (m.role === 'tool') {
+    contentExpanded.value = false;
+  } else {
+    contentExpanded.value = true;
+  }
+}, { immediate: true });
+
+let oldChangedExpanded = '';
+
+watch(isSelectMode, (v) => {
+  if (v) {
+    oldChangedExpanded = contentExpanded.value ? 'expanded' : '';
+    contentExpanded.value = false;
+  } else {
+    if (oldChangedExpanded) {
+      contentExpanded.value = oldChangedExpanded === 'expanded';
+      oldChangedExpanded = '';
+    }
+  }
+});
+
+//保存
+function copyMessage(message: ChatMessage) {
+  uni.setClipboardData({
+    data: message.content,
+    success: () => {
+      uni.showToast({
+        title: '复制成功',
+        icon: 'success',
+      });
+    },
+  });
+}
+
+function onLongpress() {
+  if (!canShowActions.value) 
+    return;
+  actionSheet({
+    title: '消息操作',
+    actions: [
+      {
+        name: '复制',
+      },
+      {
+        name: '删除',
+        color: 'danger',
+      },
+      {
+        name: '多选',
+        color: 'primary',
+      },
+    ],
+  }).then((result) => {
+    switch (result) {
+      case 0:
+        copyMessage(props.message);
+        break;
+      case 1:
+        emit('delete', props.message.id);
+        break;
+      case 2:
+        emit('mulitSelect', [props.message.id]);
+        break;
+    }
+  });
+}
+</script>
+
+<style lang="scss">
+.message-item {
+  --color-background: #fff;
+  --color-background-light: #f5f5f5;
+  --color-ai: #ffffff;
+  --color-user: #fafafa;
+  --color-border: #e0e0e0;
+  --color-border-light: #f0f0f0;
+  --color-border-dark: #d0d0d0;
+  --color-text: #333333;
+  --color-text-light: #666666;
+  --color-content-light: #d0d0d0;
+  --color-content-dark: #333333;
+  --color-surface: #ffffff;
+  --color-surface-hover: #fafafa;
+  --border-radius: 15rpx;
+  --border-radius-small: 10rpx;
+
+  position: relative;
+  max-width: 100%;
+  display: flex;
+  flex-direction: row;
+  align-items: flex-start;
+  gap: 10rpx;
+  margin-bottom: 20rpx;
+  user-select: text;
+  -webkit-user-select: text;
+
+  .message-content-container  {
+    display: flex;
+    flex-direction: column;
+    justify-content: flex-start;
+    align-items: flex-start;
+    flex: 1;
+    max-width: 100%;
+    min-width: 0;
+  }
+  .message {
+    position: relative;
+    padding: 12px 15px;
+    border-radius: var(--border-radius);
+    max-width: 95%;
+    min-width: 0;
+    word-wrap: break-word;
+    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    gap: 10px;
+    overflow: hidden;
+
+    &:hover {
+      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
+    }
+    &.expanded {
+      align-items: flex-start;
+    }
+
+    >.icon {
+      flex-shrink: 0;
+    }
+
+    .status-container {
+      display: flex;
+      align-items: center;
+      gap: 2px;
+
+      .status-text {
+        font-size: 13px;
+        color: var(--color-text-light);
+    
+        &.error {
+          color: var(--color-danger);
+        }
+      }
+    }
+
+    //内容控制
+    .content {
+      display: flex;
+      flex-direction: column;
+      gap: 4px;
+      font-size: 1rem;
+      line-height: 1.5;
+      color: var(--color-text);
+      max-width: 100%;
+      min-width: 0;
+
+      //不同类型的内容样式
+      .image-content {
+        img {
+          max-width: 300px;
+          height: auto;
+          object-fit: cover;
+          border-radius: var(--border-radius-small);
+        }
+      }
+      .audio-content {
+        audio {
+          width: 400px;
+          border-radius: var(--border-radius-small);
+        }
+      }
+      .video-content {
+        video {
+          max-width: 100%;
+          height: auto;
+          border-radius: var(--border-radius-small);
+        }
+      }
+      .file-content {
+        display: flex;
+        align-items: center;
+        gap: 4px;
+      }
+
+      //思考内容 
+      .reasoning-content {
+        position: relative;
+        font-size: 0.8rem;
+        max-width: 500px;
+        color: var(--color-text-light);
+      }
+
+      .prefix {
+        display: inline-block;
+        margin-right: 8px;
+        vertical-align: middle;
+      }
+      
+      //文本内容样式
+      .text-content {
+        display: flex;
+        align-items: flex-start;
+        flex-wrap: wrap;
+      }
+    }
+    .time {
+      font-size: 0.8rem;
+      color: var(--color-text-light);
+      margin-top: 6px;
+      opacity: 0.8;
+    }    
+    .type-user .time {
+      align-self: flex-end;
+    }
+    .type-ai .time {
+      align-self: flex-start;
+    }
+  }
+  .extra-container {
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+    max-width: 100%;
+
+    span {
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+
+    &.expanded {
+      overflow: hidden;
+
+      .expand-icon {
+        transform: rotate(180deg);
+      }
+      .button-container {
+        position: relative;
+      }
+    }
+    .expand-icon {
+      transition: transform 0.3s ease;
+    }
+  }
+  .actions-container {
+    display: flex;
+    flex-direction: column;
+    justify-content: flex-start;
+    align-items: flex-start;
+    margin-top: 10px;
+    gap: 2px;
+  }
+  .message-actions {
+    position: absolute;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    bottom: -26px;
+    right: 6px;
+    left: 6px;
+    visibility: hidden;
+    z-index: 10;
+    gap: 12px;
+
+    > div {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+    }
+
+    .time {
+      font-size: 0.8rem;
+      color: var(--color-text-light);
+      margin: 0 6px;
+      margin-top: 3px;
+    }
+  }
+  .select-container {
+    padding: 14px 0;
+  }
+
+  /* 不同消息类型的样式 */
+  &.type-user {
+    flex-direction: row-reverse;
+
+    .message {
+      background-color: var(--color-user);
+      align-self: flex-end;
+      border: 1px solid var(--color-border);
+      border-top-right-radius: 0;
+    }
+    .message-actions {
+      left: unset;
+      right: 46px;
+    }
+  }
+  &.type-system,
+  &.type-tool,
+  &.type-assistant {
+    .message {  
+      background-color: var(--color-ai);
+      align-self: flex-start;
+      border: 1px solid var(--color-border-light);
+      border-top-left-radius: 0;
+    }
+    .message-actions {
+      left: 46px;
+      justify-content: flex-start;
+    }
+  }
+  &.type-assistant {
+    .message-actions {
+      left: 46px;
+      right: 66px;
+      justify-content: space-between;
+    }
+  }
+  &.type-tool {
+    .message {
+      border: none;
+      padding: 5px 10px;
+      box-shadow: none;
+    }
+  }
+
+  &.mobile {
+    .message-actions {
+      visibility: visible;
+      justify-content: space-between;
+      left: 0px;
+      right: 0px;
+      bottom: -36px;
+    }
+    .message {
+      padding: 5px 10px;
+    }
+
+    &.message-item {
+      margin-bottom: 46rpx;
+    }
+
+    &.type-user {
+      .message-actions {
+        justify-content: flex-end;
+      }
+    }
+  }
+}
+
+</style>

+ 90 - 0
src/pages/chat/components/ChatMessageContainer.vue

@@ -0,0 +1,90 @@
+<template>
+  <FlexCol position="relative" width="100%" height="100%">
+    <slot name="header" />
+    <!--聊天内容-->
+    <FlexCol flex="1" flexBasis="100%" :padding="20">
+      <FlexRow
+        v-if="historyItemsPagerManager && historyItemsPagerManager.hasMoreOlder.value"
+        position="absolute"
+        center
+        :inset="{t:0,l:0,r:0}"
+      >
+        <Button
+          size="small"
+          :loading="historyItemsPagerManager.loadingMore.value"
+          @click="() => historyItemsPagerManager!.loadOlder()"
+        >
+          加载更多
+        </Button>
+      </FlexRow>
+      <ActivityIndicator v-if="historyItemsPagerManager?.loadingInit.value" />
+      <scroll-view
+        ref="scrollRectRef"
+        scroll-y 
+        :style="{ flex: 1, height: '100%', maxHeight: '100%' }"
+        :scroll-top="scrollY"
+      >
+        <FlexCol gap="gap.sm">
+          <ChatMessage
+            v-for="message in chatManager.messages.value"
+            :key="message.id"
+            :message="message"
+            :isGlobalLoading="chatManager.isLoading.value"
+            @delete="deleteMessage(message.id)"
+            @action="chatManager.send($event)"
+            @mulitSelect="emit('intoSelectMode', [message.id])"
+          />
+        </FlexCol>
+      </scroll-view>
+    </FlexCol>
+    <!--聊天输入框-->
+    <slot name="footer" />
+  </FlexCol>
+</template>
+
+<script setup lang="ts">
+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 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';
+
+export interface ChatMessageContainerExpose {
+  scrollToBottom: () => void;
+  stopMessageEditing: () => void;
+}
+
+const props = defineProps<{
+  chatManager: ChatManager;
+  sessionManager: ChatSessionManager;
+  historyItemsPagerManager?: ChatHistoryItemsPagerManager;
+  chatInterfaceManager: ChatInterfaceManager;
+}>();
+
+const emit = defineEmits<{
+  (e: 'intoSelectMode', messageIds: number[]): void;
+}>();
+
+const scrollY = ref(0);
+
+function deleteMessage(messageId: number) {
+  props.sessionManager.onDeleteMessage(messageId);
+}
+function scrollToBottom() {
+  scrollY.value = 10000 + Math.random() * 1000;
+}
+
+onMounted(() => {
+  props.chatInterfaceManager.scrollToBottom = scrollToBottom;
+});
+defineExpose({
+  scrollToBottom,
+});
+</script>
+
+<style lang="scss">
+</style>

+ 25 - 0
src/pages/chat/components/Compoents/ExpandButton.vue

@@ -0,0 +1,25 @@
+<template>
+  <Button 
+    icon="arrow-down-bold"
+    :iconProps="{
+      rotate: expanded ? 180 : 0,
+    }"
+    :text="expanded ? '收起详情' : '查看更多'"
+    size="small"
+    type="text"
+    @click="emit('update:expanded', !props.expanded)"
+  />
+</template>
+
+<script lang="ts" setup>
+import Button from '@/components/basic/Button.vue';
+
+const props = defineProps<{
+  expanded: boolean;
+}>();
+
+const emit = defineEmits<{
+  (e: 'update:expanded', value: boolean): void;
+}>();
+
+</script>

+ 49 - 0
src/pages/chat/composables/useChatExportSave.ts

@@ -0,0 +1,49 @@
+import type { ChatMessage } from "../model/Message";
+
+export function useChatExportSave(options: {
+  getContentHtml?: () => string;
+  getExtraContentHtml?: () => string;
+}) {
+  const copyMessage = async(message: ChatMessage, type: 'text' | 'html' | 'markdown') => {
+
+    function copy(data: string) {
+      uni.setClipboardData({
+        data: data,
+        success: () => {
+          uni.showToast({
+            title: '复制成功',
+            icon: 'success',
+          });
+        },
+      });
+    }
+
+    switch (message.type) {
+      case 'image_url': {
+        uni.setClipboardData({
+          data: message.image_url || ''
+        });
+        break;
+      }
+      default: {
+        switch (type) {
+          case 'text':
+            await copy(message.content + (message.extra || ''));
+            break;
+          case 'html':
+            await copy(`<div>${options.getContentHtml?.() || ''}</div>
+    <div>${options.getExtraContentHtml?.() || ''}</div>`);
+            break;
+          case 'markdown':
+            await copy(message.content + (message.extra || ''));
+            break;
+        }
+        break;
+      }
+    }
+  }
+
+  return {
+    copyMessage,
+  }
+}

+ 61 - 0
src/pages/chat/composables/useChatSelection.ts

@@ -0,0 +1,61 @@
+import { computed, inject, provide, ref, type Ref } from "vue";
+import type { ChatMessage } from "../model/Message";
+import { ArrayUtils, assertNotNull, requireNotNull } from "@imengyu/imengyu-utils";
+
+export function useChatSelection() {
+  const selectedMessageIds = ref<number[]>([]);
+  const isSelectMode = ref(false);
+  const selectedCount = computed(() => selectedMessageIds.value.length);
+
+  provide('selectedMessageIds', selectedMessageIds);
+  provide('isSelectMode', isSelectMode);
+
+  function intoSelectMode(sourceIds: number[]) {
+    isSelectMode.value = true;
+    selectedMessageIds.value = sourceIds;
+  }
+  function exitSelectMode() {
+    isSelectMode.value = false;
+    selectedMessageIds.value = [];
+  }
+
+  return {
+    intoSelectMode,
+    exitSelectMode,
+    isSelectMode,
+    selectedCount,
+  }
+}
+
+export function useChatSelectionChild(message?: Ref<ChatMessage>) {
+  const selectedMessageIds = requireNotNull(inject<Ref<number[]>>('selectedMessageIds', ref([])));
+  const isSelectMode = requireNotNull(inject<Ref<boolean>>('isSelectMode', ref(false)));
+
+  const isThisItemSelected = computed(() => {
+    if (!message)
+      return false;
+    return selectedMessageIds.value?.includes(message.value.id);
+  });
+
+  const isAnyItemSelected = computed(() => {
+    return selectedMessageIds.value.length > 0;
+  });
+
+  function setSelected(value: boolean) {
+    assertNotNull(message?.value, 'message is not set');
+    assertNotNull(selectedMessageIds.value, 'selectedMessageIds is not set');
+    if (value) {
+      selectedMessageIds.value.push(message.value.id);
+    } else {
+      ArrayUtils.remove(selectedMessageIds.value, message.value.id);
+    }
+  }
+
+  return {
+    selectedMessageIds,
+    isSelectMode,
+    isAnyItemSelected,
+    isThisItemSelected,
+    setSelected,
+  }
+}

+ 9 - 2
src/pages/chat/core/Chat.ts

@@ -150,7 +150,9 @@ export function useChat(options: {
 
   //新建会话消息构建
   sessionManager.events.on('session-newed', () => {
+    console.log('session-newed', 1);
     messages.value = [staticMessagesManager.getWelcomeMessage()];
+    console.log('session-newed', messages.value);
     config.onNewChat?.(true);
     contextManager.estimateTokenUseage(config.onGetSendOptions());
   });
@@ -574,8 +576,13 @@ export function useChat(options: {
 
   onMounted(async () => {
     if (sessionManager.enableSession) {
-      await sessionManager.loadSessions();
-      sessionManager.onSelectNew();
+      try {
+        await sessionManager.loadSessions();
+        sessionManager.onSelectNew();
+      } catch (error) {
+        console.error("加载会话失败:", error);
+        sessionManager.onSelectLocal();
+      }
     } else {
       sessionManager.onSelectLocal();
     }

+ 4 - 4
src/pages/chat/model/Message.ts

@@ -1,8 +1,8 @@
-import { AgentChatHistoryItem } from "@/api/moduls/content/agent/Agent";
-import type { ChatAttachmentItem } from "../components/Footer/ChatAttachmentList.vue";
-import { AgentChatFile } from "@/api/moduls/content/agent/AgentChatFile";
+import { AgentChatHistoryItem } from "@/api/agent/Agent";
 import type OpenAI from "openai";
 import { StringUtils } from "@imengyu/imengyu-utils";
+import { AgentChatFile } from "@/api/agent/AgentChatFile";
+import type { ChatAttachmentItem } from "../core/Chat";
 
 export type ChatMessageType = 'text' | 'image_url' | 'input_audio' | 'input_file' | 'video' | 'video_url';
 export type ChatMessageState = 'loading' | 'success' | 'error';
@@ -168,7 +168,7 @@ export class ChatMessage {
 
   setError(title: string, error: string) {
     this.statusText = title;
-    this.extra = error.trim() ? '```\n' + error + '\n```' : undefined;
+    this.extra = error.trim() ? error : undefined;
     this.state = 'error';
   }
   resetError() {

+ 114 - 2
src/pages/home/post/agent.vue

@@ -1,3 +1,115 @@
 <template>
-  
-</template>
+  <ChatMessageContainer
+    :chatManager="chatManager"
+    :sessionManager="sessionManager"
+    :historyItemsPagerManager="historyItemsPagerManager"
+    :chatInterfaceManager="interfaceManager"
+    @intoSelectMode="intoSelectMode"
+  >
+    <template #header>
+      <NavBar title="AI伴写" leftButton="menu" @leftButtonPressed="" />
+    </template>
+    <template #footer>
+      <ChatFooter 
+        :chatManager="chatManager" 
+      />
+    </template>
+  </ChatMessageContainer>
+</template>
+
+<script setup lang="ts">  
+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 { 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';
+
+const messages = ref<ChatMessageModel[]>([]) as Ref<ChatMessageModel[]>;
+const interfaceManager: ChatInterfaceManager = {
+  messages,
+  getAttachmentList: async () => {
+    return [];
+  },
+};
+
+const sessionManager = useChatSession({
+  messages,
+  enableSession: true,
+  groupId: ChatGroups.NOTE_EDITOR,
+  onStop: () => chatManager.stop(),
+  onScrollToBottom: () => interfaceManager.scrollToBottom?.(),
+});
+const historyItemsPagerManager = useChatHistoryItemsPager({
+  messages,
+  sessionManager,
+});
+
+const chatManager = useChat({
+  interfaceManager,
+  sessionManager,
+  config: {
+    onGetSendOptions: () => ({
+      enableThinking: false,
+      enableSearch: true,
+      modelInfo: {
+        name: 'hunyuan-2.0-instruct-20251111',
+        value: 'hunyuan-2.0-instruct-20251111',
+        max_tokens: 10000,
+      },
+      model: 'hunyuan-2.0-instruct-20251111',
+      chatOptions: {
+        temperature: 0.5,
+        top_p: 1,
+        top_k: 40,
+        presence_penalty: 0,
+      },
+      customSystemPrompt: '',
+    }),
+    defaultSystemPrompt: `你是一个“笔记/文章写作与改稿助手”,工作在一个笔记编辑器里。你需要在与用户对话的同时,能直接读取并修改当前文章(标题与正文)。
+
+## 工作目标
+- 帮用户写作:构思、列提纲、扩写、续写、改写、润色、降重、统一文风、改错别字与语病。
+- 帮用户改稿:在不改变事实/观点的前提下,提高表达清晰度、逻辑性与可读性;必要时提出修改建议并给出可直接应用的改稿。
+
+## 强制规则(很重要)
+- 当用户的需求涉及“基于现有文章内容”进行判断/总结/改写/润色/纠错/扩写时:你必须先调用工具读取文章(至少读取标题或正文)再开始。
+- 当需要改动文章时:优先使用“片段替换/插入/删除”等精确编辑工具;只有在大改(重写/大幅重排)时才使用整篇覆盖写入。
+- 任何工具写入后,都要简短说明你改了什么(1-3 条),避免长篇复述全文。
+- 如果用户只是在聊天,不需要动文章,就不要调用写入工具。
+
+## 输出偏好
+- 先给结论/方案,再给要点;结构清晰,使用小标题与列表。
+- 涉及改稿时,优先给“可直接应用”的修改结果(通过工具写入完成),必要时补充原因。`,
+    onBuildWelcome: () => {
+      return {
+        welcomeMessage: '你好!欢迎使用梦鱼的写作助手。可以与我讨论写作灵感、写作计划、交代我写作任务等。',
+        welcomeActions: [
+          '为我生成一个写作计划', 
+          '为我总结一下这篇笔记主要内容', 
+          '帮我修正这篇笔记的语法错误',
+        ],
+      }
+    },
+    onInitTools: (toolsManager) => {
+      
+    },
+  }
+});
+
+const {
+  intoSelectMode,
+  exitSelectMode,
+  selectedCount,
+  isSelectMode,
+} = useChatSelection();
+
+onMounted(() => {
+  interfaceManager.scrollToBottom?.();
+});
+</script>

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

@@ -55,7 +55,7 @@
       >
         <FlexRow padding="space.md" align="center" justify="space-between">
           <FlexRow align="center" justify="center">
-            <Button icon="https://xy.wenlvti.net/app_static/images/village/IconAi.png">AI伴写</Button>
+            <Button icon="https://xy.wenlvti.net/app_static/images/village/IconAi.png" @click="showAgentPopup = true">AI伴写</Button>
           </FlexRow>
           <FlexRow align="center" justify="center">
             <IconButton
@@ -68,7 +68,7 @@
         <XBarSpace />
       </BackgroundBox>
     </FlexCol>
-    <Popup v-model="showAgentPopup">
+    <Popup v-model:show="showAgentPopup" position="bottom" closeable size="60vh" round>
       <Agent />
     </Popup>
   </CommonRoot>
@@ -96,7 +96,7 @@ import Button from '@/components/basic/Button.vue';
 import XBarSpace from '@/components/layout/space/XBarSpace.vue';
 import IconButton from '@/components/basic/IconButton.vue';
 import Popup from '@/components/dialog/Popup.vue';
-import type Agent from '@/api/agent/Agent';
+import Agent from './agent.vue';
 
 const { querys } = useLoadQuerys({
   tag: '',
@@ -107,6 +107,7 @@ const title = ref('');
 const content = ref('');
 const images = ref([] as string[]);
 
+const showAgentPopup = ref(false);
 const saveDraftDebunce = new Debounce(1000, () => {
   uni.setStorageSync('postDraft', {
     title: title.value,
@@ -164,5 +165,8 @@ function publish() {
 
 onMounted(() => {
   loadDraft();
+  setTimeout(() => {
+    showAgentPopup.value = true;
+  }, 1000);
 });
 </script>