Преглед на файлове

📦 ai伴写功能完善,增加相关工具

快乐的梦鱼 преди 3 седмици
родител
ревизия
039a7157b2

+ 7 - 3
src/api/system/ConfigurationApi.ts

@@ -11,13 +11,17 @@ export const CommonConfigurationConfig = {
   appConfigId: 14,
 }
 
+export interface IBannerItem {
+  height: number;
+  images: string[];
+}
 export interface IConfigurationItem {
   banners: {
     homeTop: string;
     homeTitle: string;
-    home: string[];
-    dig: string[];
-    discover: string[];
+    home: IBannerItem;
+    dig: IBannerItem;
+    discover: IBannerItem;
   }
 }
 

+ 18 - 9
src/api/system/DefaultConfiguration.json

@@ -2,14 +2,23 @@
   "banners": {
     "homeTop": "https://xy.wenlvti.net/app_static/images/home/BannerHomeNew.png",
     "homeTitle": "https://xy.wenlvti.net/app_static/images/home/BannerHomeTitle.png",
-    "home": [
-      "https://xy.wenlvti.net/app_static/images/banner/Home.jpg"
-    ],
-    "dig": [
-      "https://xy.wenlvti.net/app_static/images/banner/Dig.jpg"
-    ],
-    "discover": [
-      "https://xy.wenlvti.net/app_static/images/banner/Discover.jpg"
-    ]
+    "home": {
+      "height": 460,
+      "images": [
+        "https://xy.wenlvti.net/app_static/images/banner/Home.jpg"
+      ]
+    },
+    "dig": {
+      "height": 400,
+      "images": [
+        "https://xy.wenlvti.net/app_static/images/banner/Dig.jpg"
+      ]
+    },
+    "discover": {
+      "height": 400,
+      "images": [
+        "https://xy.wenlvti.net/app_static/images/banner/Discover.jpg"
+      ]
+    }
   }
 }

+ 2 - 2
src/components/dialog/Dialog.vue

@@ -2,7 +2,6 @@
   <Popup
     v-bind="props"
     round
-    position="center"
     @close="onClose"
   >
     <DialogInner 
@@ -51,7 +50,7 @@ import DialogInner from './DialogInner.vue';
 import Popup from './Popup.vue';
 import type { PopupProps } from './Popup.vue';
 
-export interface DialogProps extends Omit<PopupProps, 'onClose'|'position'|'renderContent'> {
+export interface DialogProps extends Omit<PopupProps, 'onClose'|'renderContent'> {
   /**
    * 对话框的标题
    */
@@ -159,6 +158,7 @@ const props = withDefaults(defineProps<DialogProps>(), {
   mask: true,
   showConfirm: true,
   contentScroll: true,
+  position: 'center',
 });
 const emit = defineEmits([ 'close', 'update:show' ]);
 

+ 2 - 2
src/components/feedback/BubbleBox.vue

@@ -219,7 +219,7 @@ function hide() {
 }
 
 const innerStyle = computed(() => {
-  const horzLayout = selectStyleType(props.crossPosition, 'left', {
+  const horzLayout = selectStyleType(props.crossPosition, 'center', {
     left: {
       k: 'top',
       y: '0%',
@@ -236,7 +236,7 @@ const innerStyle = computed(() => {
       t: 'translateY(0%)',
     }
   });
-  const vertLayout = selectStyleType(props.crossPosition, 'left', {
+  const vertLayout = selectStyleType(props.crossPosition, 'center', {
     left: {
       k: 'left',
       x: '0%',

+ 2 - 1
src/components/form/DatePicker.vue

@@ -10,7 +10,8 @@
 
 <script setup lang="ts">
 import { computed, onMounted } from 'vue';
-import type { PickerItem, PickerProps } from './Picker.vue';
+import type { PickerProps } from './Picker.vue';
+import type { PickerItem } from './Picker';
 import Picker from './Picker.vue';
 import { DateUtils } from '@imengyu/imengyu-utils';
 

+ 2 - 1
src/components/form/DateTimePicker.vue

@@ -10,7 +10,8 @@
 
 <script setup lang="ts">
 import { computed, onMounted } from 'vue';
-import type { PickerItem, PickerProps } from './Picker.vue';
+import type { PickerProps } from './Picker.vue';
+import type { PickerItem } from './Picker';
 import Picker from './Picker.vue';
 import { DateUtils } from '@imengyu/imengyu-utils';
 

+ 105 - 0
src/components/form/Tags.vue

@@ -0,0 +1,105 @@
+<template>
+  <scroll-view :scroll-x="!wrap" class="nana-tags">
+    <FlexRow :wrap="wrap" align="center" gap="gap.md">
+      <Tag 
+        v-for="(tag, index) in tags"
+        :key="tag.value"
+        v-bind="props"
+        :text="tag.text"
+        :type="value == tag.value || (Array.isArray(value) && value.includes(tag.value)) ? activeType : unActiveType"
+        :touchable="!disabled"
+        @click="onToggleTag(tag.value)"
+        @close="onTagClose(tag.value)"
+      />
+    </FlexRow>
+  </scroll-view>
+</template>
+
+<script setup lang="ts">
+import { computed, toRef } from 'vue';
+import Tag, { type TagProps } from '../display/Tag.vue';
+import FlexRow from '../layout/FlexRow.vue';
+import { useFieldChildValueInjector } from './FormContext';
+
+export type TagsAcceptKey = string|number;
+export interface TagsProps extends TagProps {
+  /**
+   * 当前选中的标签值,传入数组时为多选模式
+   */
+  modelValue?: TagsAcceptKey|TagsAcceptKey[];
+  /**
+   * 标签数据列表
+   */
+  tags?: {
+    value: TagsAcceptKey,
+    text: string,
+  }[],
+  /**
+   * 是否禁用
+   * @default false
+   */
+  disabled?: boolean;
+  /**
+   * 激活时的标签颜色类型
+   * @default 'primary'
+   */
+  activeType?: TagProps['type'];
+  /**
+   * 非激活时的标签颜色类型
+   * @default 'default'
+   */
+  unActiveType?: TagProps['type'];
+  /**
+   * 是否换行显示,为 false 时横向滚动
+   * @default false
+   */
+  wrap?: boolean;
+}
+
+const props = withDefaults(defineProps<TagsProps>(), {
+  modelValue: 50,
+  disabled: false,
+  size: "small",
+  activeType: "primary",
+  unActiveType: "default",
+  wrap: false,
+  tags: () => [],
+});
+
+const emit = defineEmits([ 'update:modelValue', 'tagClose' ])
+
+const {
+  value,
+  updateValue,
+  context,
+} = useFieldChildValueInjector(
+  toRef(props, 'modelValue'), 
+  (v) => emit('update:modelValue', v)
+);
+
+const isMulitselect = computed(() => Array.isArray(value.value));
+
+function onTagClose(tag: TagsAcceptKey) {
+  emit('tagClose', tag);
+}
+function onToggleTag(tag: TagsAcceptKey) {
+  if (isMulitselect.value) {
+    const arr = value.value as TagsAcceptKey[];
+    if (arr.includes(tag)) {
+      arr.splice(arr.indexOf(tag), 1);
+    } else {
+      arr.push(tag);
+    }
+    updateValue(arr);
+  } else {
+    updateValue(tag);
+  } 
+}
+
+defineOptions({
+  options: {
+    styleIsolation: "shared",
+    virtualHost: true,
+  }
+})
+</script>

+ 2 - 1
src/components/form/TimePicker.vue

@@ -10,9 +10,10 @@
 
 <script setup lang="ts">
 import { computed, onMounted } from 'vue';
-import type { PickerItem, PickerProps } from './Picker.vue';
+import type { PickerProps } from './Picker.vue';
 import Picker from './Picker.vue';
 import { DateUtils } from '@imengyu/imengyu-utils';
+import type { PickerItem } from './Picker';
 
 export interface TimePickerProps extends Omit<PickerProps, 'columns'|'value'> {
   modelValue?: Date,

+ 12 - 2
src/pages/home/chat/core/Chat.ts

@@ -96,7 +96,12 @@ export interface ChatConfig {
    * 发送消息前
    * @param message - 消息
    */
-  onBeforeSend?: (userMessages: ChatMessageModel[], streamOptions: any) => void;
+  onBeforeSend?: (userMessages: ChatMessageModel[], streamOptions: any) => Promise<void>;
+  /**
+   * 程序追加系统提示词
+   * @returns 
+   */
+  onGetAppendSystemMessages?: () => string[];
   /**
    * 程序追加消息,不保存到数据库
    * @returns 
@@ -189,6 +194,11 @@ export function useChat(options: {
     else if (config.defaultSystemPrompt)
       systemMessages.push({ role: 'system', content: config.defaultSystemPrompt.trim() });
 
+    if (config.onGetAppendSystemMessages)
+      config.onGetAppendSystemMessages()
+        .map(s => ({ role: 'system', content: s.trim() }))
+        .forEach(s => systemMessages.push(s as OpenAI.Chat.ChatCompletionSystemMessageParam));
+
     /**
      * 构建用户消息
      */
@@ -280,7 +290,7 @@ export function useChat(options: {
         toolsManager.openAiTools.value
       );
 
-      config.onBeforeSend?.(messages.value.filter(m => currentUserMessageIds.includes(m.id)), streamOptions);
+      await config.onBeforeSend?.(messages.value.filter(m => currentUserMessageIds.includes(m.id)), streamOptions);
     } catch (error) {
       console.error("构建流式选项失败:", error);
       failedAndSetInfo("处理消息失败", error);

+ 24 - 1
src/pages/home/chat/core/ToolCall.ts

@@ -27,8 +27,10 @@ export function useToolCalls(
 
       const name = call.function.name;
       const tool = toolsManager.toolRegistry.value.get(name);
+      const argsForDisplay = parseToolArgumentsForDisplay(call.function.arguments);
+      const friendlyName = tool?.generateFriendlyName?.(argsForDisplay) || `调用工具 ${name}`;
       const newToolMessageId = LocalMessageIdPool.getNextId();
-      const toolMsg = messagesManager.addMessage(ChatMessageModel.createTool(name, `AI自动工作`, newToolMessageId) as ChatMessageModel);;
+      const toolMsg = messagesManager.addMessage(ChatMessageModel.createTool(name, friendlyName, newToolMessageId) as ChatMessageModel);;
       toolMsg.parentId = parentMessageId;
 
       try {
@@ -69,7 +71,28 @@ export function useToolCalls(
     }
   }
 
+
   return {
     executeToolCalls,
   }
+}
+
+/** 将 tool call 的 arguments 解析为对象,供展示用;解析失败时返回空对象,不抛错 */
+export function parseToolArgumentsForDisplay(raw: unknown): Record<string, unknown> {
+  if (raw == null || raw === "")
+    return {};
+  if (typeof raw === "object" && !Array.isArray(raw))
+    return raw as Record<string, unknown>;
+  if (typeof raw === "string") {
+    const s = raw.trim();
+    if (!s)
+      return {};
+    try {
+      const v = JSON.parse(s) as unknown;
+      return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
+    } catch {
+      return {};
+    }
+  }
+  return {};
 }

+ 5 - 0
src/pages/home/chat/core/Tools.ts

@@ -29,6 +29,11 @@ export type ChatToolDefinition = {
    */
   parameters?: Record<string, any>;
   /**
+   * 根据工具调用参数生成对用户可见的一句话说明(展示在工具消息标题等位置)
+   * @param args 已解析的工具参数对象
+   */
+  generateFriendlyName?: (args: any) => string;
+  /**
    * 当模型触发 tool_calls 时执行
    */
   handler: ChatToolHandler;

+ 355 - 60
src/pages/home/chat/dependent/post/components/agent.vue

@@ -1,52 +1,136 @@
 <template>
-  <FlexCol v-if="showSessionSidebar" position="relative" width="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"
-    :chatInterfaceManager="interfaceManager"
-    @intoSelectMode="intoSelectMode"
+  <!-- 主弹窗 -->
+  <Popup
+    :show="showAgentPopup && !isAnySubDialogShow"
+    position="bottom"
+    :mask="false"
+    size="70vh"
+    @update:show="emit('update:showAgentPopup', $event)"
   >
-    <template #header>
+    <FlexCol v-if="showSessionSidebar" position="relative" width="100%">
       <NavBar 
-        title="AI伴写" 
-        leftButton="menu"
-        rightButton="close-bold"
-        @leftButtonPressed="showSessionSidebar = true" 
-        @rightButtonPressed="emit('close')"
+        title="历史会话" 
+        leftButton="close-bold" 
+        @leftButtonPressed="showSessionSidebar=false"
       />
-    </template>
-    <template #footer>
-      <ChatFooter
-        :chatManager="chatManager" 
-        :chatInterfaceManager="interfaceManager"
-      >
-        <template #mulitSelectMode>
-          <ChatMulitSelectBar 
-            :selectedCount="selectedCount" 
-            :messages="messages" 
-            :sessionManager="sessionManager" 
-            @cancel="exitSelectMode"
-          />
-        </template>
-        <template #header>
-
-        </template>
-      </ChatFooter>
-    </template>
-  </ChatMessageContainer>
+      <ChatSessionSidebar :sessionManager="sessionManager" @close="showSessionSidebar = false" />
+    </FlexCol>
+    <ChatMessageContainer
+      v-else
+      :chatManager="chatManager"
+      :sessionManager="sessionManager"
+      :historyItemsPagerManager="historyItemsPagerManager"
+      :chatInterfaceManager="interfaceManager"
+      @intoSelectMode="intoSelectMode"
+    >
+      <template #header>
+        <NavBar 
+          title="AI伴写" 
+          leftButton="menu"
+          rightButton="close-bold"
+          @leftButtonPressed="showSessionSidebar = true" 
+          @rightButtonPressed="emit('close')"
+        />
+      </template>
+      <template #footer>
+        <ChatFooter
+          :chatManager="chatManager" 
+          :chatInterfaceManager="interfaceManager"
+        >
+          <template #mulitSelectMode>
+            <ChatMulitSelectBar 
+              :selectedCount="selectedCount" 
+              :messages="messages" 
+              :sessionManager="sessionManager" 
+              @cancel="exitSelectMode"
+            />
+          </template>
+          <template #header>
+            <scroll-view scroll-x>
+              <FlexRow align="center" padding="padding.md" gap="gap.md">
+                <Button v-if="showImageWritingBtn" text="看图写作" @click="showImageWritingDialog = true" />
+                <Button v-if="showContinueBtn" text="AI续写" @click="showContinueDialog = true" />
+                <Button v-if="showPolishBtn" text="AI润色" @click="showPolishDialog = true" />
+                <Button v-if="showCorrectBtn" text="AI订正" @click="confirmCorrect" />
+                <Button v-if="showSummaryBtn" text="AI总结" @click="confirmSummary" />
+              </FlexRow>
+            </scroll-view>
+          </template>
+        </ChatFooter>
+      </template>
+    </ChatMessageContainer>
+  </Popup>
+
+  <!-- 看图写作弹窗 -->
+  <Popup
+    v-model:show="showImageWritingDialog"
+    closeable
+    :mask="false"
+    position="bottom"
+  >
+    <NavBar
+      leftButton="direction-left"
+      title="看图写作要求"
+      @leftButtonPressed="showImageWritingDialog = false"
+    />
+    <FlexCol padding="padding.md" gap="gap.md">
+      <Field label="篇幅">
+        <Tags v-model="imageWritingLength" :tags="imageWritingLengthOptions" />
+      </Field>
+      <Field label="其他要求" v-model="imageWritingContent" placeholder="输入其他要求" />
+      <Button type="primary" text="开始看图写作" @click="confirmImageWriting" />
+    </FlexCol>
+  </Popup>
+
+  <!-- AI续写弹窗 -->
+  <Popup
+    v-model:show="showContinueDialog"
+    closeable
+    :mask="false"
+    position="bottom"
+  >
+    <NavBar
+      leftButton="direction-left"
+      title="AI续写要求"
+      @leftButtonPressed="showContinueDialog = false"
+    />
+    <FlexCol padding="padding.md" gap="gap.md">
+      <Field label="篇幅">
+        <Tags v-model="continueLength" :tags="continueLengthOptions" />
+      </Field>
+      <Field label="更多">
+        <Tags v-model="continueStyle" :tags="continueStyleOptions" />
+      </Field>
+      <Field label="其他要求" v-model="continueExtra" placeholder="输入其他要求" />
+      <Button type="primary" text="开始AI续写" @click="confirmContinue" />
+    </FlexCol>
+  </Popup>
+
+  <!-- AI润色弹窗 -->
+  <Popup
+    v-model:show="showPolishDialog"
+    closeable
+    :mask="false"
+    position="bottom"
+  >
+    <NavBar
+      leftButton="direction-left"
+      title="AI润色要求"
+      @leftButtonPressed="showPolishDialog = false"
+    />
+    <FlexCol padding="padding.md" gap="gap.md">
+      <Field label="润色方向">
+        <Tags v-model="polishStyle" :tags="polishStyleOptions" />
+      </Field>
+      <Field label="其他要求" v-model="polishExtra" placeholder="输入其他要求" />
+      <Button type="primary" text="开始AI润色" @click="confirmPolish" />
+    </FlexCol>
+  </Popup>
+
 </template>
 
 <script setup lang="ts">  
-import { onMounted, ref, type Ref } from 'vue';
+import { computed, onMounted, ref, type Ref } from 'vue';
 import { useChatSession } from '../../../composables/useChatSession';
 import { useChat, type ChatInterfaceManager } from '../../../core/Chat';
 import { useChatHistoryItemsPager } from '../../../composables/useChatHistoryItemsPager';
@@ -60,6 +144,14 @@ import ChatSessionSidebar from '../../../components/Session/ChatSessionSidebar.v
 import ChatMulitSelectBar from '../../../components/Footer/ChatMulitSelectBar.vue';
 import NavBar from '@/components/nav/NavBar.vue';
 import FlexCol from '@/components/layout/FlexCol.vue';
+import FlexRow from '@/components/layout/FlexRow.vue';
+import type { VillageListItem } from '@/api/light/LightVillageApi';
+import AgentApi from '@/api/agent/Agent';
+import CommonContent from '@/api/CommonContent';
+import Button from '@/components/basic/Button.vue';
+import Field from '@/components/form/Field.vue';
+import Popup from '@/components/dialog/Popup.vue';
+import Tags from '@/components/form/Tags.vue';
 
 const messages = ref<ChatMessageModel[]>([]) as Ref<ChatMessageModel[]>;
 const interfaceManager: ChatInterfaceManager = {
@@ -79,17 +171,198 @@ const props = defineProps<{
     url: string;
     localUrl: string;
   }[];
+  villageInfo?: VillageListItem | undefined;
+  tag?: string;
+  showAgentPopup: boolean;
 }>();
 const emit = defineEmits([ 
   'update:title',
   'update:content',
   'update:images',
+  'update:showAgentPopup',
   'close',
   'upload'
 ]);
 
 const showSessionSidebar = ref(false);
 
+//快速操作控制
+//=============================================
+
+const showImageWritingDialog = ref(false);
+const imageWritingContent = ref('');
+const imageWritingLength = ref(300);
+const imageWritingLengthOptions = [
+  { text: '约50字', value: 50 },
+  { text: '约100字', value: 100 },
+  { text: '约300字', value: 300 },
+  { text: '约500字', value: 500 },
+];
+
+function confirmImageWriting() {
+  showImageWritingDialog.value = false;
+  if (props.content.length > 0) {
+    chatManager.send(`根据图片描述为我续写,约${imageWritingLength.value}字${imageWritingContent.value ? ',' + imageWritingContent.value : ''}`);
+  } else {
+    chatManager.send(`帮我看图图写作,约${imageWritingLength.value}字${imageWritingContent.value ? ',' + imageWritingContent.value : ''}`);
+  }
+}
+
+const showContinueDialog = ref(false);
+const continueLength = ref(100);
+const continueLengthOptions = [
+  { text: '约50字', value: 50 },
+  { text: '约100字', value: 100 },
+  { text: '约300字', value: 300 },
+];
+const continueStyle = ref('');
+const continueStyleOptions = [
+  { text: '发散介绍', value: '发散介绍' },
+  { text: '详细论述', value: '详细论述' },
+  { text: '添加转折', value: '添加转折' },
+  { text: '总结升华', value: '总结升华' },
+];
+const continueExtra = ref('');
+
+function confirmContinue() {
+  showContinueDialog.value = false;
+  const parts = [`帮我续写文章,约${continueLength.value}字`];
+  if (continueStyle.value) parts.push(`风格:${continueStyle.value}`);
+  if (continueExtra.value) parts.push(continueExtra.value);
+  chatManager.send(parts.join(','));
+}
+
+const showPolishDialog = ref(false);
+const polishStyle = ref('');
+const polishStyleOptions = [
+  { text: '语句通顺', value: '使语句更加通顺流畅' },
+  { text: '文采提升', value: '提升文采,使用更优美的表达' },
+  { text: '口语化', value: '改为口语化、亲切自然的表达' },
+  { text: '正式书面', value: '改为正式书面语风格' },
+];
+const polishExtra = ref('');
+
+function confirmPolish() {
+  showPolishDialog.value = false;
+  const parts = ['帮我润色文章'];
+  if (polishStyle.value) parts.push(`要求:${polishStyle.value}`);
+  if (polishExtra.value) parts.push(polishExtra.value);
+  chatManager.send(parts.join(','));
+}
+
+function confirmCorrect() {
+  chatManager.send('帮我检查文章中的错别字与语病并修正');
+}
+
+function confirmSummary() {
+  chatManager.send('帮我总结文章中的内容');
+}
+
+const isAnySubDialogShow = computed(() =>
+  showImageWritingDialog.value || showContinueDialog.value || showPolishDialog.value
+);
+
+/**
+  - 看图写作 — 有上传图片时显示
+  - AI续写 — 有图片或正文超 8 字时显示
+  - AI润色 / AI总结 — 正文超 50 字时显示
+  - AI订正 — 正文超 20 字时显示
+ */
+const showImageWritingBtn = computed(() => props.images.length > 0);
+const showContinueBtn = computed(() => props.images.length > 0 || props.content.length > 8);
+const showPolishBtn = computed(() => props.content.length > 50);
+const showCorrectBtn = computed(() => props.content.length > 20);
+const showSummaryBtn = computed(() => props.content.length > 50);
+
+//图片识别
+//=============================================
+
+const uploadedImageDescs = [] as {
+  srcImage: string;
+  aiDescription: string;
+}[];
+
+/**                                                                                                                                                                     
+  * 为用户上传的图片生成AI描述,以供写作助手使用。                                                                                                                
+  * 函数将进行去重处理,不会重复生成相同的描述。                                                                                                                         
+  */   
+async function generateAiImageDescs() {
+  if (props.images.length === 0) return;
+
+  const newImages = props.images.filter(
+    img => !uploadedImageDescs.some(desc => desc.srcImage === img.localUrl)
+  );
+  if (newImages.length === 0) return;
+
+  try {
+    uni.showLoading({ title: 'AI识图中...' });
+
+    const uploadedUrls = await Promise.all(
+      newImages.map(async (img) => {
+        if (img.url) return { localUrl: img.localUrl, remoteUrl: img.url };
+        const res = await CommonContent.uploadFile(img.localUrl, 'image', 'file');
+        return { localUrl: img.localUrl, remoteUrl: res.fullurl };
+      })
+    );
+
+    const imageContents = uploadedUrls.flatMap(item => ([
+      { type: "image_url", image_url: { url: item.remoteUrl } },
+    ]));
+
+    const res = await AgentApi.chat({
+      model: "hy-vision-2.0-instruct",
+      messages: [
+        {
+          role: "system",
+          content: "你是一个图片解析助手,请依次描述每张图片的内容,用于辅助乡村文化写作。" +
+            "请按以下JSON数组格式输出,不要输出其他内容:\n[{\"index\":1,\"scene\":\"场景概述\",\"details\":\"细节描述\",\"mood\":\"氛围/情感基调\"}]",
+        },
+        {
+          role: "user",
+          content: [
+            ...imageContents,
+            {
+              type: "text",
+              text: '请依次描述每张图片的内容'
+            },
+          ],
+        },
+      ] as any,
+      stream: false,
+    });
+
+    const content = res.data?.choices?.[0]?.message?.content || '';
+    try {
+      const jsonMatch = content.match(/\[[\s\S]*\]/);
+      if (jsonMatch) {
+        const descriptions: { index: number; scene: string; details: string; mood: string }[] = JSON.parse(jsonMatch[0]);
+        descriptions.forEach((desc, i) => {
+          if (i < uploadedUrls.length) {
+            uploadedImageDescs.push({
+              srcImage: uploadedUrls[i].localUrl,
+              aiDescription: `${desc.scene}。${desc.details}。氛围:${desc.mood}`,
+            });
+          }
+        });
+      }
+    } catch {
+      uploadedUrls.forEach(item => {
+        uploadedImageDescs.push({
+          srcImage: item.localUrl,
+          aiDescription: content,
+        });
+      });
+    }
+  } catch (error) {
+    console.error('图片AI描述生成失败', error);
+  } finally {
+    uni.hideLoading();
+  }
+}
+
+//AI 聊天控制
+//=============================================
+
 const sessionManager = useChatSession({
   messages,
   enableSession: true,
@@ -102,22 +375,25 @@ 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 titleRef = computed({
+  get: () => props.title,
+  set: (v: string) => emit('update:title', v),
+});
+const contentRef = computed({
+  get: () => props.content,
+  set: (v: string) => emit('update:content', v),
+});
+const imagesRef = computed({
+  get: () => props.images,
+  set: (v: { url: string; localUrl: string }[]) => emit('update:images', v as any),
 });
 
-const welcomeActions = [
-  '乡村振兴的现状与未来', 
-  '乡源情怀是什么',
-  '乡源文化的传承与发展',
-  '乡源文化的保护与利用',
-  '乡源文化的传承与发展',
-]
+const { registerTools } = useAgentTools({
+  title: titleRef,
+  content: contentRef,
+  images: imagesRef,
+});
 
 const chatManager = useChat({
   interfaceManager,
@@ -149,17 +425,36 @@ const chatManager = useChat({
 ## 强制规则(很重要)
 - 当用户的需求涉及“基于现有文章内容”进行判断/总结/改写/润色/纠错/扩写时:你必须先调用工具读取文章(至少读取标题或正文)再开始。
 - 当需要改动文章时:优先使用“片段替换/插入/删除”等精确编辑工具;只有在大改(重写/大幅重排)时才使用整篇覆盖写入。
-- 任何工具写入后,都要简短说明你改了什么(1-3 条),避免长篇复述全文。
 - 如果用户只是在聊天,不需要动文章,就不要调用写入工具。
 - 用户大部分情况下需要简短的文章,请尽量在200~800字以内完成任务。
 
 ## 输出偏好
-- 先给结论/方案,再给要点;结构清晰,使用小标题与列表。
-- 涉及改稿时,优先给“可直接应用”的修改结果(通过工具写入完成),必要时补充原因。`,
+- 输出纯文本,除段落空行或首段空格外,**不要包含markdown** 。
+- 先给结论/方案,再给要点;结构清晰。`,
+    onGetAppendSystemMessages: () => {
+      const result = [] as string[];
+      result.push('## 编写内容');
+      result.push('用户正在编写话题:' + props.tag);
+      if (props.villageInfo) {
+        result.push('用户编写内容与此村社有关:' + props.villageInfo.name);
+        result.push('地址:' + props.villageInfo.address);
+      }
+      if (uploadedImageDescs.length > 0) {
+        result.push('## 相关图片');
+        result.push('用户上传了以下图片,请参考图片说明内容进行写作:');
+        uploadedImageDescs.forEach((desc, i) => {
+          result.push(`图片${i + 1}:${desc.aiDescription}`);
+        });
+      }
+      return result;
+    },
+    onBeforeSend: async (userMessages, streamOptions) => {
+      await generateAiImageDescs();
+    },
     onBuildWelcome: () => {
       return {
         welcomeMessage: '您好!欢迎使用亮乡源AI伴写助手。我可以与您讨论写作灵感、写作计划、为您智能完成写作任务',
-        welcomeActions: [...welcomeActions].sort(() => Math.random() - 0.5).slice(0, 3),
+        welcomeActions: [],
       }
     },
     onInitTools: (toolsManager) => {

+ 73 - 21
src/pages/home/chat/dependent/post/components/tbutton.vue

@@ -5,11 +5,11 @@
     position="left"
     backgroundColor="rgba(0,0,0,0.9)"
     :innerProps="{
-      width: '400rpx',
+      width: '430rpx',
     }"
   >
     <template #content>
-      <FlexRow align="center" gap="gap.md">
+      <FlexRow justify="space-between" align="center">
         <Text :text="currentTipContent" fontConfig="contentText" color="white" touchable @click="openAiWindow" />
         <IconButton icon="close" @click="hideTip" />
       </FlexRow>
@@ -26,7 +26,7 @@ import IconButton from '@/components/basic/IconButton.vue';
 import Text from '@/components/basic/Text.vue';
 import BubbleBox from '@/components/feedback/BubbleBox.vue';
 import FlexRow from '@/components/layout/FlexRow.vue';
-import { onMounted, ref } from 'vue';
+import { onMounted, onUnmounted, ref, watch } from 'vue';
 
 const props = defineProps<{
   title: string;
@@ -39,37 +39,89 @@ const props = defineProps<{
 
 const emit = defineEmits([ 'showAi' ]);
 
-/**
- * 能助手提示:
- * 0. 监听编辑器内容  
- * 1. 当用户进入后,长时间 15s 没有内容变更,则触发 "不知道怎么写?来和我讨论灵感吧"
- * 2. 当用户上传图片后,则触发 “让我来为你根据素材智能编写吧”
- * 
- * 3. 用户已写一段文字,防抖,一段时间内触发 
- * “灵感枯竭?让我来为你拓展文化选题”
- * "让我来为你润色文稿叙述更优雅动人"
- * "让我来为你校对纠错精准修正文史细节"
- * 
- */
-
 const bubbleBoxRef = ref<InstanceType<typeof BubbleBox>>();
 const currentTipContent = ref('');
 
+let idleTimer: ReturnType<typeof setTimeout> | null = null;
+let contentDebounceTimer: ReturnType<typeof setTimeout> | null = null;
+let tipDismissed = false;
+let contentTipIndex = 0;
+
+const IDLE_DELAY = 12000;
+const CONTENT_DEBOUNCE_DELAY = 10000;
+
+const contentTips = [
+  '灵感枯竭?让我来为你拓展文化选题',
+  '让我来为你润色文稿叙述更优雅动人',
+  '让我来为你校对纠错精准修正文史细节',
+];
+
+function clearTimers() {
+  if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
+  if (contentDebounceTimer) { clearTimeout(contentDebounceTimer); contentDebounceTimer = null; }
+}
+
 function hideTip() {
+  tipDismissed = true;
   bubbleBoxRef.value?.hide();
 }
 function showTip(message: string) {
+  tipDismissed = false;
   currentTipContent.value = message;
   bubbleBoxRef.value?.show();
 }
 function openAiWindow() {
   emit('showAi', currentTipContent.value);
-  hideTip();
+  tipDismissed = true;
+  bubbleBoxRef.value?.hide();
+}
+
+function startIdleTimer() {
+  clearTimers();
+  idleTimer = setTimeout(() => {
+    if (!tipDismissed) {
+      showTip('不知道怎么写?来和我讨论灵感吧');
+    }
+  }, IDLE_DELAY);
 }
 
+watch(() => props.images.length, (newLen, oldLen) => {
+  if (newLen > oldLen) {
+    clearTimers();
+    showTip('让我来为你根据素材智能编写吧');
+  }
+});
+
+watch(() => props.content, () => {
+  clearTimers();
+  bubbleBoxRef.value?.hide();
+
+  if (!props.content) {
+    startIdleTimer();
+    return;
+  }
+
+  contentDebounceTimer = setTimeout(() => {
+    const tip = contentTips[contentTipIndex % contentTips.length];
+    contentTipIndex++;
+    showTip(tip);
+  }, CONTENT_DEBOUNCE_DELAY);
+});
+
+watch(() => props.title, () => {
+  clearTimers();
+  bubbleBoxRef.value?.hide();
+
+  if (!props.title && !props.content) {
+    startIdleTimer();
+  }
+});
+
 onMounted(() => {
-  setTimeout(() => {
-    showTip('不知道怎么写?让我来帮你吧');
-  }, 1000);
-})
+  startIdleTimer();
+});
+
+onUnmounted(() => {
+  clearTimers();
+});
 </script>

+ 43 - 42
src/pages/home/chat/dependent/post/composables/agentTools.ts

@@ -1,33 +1,21 @@
-import type { ChatToolsManager } from "@/pages/chat/core/Tools";
+import type { Ref } from "vue";
+import type { ChatToolsManager } from "../../../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;
+  title: Ref<string>;
+  content: Ref<string>;
+  images: Ref<{ url: string; localUrl: string }[]>;
 }) {
-  let currentTitle = options.title ?? "";
-  let currentContent = options.content ?? "";
-  let currentImages = [...(options.images ?? [])];
-
   function setTitle(title: string) {
-    currentTitle = title;
-    options.onUpdateTitle(title);
+    options.title.value = title;
   }
 
   function setContent(content: string) {
-    currentContent = content;
-    options.onUpdateContent(content);
+    options.content.value = content;
   }
 
   function setImages(images: string[]) {
-    currentImages = images.map((url) => ({ url, localUrl: url }));
-    options.onUpdateImages(images);
+    options.images.value = images.map((url) => ({ url, localUrl: url }));
   }
 
   function requireNonEmptyText(value: unknown, fieldName: string) {
@@ -58,9 +46,9 @@ export function useAgentTools(options: {
   function buildResult(extra: Record<string, unknown> = {}) {
     return {
       ok: true,
-      title: currentTitle,
-      contentLength: currentContent.length,
-      imageCount: currentImages.length,
+      title: options.title.value,
+      contentLength: options.content.value.length,
+      imageCount: options.images.value.length,
       ...extra,
     };
   }
@@ -78,14 +66,15 @@ export function useAgentTools(options: {
           },
         },
       },
+      generateFriendlyName: () => "读取文章状态",
       handler: (args) => {
         const includeContent = args?.includeContent !== false;
         return {
           ok: true,
-          title: currentTitle,
-          content: includeContent ? currentContent : undefined,
-          contentLength: currentContent.length,
-          images: currentImages.map((item) => item.url),
+          title: options.title.value,
+          content: includeContent ? options.content.value : undefined,
+          contentLength: options.content.value.length,
+          images: options.images.value.map((item) => item.url),
         };
       },
     });
@@ -103,6 +92,7 @@ export function useAgentTools(options: {
         },
         required: ["title"],
       },
+      generateFriendlyName: (args) => `设置标题:${args?.title ?? ""}`,
       handler: (args) => {
         const title = requireNonEmptyText(args?.title, "title");
         setTitle(title);
@@ -123,6 +113,7 @@ export function useAgentTools(options: {
         },
         required: ["content"],
       },
+      generateFriendlyName: () => "覆盖写入正文",
       handler: (args) => {
         if (typeof args?.content !== "string") {
           throw new Error("content 必须是字符串");
@@ -157,27 +148,29 @@ export function useAgentTools(options: {
         },
         required: ["target", "replacement"],
       },
+      generateFriendlyName: (args) =>
+        args?.replaceAll ? `全部替换:${args?.target ?? ""}` : `替换文本:${args?.target ?? ""}`,
       handler: (args) => {
         const target = requireNonEmptyText(args?.target, "target");
         const replacement = typeof args?.replacement === "string" ? args.replacement : "";
         const replaceAll = args?.replaceAll === true;
         if (replaceAll) {
-          if (!currentContent.includes(target)) {
+          if (!options.content.value.includes(target)) {
             throw new Error("未找到待替换文本");
           }
-          setContent(currentContent.split(target).join(replacement));
+          setContent(options.content.value.split(target).join(replacement));
           return buildResult({ changed: "content", mode: "replaceAll" });
         }
 
         const occurrence = typeof args?.occurrence === "number" ? args.occurrence : 1;
-        const index = findNthIndex(currentContent, target, occurrence);
+        const index = findNthIndex(options.content.value, target, occurrence);
         if (index < 0) {
           throw new Error("未找到指定位置的待替换文本");
         }
         const updated =
-          currentContent.slice(0, index) +
+          options.content.value.slice(0, index) +
           replacement +
-          currentContent.slice(index + target.length);
+          options.content.value.slice(index + target.length);
         setContent(updated);
         return buildResult({ changed: "content", mode: "replaceOne", occurrence });
       },
@@ -209,28 +202,33 @@ export function useAgentTools(options: {
         },
         required: ["text"],
       },
+      generateFriendlyName: (args) => {
+        const pos = args?.position ?? "end";
+        const posLabel: Record<string, string> = { start: "开头", end: "末尾", before: "前方", after: "后方" };
+        return `在${posLabel[pos] ?? pos}插入文本`;
+      },
       handler: (args) => {
         const text = requireNonEmptyText(args?.text, "text");
         const position = typeof args?.position === "string" ? args.position : "end";
-        let nextContent = currentContent;
+        let nextContent = options.content.value;
 
         if (position === "start") {
-          nextContent = `${text}${currentContent}`;
+          nextContent = `${text}${options.content.value}`;
         } else if (position === "end") {
-          nextContent = `${currentContent}${text}`;
+          nextContent = `${options.content.value}${text}`;
         } else {
           const anchor = requireNonEmptyText(args?.anchor, "anchor");
           const occurrence = typeof args?.occurrence === "number" ? args.occurrence : 1;
-          const index = findNthIndex(currentContent, anchor, occurrence);
+          const index = findNthIndex(options.content.value, anchor, occurrence);
           if (index < 0) {
             throw new Error("未找到锚点文本");
           }
           if (position === "before") {
-            nextContent = currentContent.slice(0, index) + text + currentContent.slice(index);
+            nextContent = options.content.value.slice(0, index) + text + options.content.value.slice(index);
           } else if (position === "after") {
             const afterIndex = index + anchor.length;
             nextContent =
-              currentContent.slice(0, afterIndex) + text + currentContent.slice(afterIndex);
+              options.content.value.slice(0, afterIndex) + text + options.content.value.slice(afterIndex);
           } else {
             throw new Error("position 参数不合法");
           }
@@ -262,23 +260,25 @@ export function useAgentTools(options: {
         },
         required: ["target"],
       },
+      generateFriendlyName: (args) =>
+        args?.deleteAll ? `全部删除:${args?.target ?? ""}` : `删除文本:${args?.target ?? ""}`,
       handler: (args) => {
         const target = requireNonEmptyText(args?.target, "target");
         const deleteAll = args?.deleteAll === true;
         if (deleteAll) {
-          if (!currentContent.includes(target)) {
+          if (!options.content.value.includes(target)) {
             throw new Error("未找到待删除文本");
           }
-          setContent(currentContent.split(target).join(""));
+          setContent(options.content.value.split(target).join(""));
           return buildResult({ changed: "content", mode: "deleteAll" });
         }
 
         const occurrence = typeof args?.occurrence === "number" ? args.occurrence : 1;
-        const index = findNthIndex(currentContent, target, occurrence);
+        const index = findNthIndex(options.content.value, target, occurrence);
         if (index < 0) {
           throw new Error("未找到指定位置的待删除文本");
         }
-        const updated = currentContent.slice(0, index) + currentContent.slice(index + target.length);
+        const updated = options.content.value.slice(0, index) + options.content.value.slice(index + target.length);
         setContent(updated);
         return buildResult({ changed: "content", mode: "deleteOne", occurrence });
       },
@@ -298,6 +298,7 @@ export function useAgentTools(options: {
         },
         required: ["images"],
       },
+      generateFriendlyName: (args) => `设置配图(${Array.isArray(args?.images) ? args.images.length : 0}张)`,
       handler: (args) => {
         if (!Array.isArray(args?.images)) {
           throw new Error("images 必须是字符串数组");

+ 28 - 23
src/pages/home/chat/dependent/post/publish.vue

@@ -17,14 +17,7 @@
           width="95%"
           :padding="[40, 30]"
         >
-          <Uploader 
-            ref="uploader"
-            listType="grid"
-            :maxUploadCount="9" 
-            :upload="uploadImage"
-            @updateList="onUpdateList"
-          />
-          <Field v-model="title" type="text" placeholder="输入标题(可选)" :maxLength="30" showWordLimit />
+          <Field v-model="title" type="text" placeholder="输入标题(可选)" :maxLength="30" />
           <Field 
             v-model="content" 
             type="text" 
@@ -37,6 +30,13 @@
             }"
             showWordLimit 
           />
+          <Uploader 
+            ref="uploader"
+            listType="grid"
+            :maxUploadCount="9" 
+            :upload="uploadImage"
+            @updateList="onUpdateList"
+          />
           <FlexRow justify="flex-end" align="center">
             <Tbutton 
               :title="title"
@@ -55,20 +55,16 @@
           />
         </FlexRow>
       </ProvideVar>
-      <Popup
-        v-model:show="showAgentPopup"
-        position="bottom"
-        :mask="false"
-        size="60vh"
-      >
-        <Agent 
-          v-model:title="title"
-          v-model:content="content"
-          v-model:images="images"
-          @upload="uploader?.pick()"
-          @close="showAgentPopup = false" 
-        />
-      </Popup>
+      <Agent 
+        v-model:showAgentPopup="showAgentPopup"
+        v-model:title="title"
+        v-model:content="content"
+        v-model:images="images"
+        :villageInfo="villageInfoForAI.content.value ?? undefined"
+        :tag="querys.tag"
+        @upload="uploader?.pick()"
+        @close="showAgentPopup = false" 
+      />
     </FlexCol>
   </CommonTopBanner>
 </template>
@@ -79,6 +75,7 @@ import { useLoadQuerys } from '@/components/composeabe/LoadQuerys';
 import { Debounce } from '@imengyu/imengyu-utils';
 import { confirm, toast } from '@/components/dialog/CommonRoot';
 import { useAuthStore } from '@/store/auth';
+import { useSimpleDataLoader } from '@/components/composeabe/loader/SimpleDataLoader';
 import { envVersion } from '@/common/config/AppCofig';
 import CommonContent from '@/api/CommonContent';
 import BackgroundBox from '@/components/display/block/BackgroundBox.vue';
@@ -93,12 +90,14 @@ import LightVillageApi, { PostMessage } from '@/api/light/LightVillageApi';
 import CommonTopBanner from '@/common/components/CommonTopBanner.vue';
 import PrimaryButton from '@/common/components/PrimaryButton.vue';
 import Tbutton from './components/tbutton.vue';
-import type { UploaderAction, UploaderItem } from '@/components/form/Uploader';
 import Height from '@/components/layout/space/Height.vue';
+import type { UploaderAction, UploaderItem } from '@/components/form/Uploader';
 
 const { querys } = useLoadQuerys({
   tag: '',
   villageId: 0,
+}, () => {
+  villageInfoForAI.load();
 });
 const authStore = useAuthStore();
 
@@ -121,6 +120,12 @@ const saveDraftDebunce = new Debounce(1000, () => {
     });
   }
 });
+const villageInfoForAI = useSimpleDataLoader(async () => {
+  const villageId = querys.value.villageId;
+  if (villageId) 
+    return await LightVillageApi.getVillageDetails(villageId);
+  return null;
+}, false);
 
 watch(title, () => saveDraftDebunce.executeWithDelay());
 watch(content, () => saveDraftDebunce.executeWithDelay());

+ 2 - 2
src/pages/home/dig.vue

@@ -2,9 +2,9 @@
   <FlexCol padding="padding.md">
     <HomeLargeTitle title="挖掘" />
     <ImageSwiper
-      :images="appConfiguration?.banners.dig"
+      :images="appConfiguration?.banners.dig.images"
+      :height="appConfiguration?.banners.dig.height"
       width="100%"
-      :height="400"
       radius="radius.md"
       :innerStyle="{
         border: '1px solid #fff',

+ 2 - 2
src/pages/home/discover/index.vue

@@ -2,9 +2,9 @@
   <FlexCol :gap="20" :padding="30">
     <HomeLargeTitle title="发现" />
     <ImageSwiper
-      :images="appConfiguration?.banners.discover"
       width="100%"
-      :height="400"
+      :height="appConfiguration?.banners.discover.height"
+      :images="appConfiguration?.banners.discover.images"
       radius="radius.md"
       :innerStyle="{
         border: '1px solid #fff',

+ 2 - 2
src/pages/home/index.vue

@@ -44,8 +44,8 @@
           <!-- <Button icon="ai-thinking" @click="handleGoAI" text="问AI" /> -->
         </FlexRow>
         <ImageSwiper 
-          :images="appConfiguration?.banners.home"
-          :height="460"
+          :images="appConfiguration?.banners.home.images"
+          :height="appConfiguration?.banners.home.height"
           :width="700"
           radius="radius.md"
           :innerStyle="{