快乐的梦鱼 2 hafta önce
ebeveyn
işleme
51458c8c1a

+ 33 - 4
package-lock.json

@@ -24,11 +24,13 @@
         "@dcloudio/uni-mp-weixin": "3.0.0-4070620250821001",
         "@dcloudio/uni-mp-xhs": "3.0.0-4070620250821001",
         "@dcloudio/uni-quickapp-webview": "3.0.0-4070620250821001",
-        "@imengyu/imengyu-utils": "^0.0.27",
+        "@imengyu/imengyu-utils": "^0.0.28",
         "@imengyu/js-request-transform": "^0.4.0",
         "async-validator": "^4.2.5",
         "crypto-js": "^4.2.0",
+        "openai": "^6.35.0",
         "pinia": "^3.0.1",
+        "sse.js": "^2.8.0",
         "tslib": "^2.8.1",
         "vue": "3.5.22",
         "vue-i18n": "9.14.5",
@@ -6031,9 +6033,9 @@
       }
     },
     "node_modules/@imengyu/imengyu-utils": {
-      "version": "0.0.27",
-      "resolved": "https://registry.npmmirror.com/@imengyu/imengyu-utils/-/imengyu-utils-0.0.27.tgz",
-      "integrity": "sha512-gDfOtmHyQ1ExgEe9vaEpTNsvMK/NZEnDDRas6evejPAIH7Qhu6+435Or/9jwsjbIB/vEVPHYUBl8mMqqqyLAKQ==",
+      "version": "0.0.28",
+      "resolved": "https://registry.npmmirror.com/@imengyu/imengyu-utils/-/imengyu-utils-0.0.28.tgz",
+      "integrity": "sha512-DE0hrBJjdAsyiNJER/ZS8cuEl9+nWZtyUiJBKFimxuVLLiBtPZC2PQRcrHBDdyhxeQHUhfjCvqRbbFjDY6jloQ==",
       "license": "MIT",
       "dependencies": {
         "@imengyu/js-request-transform": "^0.4.0"
@@ -12416,6 +12418,27 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/openai": {
+      "version": "6.35.0",
+      "resolved": "https://registry.npmmirror.com/openai/-/openai-6.35.0.tgz",
+      "integrity": "sha512-L/skwIGnt5xQZHb0UfTu9uAUKbis3ehKypOuJKi20QvG7UStV6C8IC3myGYHcdiF4kms/bAvOJ9UqqNWqi8x/Q==",
+      "license": "Apache-2.0",
+      "bin": {
+        "openai": "bin/cli"
+      },
+      "peerDependencies": {
+        "ws": "^8.18.0",
+        "zod": "^3.25 || ^4.0"
+      },
+      "peerDependenciesMeta": {
+        "ws": {
+          "optional": true
+        },
+        "zod": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/os-locale-s-fix": {
       "version": "1.0.8-fix-1",
       "resolved": "https://registry.npmmirror.com/os-locale-s-fix/-/os-locale-s-fix-1.0.8-fix-1.tgz",
@@ -13890,6 +13913,12 @@
       "license": "BSD-3-Clause",
       "peer": true
     },
+    "node_modules/sse.js": {
+      "version": "2.8.0",
+      "resolved": "https://registry.npmmirror.com/sse.js/-/sse.js-2.8.0.tgz",
+      "integrity": "sha512-35RyyFYpzzHZgMw9D5GxwADbL6gnntSwW/rKXcuIy1KkYCPjW6oia0moNdNRhs34oVHU1Sjgovj3l7uIEZjrKA==",
+      "license": "Apache-2.0"
+    },
     "node_modules/stack-utils": {
       "version": "2.0.6",
       "resolved": "https://registry.npmmirror.com/stack-utils/-/stack-utils-2.0.6.tgz",

+ 3 - 1
package.json

@@ -51,11 +51,13 @@
     "@dcloudio/uni-mp-weixin": "3.0.0-4070620250821001",
     "@dcloudio/uni-mp-xhs": "3.0.0-4070620250821001",
     "@dcloudio/uni-quickapp-webview": "3.0.0-4070620250821001",
-    "@imengyu/imengyu-utils": "^0.0.27",
+    "@imengyu/imengyu-utils": "^0.0.28",
     "@imengyu/js-request-transform": "^0.4.0",
     "async-validator": "^4.2.5",
     "crypto-js": "^4.2.0",
+    "openai": "^6.35.0",
     "pinia": "^3.0.1",
+    "sse.js": "^2.8.0",
     "tslib": "^2.8.1",
     "vue": "3.5.22",
     "vue-i18n": "9.14.5",

+ 1 - 0
src/App.vue

@@ -85,6 +85,7 @@ IconUtils.loadDefaultIcons('https://mncdn.wenlvti.net/app_static/xiangyuan/data/
 <style lang="scss">
 	/*每个页面公共css */
   @import "@/components/index.scss";
+  @import "@/common/style/leagicy.scss";
 
   page {
     background: #fef2e8;

+ 23 - 3
src/api/RequestModules.ts

@@ -8,7 +8,7 @@ import ApiCofig from "@/common/config/ApiCofig";
 import { isDev } from "../common/config/AppCofig";
 import { BaseAppServerRequestModule } from "./BaseAppServerRequestModule";
 import type { DataModel, NewDataModel } from "@imengyu/js-request-transform";
-import { appendGetUrlParams, defaultResponseDataHandlerCatch, RequestApiConfig, RequestApiError, RequestApiResult, RequestCoreInstance, RequestOptions, RequestResponse, type RequestApiInfoStruct } from "@imengyu/imengyu-utils";
+import { appendGetUrlParams, appendPostParams, defaultResponseDataHandlerCatch, RandomUtils, RequestApiConfig, RequestApiError, RequestApiResult, RequestCoreInstance, RequestOptions, RequestResponse, type RequestApiInfoStruct } from "@imengyu/imengyu-utils";
 
 
 /**
@@ -23,11 +23,17 @@ export class AppServerRequestModule<T extends DataModel> extends BaseAppServerRe
 /**
  * 地图服务请求模块
  */
-export class MapServerRequestModule<T extends DataModel> extends BaseAppServerRequestModule<T> {
+export class MengyuServerRequestModule<T extends DataModel> extends BaseAppServerRequestModule<T> {
   constructor() {
     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());
+      }
       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>> {
@@ -84,12 +90,26 @@ export class MapServerRequestModule<T extends DataModel> extends BaseAppServerRe
       }
     };
   }
+
+  private static readonly DEVICE_UID_KEY = 'deviceUid';
+  private uid = '';
+
+  getDeviceUid() {
+    if (!this.uid) {
+      this.uid = uni.getStorageSync(MengyuServerRequestModule.DEVICE_UID_KEY);
+      if (!this.uid) {
+        this.uid = RandomUtils.genNonDuplicateIDHEX(10);
+        uni.setStorageSync(MengyuServerRequestModule.DEVICE_UID_KEY, this.uid);
+      }
+    }
+    return this.uid;
+  }
 }
 
 /**
  * 腾讯地图服务请求模块
  */
-export class TencentMapServerRequestModule<T extends DataModel> extends BaseAppServerRequestModule<T> {
+export class TencentMengyuServerRequestModule<T extends DataModel> extends BaseAppServerRequestModule<T> {
   constructor() {
     super("https://apis.map.qq.com");
     this.config.requestInterceptor = (url, req) => {

+ 431 - 0
src/api/agent/Agent.ts

@@ -0,0 +1,431 @@
+import { DataModel, type KeyValue } from "@imengyu/js-request-transform";
+import { appendGetUrlParams, assertNotNull, RandomUtils } from "@imengyu/imengyu-utils";
+import { useAuthStore } from "@/store/auth";
+import { RestfulApi } from "../restful";
+import { SSE } from "sse.js";
+import OpenAI from "openai";
+import { CommonPageResult } from "@/api/restful/types";
+import { AgentChatFile } from "./AgentChatFile";
+
+/**
+ * Agent 占位模型(用于复用 RestfulApi 的通用请求能力)
+ */
+export class AgentChatDummy extends DataModel<AgentChatDummy> {
+  constructor() {
+    super(AgentChatDummy, "AI对话");
+  }
+}
+
+/**
+ * AI 聊天会话
+ */
+export class AgentChatHistory extends DataModel<AgentChatHistory> {
+  constructor() {
+    super(AgentChatHistory, "AI聊天会话");
+    this._convertTable = {
+      createdAt: { clientSide: "date" },
+    };
+  }
+
+  id = 0;
+  /**
+   * 用户ID
+   */
+  userId = 0;
+  /**
+   * 描述
+   */
+  desc = "";
+  /**
+   * 摘要
+   */
+  summary = "";
+  /**
+   * 长时记忆
+   */
+  longMemory = "";
+  /**
+   * 短时记忆
+   */
+  shortMemory = "";
+
+  createdAt = new Date();
+}
+
+/**
+ * AI 聊天会话项
+ */
+export class AgentChatHistoryItem extends DataModel<AgentChatHistoryItem> {
+  constructor() {
+    super(AgentChatHistoryItem, "AI聊天记录项");
+    this._convertTable = {
+      files: { clientSide: "array", clientSideChildDataModel: AgentChatFile, serverSide: 'undefined' },
+      date: { clientSide: "date" },
+    };
+    this._blackList.toServer.push('userId','files');
+  }
+
+  id = 0;
+  userId = 0;
+  /**
+   * 会话ID
+   */
+  historyId = 0;
+  /**
+   * 回复ID,针对用户消息
+   */
+  replyItemId = 0;
+  /**
+   * 父级ID,针对非用户消息,链接至用户消息
+   */
+  parentId = 0;
+  /**
+   * 额外信息
+   */
+  extra = "";
+  /**
+   * 状态
+   */
+  state = '';
+  /**
+   * 思考内容
+   */
+  reasoningContent = '';
+  /**
+   * 思考时间
+   */
+  reasoningTime = 0;
+  /**
+   * 回复时间
+   */
+  replyTime = 0;
+  /** 
+   * 回复AI名称
+   */
+  name = "";
+  /**
+   * 附件列表
+   */
+  files?: AgentChatFile[];
+  date = new Date();
+  role: "user" | "assistant" | "system" | "tool" | "" = "user";
+  content = "";
+  type = '';
+}
+
+/**
+ * AI 聊天记录分组
+ */
+export class AgentChatHistoryGroup extends DataModel<AgentChatHistoryGroup> {
+  constructor() {
+    super(AgentChatHistoryGroup, "AI聊天记录分组");
+  }
+
+  id = 0;
+  userId = 0;
+  name = "";
+  customPropmt = "";
+  icon = "";
+}
+
+/**
+ * SSE 推送消息结构(后端 chat-stream 写入的 data JSON)
+ */
+export type AgentChatStreamMessage =
+  | { content: string }
+  | { finished: true }
+  | { error: string };
+
+export type AgentChatModel = {
+  /**
+   * 模型名称
+   */
+  name: string;
+  /**
+   * 模型值
+   */
+  value: string;
+  /**
+   * 模型最大Tokens数
+   */
+  max_tokens: number;
+  /**
+   * 模型是否支持思考
+   * @default false
+   */
+  can_think?: boolean;
+  /**
+   * 模型是否支持视觉
+   * @default false
+   */
+  can_vl?: boolean;
+  /**
+   * 模型是否支持搜索
+   * @default false
+   */
+  can_search?: boolean; 
+  /**
+   * 模型是否支持解析文档
+   * @default false
+   */
+  can_prase_document?: boolean;
+  /**
+   * 模型是否支持读取音频
+   * @default false
+   */
+  can_read_audio?: boolean;
+  /**
+   * 模型是否支持读取视频
+   * @default false
+   */
+  can_read_video?: boolean;
+  /**
+   * 文档传输类型,用于指定文档在系统提示词中还是消息内容中
+   * @default "in_system_prompt"
+   */
+  docunment_transfer_type?: "in_system_prompt" | "in_message_content";
+  /**
+   * 文档传输关键字,用于指定文档在系统提示词中还是消息内容中
+   * @default ""
+   */
+  docunment_transfer_key?: string;
+};
+
+export class AgentApi extends RestfulApi<AgentChatDummy> {
+  constructor() {
+    super("content/agent", AgentChatDummy);
+  }
+
+  /**
+   * 获取模型列表
+   * 后端接口:GET /content/agent/model-list
+   */
+  getModelList() {
+    return this.commonRquest<Record<string, AgentChatModel[]>>(
+      "GET",
+      `/${this.subResKey}/model-list`,
+      "获取模型列表",
+    );
+  }
+  /**
+   * 非流式对话
+   * 后端接口:POST /content/agent/chat,body: { options }
+   */
+  chat(options: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming) {
+    return this.commonRquest<OpenAI.Chat.Completions.ChatCompletion>(
+      "POST",
+      `/${this.subResKey}/chat`,
+      "AI对话(非流式)",
+      { options },
+    );
+  }
+
+  /**
+   * 流式对话(SSE)
+   * 后端接口:POST /content/agent/chat-stream,body: { options }
+   *
+   * 注意:此接口返回 text/event-stream,不走项目内通用的 JSON 包装协议。
+   */
+  chatStream(options: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming): SSE {
+    const authStore = useAuthStore();
+    const devUid = this.getDeviceUid();
+
+    const url = appendGetUrlParams(
+      `${this.config.baseUrl}/content/agent/chat-stream`,
+      "identifier",
+      devUid,
+    );
+
+    return new SSE(url, {
+      headers: { 
+        'Content-Type': 'application/json' ,
+        token: authStore.token,
+      },
+      method: 'POST',
+      payload: JSON.stringify({ options }),
+      withCredentials: false
+    });
+  }
+
+  /**
+   * 获取聊天会话分页(仅当前用户)
+   * 后端接口:GET /content/agent/chat-history?page=&size=&search=
+   */
+  getChatHistory(page = 1, size = 10, search = "", groupId?: number) {
+    return this.get<{
+      allCount?: number;
+      allPage?: number;
+      items?: KeyValue[] | null;
+    }>(
+      "/content/agent/chat-history",
+      "获取聊天会话分页列表",
+      { page, size, search, groupId },
+    ).then((rs) => {
+      assertNotNull(rs.data, "rs.data is null!");
+      return rs.cloneWithOtherData(
+        new CommonPageResult<AgentChatHistory>().fromData(rs.data, page, size, AgentChatHistory),
+      );
+    });
+  }
+
+  /**
+   * 获取聊天会话项分页(仅当前用户)
+   * 后端接口:GET /content/agent/chat-history-items?page=&size=&id=
+   */
+  getChatHistoryItems(page = 1, size = 10, id = 0) {
+    return this.get<{
+      allCount?: number;
+      allPage?: number;
+      items?: KeyValue[] | null;
+    }>(
+      "/content/agent/chat-history-items",
+      "获取聊天会话项分页列表",
+      { 
+        page, size, id, 
+        sort: {
+          field: 'date',
+          order: 'asc',
+        }
+      },
+    ).then((rs) => {
+      assertNotNull(rs.data, "rs.data is null!");
+      return rs.cloneWithOtherData(
+        new CommonPageResult<AgentChatHistoryItem>().fromData(rs.data, page, size, AgentChatHistoryItem),
+      );
+    });
+  }
+
+  /**
+   * 获取聊天记录分组分页(仅当前用户)
+   * 后端接口:GET /content/agent/chat-history-groups?page=&size=
+   */
+  getChatHistoryGroups(page = 1, size = 10) {
+    return this.get<{
+      allCount?: number;
+      allPage?: number;
+      items?: KeyValue[] | null;
+    }>(
+      "/content/agent/chat-history-groups",
+      "获取聊天记录分组分页列表",
+      { page, size },
+    ).then((rs) => {
+      assertNotNull(rs.data, "rs.data is null!");
+      return rs.cloneWithOtherData(
+        new CommonPageResult<AgentChatHistoryGroup>().fromData(rs.data, page, size, AgentChatHistoryGroup),
+      );
+    });
+  }
+
+  /**
+   * 创建聊天记录分组
+   * 后端接口:POST /content/agent/chat-history-groups,body: { name, customPropmt, icon }
+   */
+  createChatHistoryGroup(name: string, customPropmt = "", icon = "") {
+    return this.post<number>(
+      "/content/agent/chat-history-groups",
+      "创建聊天记录分组",
+      { name, customPropmt, icon },
+    );
+  }
+
+  /**
+   * 更新聊天记录分组
+   * 后端接口:PUT /content/agent/chat-history-groups/:id,body: { name, customPropmt, icon }
+   */
+  updateChatHistoryGroup(id: number, name: string, customPropmt = "", icon = "") {
+    return this.put<unknown>(
+      `/content/agent/chat-history-groups/${id}`,
+      "更新聊天记录分组",
+      { name, customPropmt, icon },
+    );
+  }
+
+  /**
+   * 删除聊天记录分组
+   * 后端接口:DELETE /content/agent/chat-history-groups/:id
+   */
+  deleteChatHistoryGroup(id: number) {
+    return this.delete<unknown>(
+      `/content/agent/chat-history-groups/${id}`,
+      "删除聊天记录分组",
+    );
+  }
+
+  /**
+   * 创建我的聊天会话
+   * 后端接口:POST /content/agent/chat-history,body: { desc }
+   */
+  createChatHistory(desc: string, groupId?: number) {
+    return this.post<number>(
+      "/content/agent/chat-history",
+      "创建聊天会话",
+      { desc, groupId },
+    );
+  }
+
+  /**
+   * 更新我的聊天会话
+   * 后端接口:PUT /content/agent/chat-history/:id,body: { data }
+   */
+  updateChatHistory(id: number, data: AgentChatHistory) {
+    return this.put<unknown>(
+      `/content/agent/chat-history/${id}`,
+      "更新聊天会话",
+      { data: data.toServerSide() },
+    );
+  }
+  /**
+   * 更新聊天记录项
+   * 后端接口:PUT /content/agent/chat-history-items/:id,body: { data }
+   */
+  updateChatHistoryItem(id: number, data: AgentChatHistoryItem) {
+    return this.put<unknown>(
+      `/content/agent/chat-history-items/${id}`,
+      "更新聊天记录项",
+      { data: data.toServerSide() },
+    );
+  }
+
+  /**
+   * 删除我的聊天会话
+   * 后端接口:DELETE /content/agent/chat-history/:id
+   * @param id 聊天会话ID
+   * @returns 删除结果
+   */
+  deleteChatHistory(id: number) {
+    return this.delete<unknown>(
+      `/content/agent/chat-history/${id}`,
+      "删除聊天会话",
+    );
+  }
+
+  /**
+   * 向我的聊天会话添加/更新聊天记录项
+   * 后端接口:POST /content/agent/chat-history-items/:historyId,body: { datas }
+   */
+  addOrUpdateChatHistoryItems(historyId: number, datas: AgentChatHistoryItem[], parentId?: number) {
+    return this.post<number[]>(
+      `/content/agent/chat-history-items/${historyId}`,
+      "添加聊天记录项",
+      { 
+        datas: datas.map((k) => k.toServerSide()),
+        parentId,
+      },
+    );
+  }
+
+  /**
+   * 删除我的聊天会话的聊天记录项
+   * 后端接口:POST /content/agent/chat-history-items/:historyId/delete,body: { ids }
+   */
+  deleteChatHistoryItems(historyId: number, ids: number[]) {
+    return this.post<unknown>(
+      `/content/agent/chat-history-items/${historyId}/delete`,
+      "删除聊天记录项",
+      { ids },
+    );
+  }
+}
+
+export default new AgentApi();
+

+ 90 - 0
src/api/agent/AgentChatFile.ts

@@ -0,0 +1,90 @@
+import { DataModel } from "@imengyu/js-request-transform";
+import { RestfulApi } from "../restful";
+
+/**
+ * AI 对话附件(临时文件)
+ */
+export class AgentChatFile extends DataModel<AgentChatFile> {
+  constructor() {
+    super(AgentChatFile, "AI对话附件");
+    this._convertTable = {
+      createdAt: { clientSide: "date" },
+      updatedAt: { clientSide: "date" },
+      deletedAt: { clientSide: "date" },
+    };
+  }
+
+  id = 0;
+  userId = 0;
+  historyItemId = 0;
+  path = "";
+  url = "";
+  aiFileId = '';
+
+  createdAt = new Date();
+  updatedAt = new Date();
+  deletedAt = new Date();
+}
+
+export class AgentChatFileApi extends RestfulApi<AgentChatFile> {
+  constructor() {
+    super("content/agent/chat-file", AgentChatFile);
+  }
+
+  /**
+   * 上传对话附件(FormData)。临时文件,不持久化保存。
+   * 后端:POST /content/agent/chat-file/upload
+   */
+  upload(file: File | Blob) {
+    const formData = new FormData();
+    formData.append("file", file);
+    return this.post<{
+      path: string;
+      url: string;
+      id: number;
+    }>(
+      `/${this.subResKey}/upload`,
+      "上传AI对话附件",
+      formData,
+    );
+  }
+
+  /**
+   * 将聊天附件关联到聊天记录项
+   * 后端:POST /content/agent/chat-file/link-to-history-item
+   */
+  linkToHistoryItem(historyItemId: number, fileId: number) {
+    return this.post<void>(
+      `/${this.subResKey}/link-to-history-item`,
+      "将聊天附件关联到聊天记录项",
+      { historyItemId, fileId },
+    );
+  }
+
+  /**
+   * 删除对话附件(临时文件)
+   * 后端:DELETE /content/agent/chat-file/delete
+   */
+  deleteFile(path: string) {
+    return this.delete<void>(
+      `/${this.subResKey}/delete`,
+      "删除AI对话附件",
+      { path },
+    );
+  }
+
+  /**
+   * 将对话附件转为对应AI系统可用的文件
+   * 后端:POST /content/agent/chat-file/process-to-ai
+   */
+  processChatFileToAi(id: number, modelName: string) {
+    return this.post<string>(
+      `/${this.subResKey}/process-to-ai`,
+      "将对话附件转为对应AI系统可用的文件",
+      { id, modelName },
+    );
+  }
+}
+
+export default new AgentChatFileApi();
+

+ 306 - 0
src/api/agent/AgentWorks.ts

@@ -0,0 +1,306 @@
+import { DataModel } from "@imengyu/js-request-transform";
+import { RestfulApi } from "../restful";
+import AgentApi from "./Agent";
+
+export class AgentWorkApi extends RestfulApi<DataModel> {
+  constructor() {
+    super("content/agent", DataModel);
+  }
+
+  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 }>();
+
+  private extractJsonObject<T>(content: string, fallback: T): T {
+    try {
+      return JSON.parse(content) as T;
+    } catch {
+      const start = content.indexOf('{');
+      const end = content.lastIndexOf('}');
+      if (start >= 0 && end > start) {
+        try {
+          return JSON.parse(content.slice(start, end + 1)) as T;
+        } catch {
+          return fallback;
+        }
+      }
+      return fallback;
+    }
+  }
+
+  private getQuestionRelatedCacheKey(thisQuestion: string, prevQuestions: string[], prevAnswers: string[]) {
+    const normalize = (text: string) => text.trim().replace(/\s+/g, " ").slice(0, 240);
+    return JSON.stringify({
+      q: normalize(thisQuestion),
+      prevQ: prevQuestions.map(normalize),
+      prevA: prevAnswers.map(normalize),
+    });
+  }
+  private getQuestionRelatedFromCache(cacheKey: string): boolean | undefined {
+    const hit = this.questionRelatedCache.get(cacheKey);
+    if (!hit)
+      return undefined;
+    if (Date.now() > hit.expireAt) {
+      this.questionRelatedCache.delete(cacheKey);
+      return undefined;
+    }
+    return hit.value;
+  }
+  private setQuestionRelatedCache(cacheKey: string, value: boolean) {
+    const now = Date.now();
+    this.questionRelatedCache.set(cacheKey, {
+      value,
+      expireAt: now + AgentWorkApi.QUESTION_RELATED_CACHE_TTL_MS,
+    });
+    if (this.questionRelatedCache.size <= AgentWorkApi.QUESTION_RELATED_CACHE_MAX_SIZE)
+      return;
+
+    for (const [key, item] of this.questionRelatedCache.entries()) {
+      if (item.expireAt <= now)
+        this.questionRelatedCache.delete(key);
+    }
+    while (this.questionRelatedCache.size > AgentWorkApi.QUESTION_RELATED_CACHE_MAX_SIZE) {
+      const oldestKey = this.questionRelatedCache.keys().next().value as string | undefined;
+      if (!oldestKey)
+        break;
+      this.questionRelatedCache.delete(oldestKey);
+    }
+  }
+
+
+  /**
+   * 调用模型,获取用户问题的一个简要概览
+   * @param question 
+   * @returns 简要概览,如果获取失败,则返回用户问题的前24个字符
+   */
+  async getQuestionOverview(question: string) {
+    try {
+      const result = await AgentApi.chat({
+        model: "qwen-flash",
+        messages: [
+          { role: "system", content: `你是一个专业的概览生成器,请根据用户的问题生成一个简要概览,
+            注意,你只对用户输入进行简要概括,不要回答问题。输出语言与用户输入语言一致。
+            30字以内,输出格式为 JSON,结构为 { "overview": "简要概览" }
+            示例:
+            用户输入:我想学习如何使用Excel进行数据分析,请帮帮我,最好有详细的步骤说明
+            模型输出:{ "overview": "如何使用Excel进行数据分析" }
+            ` },
+          { role: "user", content: question.slice(0, 240) },
+        ],
+      });
+      const content = result.requireData().choices[0].message.content;
+      const overview = this.extractJsonObject<{ overview: string }>(content ?? '{}', { overview: '' }).overview;
+      return overview || question.slice(0, 24);
+    } catch (error) {
+      return question.slice(0, 24);
+    }
+  }
+
+  /**
+   * 获取用户内容的标题
+   * @param userContent 用户内容
+   * @returns 标题
+   */
+  async getTitle(userContent: string) {
+    try {
+      const result = await AgentApi.chat({
+        model: "qwen-flash",
+        messages: [
+          { role: "system", content: `你是一个专业的标题生成器,请根据内容概括一个标题,
+            输出格式为 JSON,结构为 { "title": "标题" }`
+          },
+          { role: "user", content: userContent.slice(0, 240) },
+        ],
+      });
+      const content = result.requireData().choices[0].message.content;
+      const title = (this.extractJsonObject<{ title: string }>(content ?? '{}', { title: '' })).title;
+      return title || userContent.slice(0, 24);
+    } catch (error) {
+      console.error(error);
+      return userContent.slice(0, 24);
+    }
+  }
+
+  /**
+   * 自动生成可能的问题
+   * @param question 
+   * @returns 可能的问题
+   */
+  async autoGeneratePossibleQuestions(question: string, aiAnswer: string) {
+    try {
+      const result = await AgentApi.chat({
+        model: "qwen-flash",
+        messages: [
+          { role: "system", content: `你是一个专业的问题联想生成器,请根据用户的问题和AI回答生成2~4个用户可能感兴趣的问题,
+            输出格式为 JSON,结构为 ["可能的问题1", "可能的问题2", "可能的问题3"]
+            示例:
+            用户输入:占道经营是什么
+            AI回答:占道经营是指在未获得合法审批的情况下,占用城市道路、桥梁、广场等公共场所进行营利性买卖商品或提供服务的行为。这是一个复杂的社会现象,既关乎城市的“面子”——市容与秩序,也关乎民生的“里子”——生计与便利。
+            模型输出:["有哪些城市成功治理占道经营的案例", "占道经营治理中有哪些常见执法难点", "如何平衡占道经营与城市交通秩序"]
+            ` },
+          { role: "user", content: question.slice(0, 1000) },
+          { role: "assistant", content: aiAnswer.slice(0, 1000) },
+        ],
+      });
+      const content = result.requireData().choices[0].message.content;
+      return (JSON.parse(content ?? '[]') as string[]);
+    } catch (error) {
+      return [];
+    }
+  }
+
+  /**
+   * 检查问题是否相关
+   * @param thisQuestion 
+   * @param prevQuestions 
+   * @param prevAnswers 
+   */
+  async checkQuestionRelated(thisQuestion: string, prevQuestions: string[], prevAnswers: string[]) {
+    const currentQuestion = thisQuestion.trim();
+    if (!currentQuestion)
+      return false;
+    if ((prevQuestions?.length || 0) === 0 && (prevAnswers?.length || 0) === 0)
+      return false;
+    const cacheKey = this.getQuestionRelatedCacheKey(currentQuestion, prevQuestions, prevAnswers);
+    const cached = this.getQuestionRelatedFromCache(cacheKey);
+    if (typeof cached === "boolean")
+      return cached;
+
+    try {
+      const historyPairs = Array.from({
+        length: Math.max(prevQuestions.length, prevAnswers.length),
+      }).map((_, index) => ({
+        q: prevQuestions[index]?.slice(0, 500) || '',
+        a: prevAnswers[index]?.slice(0, 500) || '',
+      }));
+      const result = await AgentApi.chat({
+        model: "qwen-flash",
+        messages: [
+          {
+            role: "system",
+            content: `你是一个对话相关性分类器。请根据“当前问题”和“历史问答”判断是否相关。
+返回 JSON:{ "related": true|false, "confidence": 0~1, "reason": "简短原因" }。
+判定标准:
+1) 主题、实体、任务目标连续,算 related=true;
+2) 完全换话题、换任务目标,算 related=false;
+3) 不确定时倾向 false。`,
+          },
+          {
+            role: "user",
+            content: JSON.stringify({
+              currentQuestion,
+              history: historyPairs,
+            }),
+          },
+        ],
+      });
+
+      const content = result.requireData().choices[0].message.content || "{}";
+      const parsed = this.extractJsonObject<{ related?: boolean; confidence?: number }>(content, 
+        { related: false, confidence: 0 }
+      );
+      const finalRelated =
+        typeof parsed.related === "boolean"
+          ? parsed.related
+          : (typeof parsed.confidence === "number" ? parsed.confidence >= 0.65 : false);
+      this.setQuestionRelatedCache(cacheKey, finalRelated);
+      return finalRelated;
+    } catch (error) {
+      this.setQuestionRelatedCache(cacheKey, false);
+      return false;
+    }
+  }
+
+  /**
+   * 生成会话阶段摘要(中期记忆)
+   */
+  async summarizeConversationStage(historyLines: string[], prevSummary = ""): Promise<string> {
+    const historyText = historyLines.map((line) => line.trim()).filter(Boolean).join("\n");
+    if (!historyText)
+      return prevSummary;
+    try {
+      const result = await AgentApi.chat({
+        model: "qwen-flash",
+        messages: [
+          {
+            role: "system",
+            content: `你是会话记忆压缩器。请将“历史对话”压缩为可供后续对话参考的中期摘要。
+要求:
+1) 保留关键目标、结论、约束、未完成事项;
+2) 删除寒暄与重复信息;
+3) 摘要尽量精炼(建议 8~12 条);
+4) 返回 JSON:{ "summary": "多行文本" }。`,
+          },
+          {
+            role: "user",
+            content: JSON.stringify({
+              previousSummary: prevSummary || "",
+              historyText,
+            }),
+          },
+        ],
+      });
+      const content = result.requireData().choices[0].message.content || "{}";
+      const parsed = this.extractJsonObject<{ summary?: string }>(content, {});
+      return (parsed.summary || prevSummary || "").trim();
+    } catch {
+      return prevSummary;
+    }
+  }
+
+  /**
+   * 抽取长期记忆(稳定偏好/身份/长期约束)
+   */
+  async extractLongTermMemory(historyLines: string[], existingLongMemory = "", maxItems = 12): Promise<string> {
+    const historyText = historyLines.map((line) => line.trim()).filter(Boolean).join("\n");
+    if (!historyText)
+      return existingLongMemory;
+    try {
+      const result = await AgentApi.chat({
+        model: "qwen-flash",
+        messages: [
+          {
+            role: "system",
+            content: `你是长期记忆抽取器。请从对话中抽取“长期稳定且值得记住”的信息:
+- 用户身份/背景(稳定)
+- 长期偏好、禁忌、风格要求
+- 长期目标与硬约束
+- 联系方式等高价值事实
+不要输出瞬时任务细节。
+返回 JSON:{ "items": ["记忆1", "记忆2"] }。
+如无新增重要信息可返回空数组。`,
+          },
+          {
+            role: "user",
+            content: JSON.stringify({
+              existingLongMemory,
+              historyText,
+              maxItems,
+            }),
+          },
+        ],
+      });
+      const content = result.requireData().choices[0].message.content || "{}";
+      const parsed = this.extractJsonObject<{ items?: string[] }>(content, {});
+      const current = existingLongMemory
+        .split("\n")
+        .map((line) => line.trim().replace(/^- /, ""))
+        .filter(Boolean);
+      const merged = new Set<string>(current);
+      for (const item of parsed.items || []) {
+        const text = String(item || "").trim();
+        if (text)
+          merged.add(text);
+      }
+      return Array.from(merged)
+        .slice(0, Math.max(1, maxItems))
+        .map((item) => `- ${item}`)
+        .join("\n");
+    } catch {
+      return existingLongMemory;
+    }
+  }
+}
+
+export default new AgentWorkApi();
+

+ 4 - 0
src/api/light/LightVillageApi.ts

@@ -218,6 +218,10 @@ export class LightVillageApi extends AppServerRequestModule<DataModel> {
     });
     return transformDataModel<PostMessage>(PostMessage, res.requireData());
   }
+
+  async publishMessage(params: PostMessage) {
+    return await this.post<KeyValue>('/village/collect/wechatContentSave', '发布微信贴图', params.toServerSide());
+  }
 }
 
 

+ 2 - 2
src/api/map/MapApi.ts

@@ -1,5 +1,5 @@
 import { DataModel } from '@imengyu/js-request-transform';
-import { MapServerRequestModule } from '../RequestModules';
+import { MengyuServerRequestModule } from '../RequestModules';
 
 export interface CityItem {
   first_letter: string;
@@ -35,7 +35,7 @@ export class MapCityData extends DataModel<MapCityData> {
   level = '' as MapCityDataLevel;
 }
 
-export class MapApi extends MapServerRequestModule<MapCityData> {
+export class MapApi extends MengyuServerRequestModule<MapCityData> {
 
   /**
    * 快速获取地区编码

+ 2 - 2
src/api/map/TenMapApi.ts

@@ -1,7 +1,7 @@
 import type { DataModel } from "@imengyu/js-request-transform";
-import { TencentMapServerRequestModule } from "../RequestModules";
+import { TencentMengyuServerRequestModule } from "../RequestModules";
 
-export class TenMapApi extends TencentMapServerRequestModule<DataModel> {
+export class TenMapApi extends TencentMengyuServerRequestModule<DataModel> {
   constructor() {
     super();
   }

+ 380 - 0
src/api/restful/index.ts

@@ -0,0 +1,380 @@
+/**
+ * RESTFUL API 包装类
+ * 
+ * 功能介绍:
+ *    为 RESTful 的 API 提供了一套快速使用的请求方法。
+ * 
+ * Author: imengyu
+ * Date: 2020/09/12
+ * 
+ * Copyright (c) 2021 imengyu.top. Licensed under the MIT License. 
+ * See License.txt in the project root for license information.
+ */
+
+import { DataModel, type KeyValue } from '@imengyu/js-request-transform';
+import { ArrayUtils, assertNotNull, RequestApiConfig, RequestApiResult, StringUtils, type TypeAll } from '@imengyu/imengyu-utils';
+import { CommonArrayResult, CommonListResult, CommonPageResult } from './types';
+import { MengyuServerRequestModule } from '../RequestModules';
+
+/**
+ * 可搜索类型定义
+ */
+export type SearchableType = string|number|boolean|Array<string>|Array<number>|Array<Date>|null|bigint; 
+
+/**
+ * 搜索参数
+ */
+export interface SearchArrayParams {
+  /**
+   * Key为搜索的字段(用户可能输入多个字段搜索,此字段可能为多个)
+   * 值是用户输入的。如果为Array,则为数组
+   */
+  [index: string]: SearchableType|{
+    fuzzy?: boolean,
+    range?: boolean,
+    value: string|string[]
+  };
+}
+
+/**
+ * 搜索参数
+ */
+export interface SearchParams {
+  /**
+   * 排序方法(可能为空,为空则不排序或使用默认排序)
+   */
+  sort?: {
+    /**
+     * 用来排序的字段
+     */
+    field: string|undefined;
+    /**
+     * 排序顺序(可能为空,为空则服务器默认排序)
+     */
+    order: 'ascend'|'descend'|string|undefined;
+  };
+  /**
+   * 搜索参数(为空则用户没有搜索)
+   */
+  search?: SearchArrayParams;
+  /**
+   * 筛选器(为空则用户没有筛选)
+   */
+  filter?: {
+    /**
+     * Key为要筛选的字段
+     * 值是一个数组,表示用户希望筛选出指定字段值在数组中的数据
+     * (用户可能输入多个字段筛选,此字段可能为多个)
+     */
+    [index: string]: TypeAll[]|undefined;
+  };
+  /**
+   * 分页页码
+   */
+  page?: number;
+  /**
+   * 分页页大小
+   */
+  size?: number;
+}
+
+/**
+ * URL参数
+ */
+export interface QueryParams {
+  /**
+   * URL参数
+   */
+  [index: string]: TypeAll;
+}
+
+/**
+ * 将参数转为斜杠“/”分隔的URL
+ * @param params 输入参数
+ * @returns 例如:输入 [ 'a', 'b', 'c' ] 将返回 a/b/c
+ */
+export function buildParams(params: Array<string>) : string {
+  let rs = '';
+  if(params && params.length > 0) {
+    for(let i = 0; i < params.length; i++) {
+      rs += '/' + params[i];
+    }
+  }
+  return rs
+}
+/**
+ * 转换参数结构为URL参数。
+ * * 此方法将会把参数对象中的属性按key-value展开为URL参数,例如:{ a: 1, b: 2 } 展开为 a=1&b=2。
+ * * 如果对象中的属性值是对象,它将被转为 JSON 字符串。
+ * * 所有的参数都已经URL编码。
+ * @param querys 参数结构
+ * @returns URL参数字符串
+ */
+export function buildQueryParams(querys: QueryParams) : string {
+  let rs = '?';
+  if(querys) {
+    const keys = Object.keys(querys);
+    let j = 0;
+    for(let i = 0; i < keys.length; i++) {
+      let obj = querys[keys[i]];
+      if (!StringUtils.isNullOrEmpty(obj as any)) {
+        if(typeof obj === 'object') obj = JSON.stringify(obj);
+        if(j == 0) rs += keys[i] + '=' + encodeURIComponent('' + obj);
+        else rs += '&' + keys[i] + '=' + encodeURIComponent('' + obj);
+        j++;
+      }
+    }
+  }
+  return rs
+}
+
+
+/**
+ * 通用 Restful 资源请求类
+ */
+export class RestfulApi<T extends DataModel> extends MengyuServerRequestModule<T> {
+
+  /**
+   * api 子路径
+   */
+  public subResKey: string;
+
+  public apiName: string;
+  /**
+   * 类型
+   */
+  public modulInstance: new () => T;
+
+  /**
+   * 创建 通用资源请求类
+   * @param subResKey api 子路径
+   */
+  public constructor(subResKey: string, modulInstance : new () => T) {
+    super();
+    this.subResKey = subResKey;
+    this.modulInstance = modulInstance;
+    this.apiName = (new modulInstance() as any)._classDebugName as string;
+  }
+
+  public commonRquest<T = object>(
+    method: "OPTIONS" | "GET" | "HEAD" | "POST" | "PUT" | "DELETE", 
+    url: string = `/${this.subResKey}`, 
+    apiName: string,
+    data?: Record<string, unknown>, 
+    search: SearchParams = {},
+    querys: QueryParams = {}, 
+    appendParams: string[] = [],
+    prependParams: string[] = [],
+    model?: new () => T,
+  ) : Promise<RequestApiResult<T>> {
+    return this.request<T>(
+      buildParams(prependParams) + url + buildParams(appendParams) + buildQueryParams(querys) + buildSearchParams(search),
+      {},
+      {
+        method,
+        data
+      },
+      apiName,
+      model
+    ) as any;
+  }
+  
+  /**
+   * 请求下拉框数据
+   * @param id 资源ID
+   */
+  public getList(search: SearchParams = {}, querys: QueryParams = {}, appendParams: string[] = [ 'list' ], prependParams: string[] = []) : Promise<RequestApiResult<CommonArrayResult<T>>> {
+    return this.commonRquest<KeyValue[]>(
+      'GET',
+      `/${this.subResKey}`,
+      `获取 ${this.apiName} 列表`,
+      undefined,
+      search,
+      querys,
+      appendParams,
+      prependParams
+    ).then((rs) => {
+      assertNotNull(rs.data, "rs.data is null! ");
+      return rs.cloneWithOtherData(new CommonArrayResult<T>().fromData(rs.data, undefined, this.modulInstance));
+    });
+  }
+  /**
+   * 请求下拉框数据 (无getList时的兼容, 默认分页大小100)
+   */
+  public getListPool(search: string, page: number = 1, size: number = 100, otherSearch: SearchArrayParams = {}, labelKey = 'name', querys: QueryParams = {}, appendParams: string[] = [ 'index' ], prependParams: string[] = []) : Promise<CommonListResult> {
+    return new Promise<CommonListResult>((resolve, reject) => {
+      if (search)
+        otherSearch[labelKey] = search;
+      this.getPage({
+        page,
+        size,
+        search: otherSearch
+      }, querys, appendParams, prependParams).then((d) => {
+        resolve(new CommonListResult().fromData(d.requireData().items));
+      }).catch(e => reject(e));
+    });
+  }
+  /**
+   * 请求分页
+   * @param page 页码,1 开始。默认1
+   * @param pageSize 页大小。默认10
+   * @param searchParams 搜索筛选键值
+   */
+  public getPage(searchParams: SearchParams = { page: 1, size: 10 }, querys: QueryParams = {}, appendParams: string[] = [ '' ], prependParams: string[] = []) : Promise<RequestApiResult<CommonPageResult<T>>> {
+    const page = searchParams.page || 1;
+    const size = searchParams.size || 10;
+    return this.commonRquest<{
+      allCount?: number | undefined;
+      allPage?: number | undefined;
+      items?: KeyValue[] | null | undefined;
+    }>(
+      'GET',
+      `/${this.subResKey}/${page}/${size}`,
+      `获取 ${this.apiName} 分页列表`,
+      undefined,
+      searchParams,
+      querys,
+      appendParams,
+      prependParams
+    ).then((rs) => {
+      assertNotNull(rs.data, "rs.data is null! ");
+      const data = rs.cloneWithOtherData(new CommonPageResult<T>().fromData(rs.data, page, size, this.modulInstance));
+      if (RequestApiConfig.getConfig().EnableApiDataLog)
+        console.log('[Debug] transformPageResult: ', data);
+      return data;
+    });
+  }
+  /**
+   * 请求资源
+   * @param id 资源ID
+   */
+  public getItem(id: number, querys: QueryParams = {}, appendParams: string[] = [], prependParams: string[] = []) : Promise<RequestApiResult<T>> {
+    return this.commonRquest(
+      'GET',
+      `/${this.subResKey}/${id}`,
+      `获取 ${this.apiName} 详情`,
+      undefined,
+      undefined,
+      querys,
+      appendParams,
+      prependParams,
+      this.modulInstance
+    );
+  }
+  /**
+   * 添加资源
+   * @param data 资源数据
+   */
+  public addItem(data: T, querys: QueryParams = {}, appendParams: string[] = [ '' ], prependParams: string[] = []) : Promise<RequestApiResult<number>> {
+    return this.commonRquest<number>(
+      'POST',
+      `/${this.subResKey}`,
+      `新增 ${this.apiName}`,
+      data.toServerSide(),
+      undefined,
+      querys,
+      appendParams,
+      prependParams
+    );
+  }
+  /**
+   * 更新资源
+   * @param id 资源ID
+   * @param data 资源数据
+   */
+  public updateItem(id: number, data: T, querys: QueryParams = {}, appendParams: string[] = [ '' ], prependParams: string[] = []): Promise<RequestApiResult<void>> {
+    return this.commonRquest<void>(
+      'PUT',
+      `/${this.subResKey}/${id}`,
+      `更新 ${this.apiName}`,
+      data.toServerSide(),
+      undefined,
+      querys,
+      appendParams,
+      prependParams
+    );
+  }
+  /**
+   * 更新资源状态
+   * @param id 资源ID
+   * @param state 状态
+   */
+  public toggleItemState(id: number, state: boolean|number, querys: QueryParams = {}, appendParams: string[] = [ 'toggle-state' ], prependParams: string[] = []): Promise<RequestApiResult<void>> {
+    return this.commonRquest<void>(
+      'POST',
+      `/${this.subResKey}/${id}`,
+      `更新 ${this.apiName} 状态为 ${state}`,
+      { state: typeof state === 'boolean' ? (state ? 1 : 0) : state },
+      undefined,
+      querys,
+      appendParams,
+      prependParams
+    );
+  }
+  /**
+   * 删除资源
+   * @param id 资源ID
+   */
+  public deleteItem(id: number, querys: QueryParams = {}, appendParams: string[] = [ '' ], prependParams: string[] = []) : Promise<RequestApiResult<void>>  { 
+    return this.commonRquest<void>(
+      'DELETE',
+      `/${this.subResKey}/${id}`,
+      `删除 ${this.apiName}`,
+      undefined,
+      undefined,
+      querys,
+      appendParams,
+      prependParams
+    );
+  }
+}
+
+/**
+ * 分页的返回标准
+ */
+export type PageReturn<T extends DataModel> = Promise<RequestApiResult<CommonPageResult<T>>>;
+/**
+ * 分页的API标准
+ */
+export type PageFunction<T extends DataModel> = (searchParams: SearchParams, querys?: QueryParams, appendParams?: string[], prependParams?: string[]) => PageReturn<T>;
+
+/**
+ * 排序标识转换到后端
+ */
+export function transformOrder(or : string) : string {
+  switch(or) {
+    case 'ascend': return 'asc';//升序
+    case 'descend': return 'desc';//降序
+  }
+  return or;
+}
+/**
+ * 搜索参数的转换到后端
+ * @param params 
+ * @param expandSearchs 把搜索参数分离成为单独的query
+ * @returns 
+ */
+export function buildSearchParams(params: SearchParams) : string {
+  let rs = '';
+  if(params) {
+    if(params.search && Object.keys(params.search).length > 0) {
+      const newO = {} as { [index: string]: SearchableType };
+      for(const key in params.search) {
+        const v = params.search[key]
+        if(typeof v === 'undefined' || v === null || v === "") continue;
+        if(v instanceof Array && (v.length === 0 || ArrayUtils.isAllNullOrEmpty(v))) continue;
+          
+        newO[key] = v as SearchableType;
+      }
+      if(Object.keys(newO).length > 0) {
+        rs += '&search=' + encodeURIComponent(JSON.stringify(newO));
+      }
+    }
+    if(params.sort)
+      rs += '&sort=' + encodeURIComponent(JSON.stringify(params.sort));
+
+    if(params.filter && Object.keys(params.filter).length > 0)
+      rs += '&filter=' + encodeURIComponent(JSON.stringify(params.filter));
+  }
+  return rs;
+}

+ 134 - 0
src/api/restful/types.ts

@@ -0,0 +1,134 @@
+/**
+ * 通用类型定义
+ * 
+ * 功能介绍:
+ *    为 API 提供了一些通用结构定义。
+ * 
+ * Author: imengyu
+ * Date: 2025/12/27
+ * 
+ * Copyright (c) 2021 imengyu.top. Licensed under the MIT License. 
+ * See License.txt in the project root for license information.
+ */
+
+import { DataModel, type KeyValue } from '@imengyu/js-request-transform';
+
+/**
+ * 通用分页返回结构定义
+ */
+export class CommonArrayResult<T extends DataModel> extends DataModel {
+  /**
+   * 数据
+   */
+  items: T[] = [];
+  /**
+   * 系统中一共有多少条数据
+   */
+  total = 0;
+
+  constructor() {
+    super(CommonArrayResult);
+  }
+
+  fromData(data: KeyValue[] | undefined | null, total: number|undefined, dataModel: new () => T): this {
+    this.items = data?.map((item) => new dataModel().fromServerSide(item) as T) || [];
+    this.total = total || this.items.length;
+    return this;
+  }
+}
+
+/**
+ * 通用下拉框返回结构定义
+ */
+export class CommonListResult extends DataModel {
+  /**
+   * 数据
+   */
+  items: {
+    text: string;
+    value: number;
+    raw: KeyValue;
+  }[] = [];
+  
+  constructor() {
+    super(CommonListResult);
+  }
+
+  fromData(data: KeyValue[] | undefined | null): this {
+    this.items = data?.map((item) => ({
+      text: item.label as string,
+      value: item.value as number,
+      raw: item,
+    })) || [];
+    return this;
+  }
+}
+/**
+ * 通用分页返回结构定义
+ */
+export class CommonPageResult<T extends DataModel> extends DataModel {
+  /**
+   * 当前页数据
+   */
+  items: T[] = [];
+  /**
+   * 当前页码
+   */
+  pageIndex = 0;
+  /**
+   * 当前页的大小
+   */
+  pageSize = 0;
+  /**
+   * 系统中一共有多少条数据
+   */
+  allCount = 0;
+  /**
+   * 系统中一共有多少页
+   */
+  allPage = 0;
+  /**
+   * 指示当前页是不是空的
+   */
+  empty = true;
+
+  constructor() {
+    super(CommonPageResult);
+  }
+
+  fromData(
+    data: {
+      allCount?: number,
+      allPage?: number,
+      items?: KeyValue[] | undefined | null,
+    }, 
+    page: number,
+    size: number,
+    dataModel: new () => T
+  ): this {
+    const listFromServer = data.items as unknown as Array<unknown>;
+    const list = new Array<T>();
+    if(listFromServer instanceof Array) {
+      let index = 1;
+      listFromServer.forEach((i) => {
+        const ii = new dataModel().fromServerSide(i as KeyValue); 
+        ii.index = index++;
+        list.push(ii as T);
+      });
+    }
+
+    this.items = list;
+    this.pageIndex = page;
+    this.pageSize = size;
+    this.allCount = data.allCount as number;
+    this.allPage = data.allPage as number;
+    this.empty = list.length == 0;
+
+    return this;
+  }
+}
+
+/**
+ * 加载状态
+ */
+export type LoadStatus = 'notload'|'success'|'failed'|'loading' ;

+ 27 - 0
src/api/restful/utils.ts

@@ -0,0 +1,27 @@
+/**
+ * 请求工具所使用的工具函数
+ *
+ * 功能介绍:
+ *  提供了一些处理工具函数,方便使用。
+ *
+ * Author: imengyu
+ * Date: 2022/03/25
+ *
+ * Copyright (c) 2021 imengyu.top. Licensed under the MIT License.
+ * See License.txt in the project root for license information.
+ */
+
+/* eslint-disable no-bitwise */
+
+/**
+ * 移除URL的地址部分,只保留路径
+ * @param str 原URL
+ * @returns 
+ */
+export function removeUrlOrigin(str: string) : string {
+  if(str.startsWith('http://') || str.startsWith('https://') || str.startsWith('fts://') || str.startsWith('ftps://')) {
+    str = str.substr(str.indexOf('://') + 3);
+    str = str.substr(str.indexOf('/') + 1);
+  } 
+  return str;
+}

+ 63 - 0
src/common/style/leagicy.scss

@@ -0,0 +1,63 @@
+.flex {
+  display: flex;
+}
+.flex-row {
+  flex-direction: row;
+}
+.flex-column {
+  flex-direction: column;
+}
+.flex-row-reverse {
+  flex-direction: row-reverse;
+}
+.flex-column-reverse {
+  flex-direction: column-reverse;
+}
+.flex-wrap {
+  flex-wrap: wrap;
+}
+.flex-nowrap {
+  flex-wrap: nowrap;
+}
+.items-center {
+  align-items: center;
+}
+.items-start {
+  align-items: flex-start;
+}
+.items-end {
+  align-items: flex-end;
+}
+.items-stretch {
+  align-items: stretch;
+}
+.items-baseline {
+  align-items: baseline;
+}
+.items-stretch {
+  align-items: stretch;
+}
+.items-baseline {
+  align-items: baseline;
+}
+.justify-center {
+  justify-content: center;
+}
+.justify-between {
+  justify-content: space-between;
+}
+.justify-around {
+  justify-content: space-around;
+}
+.justify-evenly {
+  justify-content: space-evenly;
+}
+.gap-1 {
+  gap: 10rpx;
+}
+.gap-2 {
+  gap: 15rpx;
+}
+.gap-3 {
+  gap: 20rpx;
+}

+ 10 - 1
src/components/basic/ImageButton.vue

@@ -3,21 +3,30 @@
     :activeOpacity="activeOpacity" 
     :touchable="touchable"
     position="relative" 
+    center
     @click="emit('click')"
   >
     <Image v-bind="props" />
-    <slot />
+    <slot>
+      <FlexCol center position="absolute" inset="0">
+        <Text v-if="text" :text="text" v-bind="textProps" />
+      </FlexCol>
+    </slot>
   </Touchable>
 </template>
 
 <script setup lang="ts">
 import Touchable from '../feedback/Touchable.vue';
+import FlexCol from '../layout/FlexCol.vue';
 import type { ImageProps } from './Image.vue';
 import Image from './Image.vue';
+import Text, { type TextProps } from './Text.vue';
 
 const props = withDefaults(defineProps<ImageProps & {
   activeOpacity?: number;
   touchable?: boolean;
+  text?: string;
+  textProps?: TextProps;
 }>(), {
   activeOpacity: 0.7,
   touchable: true,

+ 6 - 0
src/components/form/Field.vue

@@ -100,6 +100,7 @@
             :placeholder="placeholder"
             :placeholder-style="`color: ${themeContext.resolveThemeColor(error ? errorTextColor : placeholderTextColor)}`"
             confirm-type="done"
+            :rows="rows"
             :maxlength="maxLength"
             :disabled="disabled || readonly"
             @input="onInput"
@@ -289,6 +290,11 @@ export interface FieldProps {
    */
   multiline?: boolean;
   /**
+   * 多行文字的行数
+   * @default undefined
+   */
+  rows?: number|string;
+  /**
    * 多行文字下是否自动调整高度
    * @default false
    */

+ 5 - 2
src/components/form/UploaderListAddItem.vue

@@ -1,7 +1,7 @@
 <template>
   <IconButton
     v-if="!isListStyle"
-    icon="add"
+    :icon="themeContext.getVar('UploaderAddIcon', 'add')"
     :size="themeContext.getVar('UploaderAddIconSize', 60)"
     :pressedBackgroundColor="themeContext.getVar('UploaderListAddItemPressedBackgroundColor', 'pressed.button')"
     :buttonStyle="{
@@ -19,7 +19,7 @@
 import Button from '../basic/Button.vue';
 import IconButton from '../basic/IconButton.vue';
 import { useTheme, type ViewStyle } from '../theme/ThemeDefine';
-import { DynamicColor, DynamicSize } from '../theme/ThemeTools';
+import { DynamicColor, DynamicSize, DynamicVar } from '../theme/ThemeTools';
 
 export interface UploaderListAddItemProps {
   style?: ViewStyle;
@@ -35,6 +35,9 @@ const themeStyles = themeContext.useThemeStyles({
   itemAddButton: {
     overflow: 'hidden',
     backgroundColor: DynamicColor('UploaderListAddItemBackgroundColor', 'button'),
+    backgroundImage: DynamicVar('UploaderListAddItemBackgroundImage', 'none'),
+    backgroundSize: '100% 100%',
+    backgroundRepeat: 'no-repeat',
     borderRadius: DynamicSize('UploaderListAddItemBorderRadius', 20),
     margin: DynamicSize('UploaderListAddItemMargin', 4),
   },

+ 11 - 4
src/components/nav/NavBar.vue

@@ -91,8 +91,15 @@ export interface NavBarProps {
   height?: number|string;
   /**
    * 左侧按钮
+   * 
+   * 特殊按钮:
+   * * back:返回按钮
+   * * menu:菜单按钮
+   * * search:搜索按钮
+   * * setting:设置按钮
+   * @default ''
    */
-  leftButton?: NavBarButtonTypes,
+  leftButton?: NavBarButtonTypes|string,
   /**
    * 标题文字,支持自定义元素
    */
@@ -105,7 +112,7 @@ export interface NavBarProps {
   /**
    * 右侧按钮
    */
-  rightButton?: NavBarButtonTypes,
+  rightButton?: NavBarButtonTypes|string,
   /**
    * 是否显示右侧按钮
    * @default true
@@ -161,7 +168,7 @@ export interface NavBarProps {
   innerClass?: any;
 }
 
-function getButton(type: NavBarButtonTypes) {
+function getButton(type: NavBarButtonTypes|string) {
   let button = '';
   switch (type) {
     case 'back': button = 'arrow-left-bold'; break;
@@ -203,7 +210,7 @@ const titleTextStyle = theme.useThemeStyle({
   paddingRight: DynamicSize('NavBarTitlePaddingHorizontal', 15),
 });
 
-function handleButtonNavBack(button: NavBarButtonTypes, callback: () => void) {
+function handleButtonNavBack(button: NavBarButtonTypes|string, callback: () => void) {
   if (button === 'back') {
     if (isTopLevelPage()) {
       uni.reLaunch({

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

@@ -78,6 +78,7 @@ export function provideSomeThemeVar(record: Record<string, any>) {
         ...record
       }
     } as ThemeConfig;
+    console.log('provideSomeThemeVar', v);
     return v;
   });
   provide(ThemeKey, newTheme);
@@ -114,7 +115,7 @@ export function useTheme() {
     return resolveSize(inValue);
   }
   function resolveThemeColor(inValue?: string, defaultValue?: string) : string|undefined {
-    if (inValue === 'transparent')
+    if (isSpecialColor(inValue))
       return inValue;
     if (inValue === undefined)
       inValue = defaultValue;
@@ -139,6 +140,10 @@ export function useTheme() {
     return result;
   }
 
+  function isSpecialColor(key?: string) {
+    return key === 'transparent' || key === 'currentColor';
+  }
+
   function getColor(key: string, defaultValue?: string) {
     if (key === undefined)
       return defaultValue;
@@ -148,6 +153,8 @@ export function useTheme() {
       key = keyResolve;
     if (key.includes('.'))
       [type, key] = key.split('.');
+    if (isSpecialColor(keyResolve))
+      return keyResolve;
     let group = theme.value.colorConfigs[type || 'default'];
     if (!group) 
       group = theme.value.colorConfigs['default'];

+ 1 - 0
src/pages.json

@@ -71,6 +71,7 @@
       "path": "pages/home/post/publish",
       "style": {
         "navigationBarTitleText": "发布微信贴图",
+        "navigationStyle": "custom",
         "enablePullDownRefresh": false
       }
     },

+ 107 - 0
src/pages/chat/composables/useChatHistoryItemsPager.ts

@@ -0,0 +1,107 @@
+import { computed, ref, type Ref } from 'vue';
+import { ChatMessage } from '../model/Message';
+import AgentApi from '@/api/agent/Agent';
+import type { ChatSessionManager } from './useChatSession';
+
+/**
+ * 聊天记录分页:假定后端 page=1 为最旧一页、page=allPage 为最新一页。
+ * 「加载更多」向更早方向取 page-1 并 prepend 到列表顶部。
+ */
+export function useChatHistoryItemsPager(options: {
+  messages: Ref<ChatMessage[]>;
+  sessionManager: ChatSessionManager;
+  pageSize?: number;
+}) {
+
+  const messages = options.messages;
+  const sessionManager = options.sessionManager;
+  const pageSize = options.pageSize ?? 20;
+  const loadingInit = ref(false);
+  const loadingMore = ref(false);
+  const hasMorePage = ref(false);
+
+  const hasMoreOlder = computed(() => hasMorePage.value && options.sessionManager.currentSessionId.value);
+  const historyId = computed(() => options.sessionManager.currentSessionId.value);
+  const isLoading = computed(() => loadingInit.value || loadingMore.value);
+
+  let oldestLoadedPage = 0;
+
+  async function init() {
+    loadingInit.value = true;
+    oldestLoadedPage = 0;
+    hasMorePage.value = false;
+    messages.value = [];
+    try {
+      const metaRs = await AgentApi.getChatHistoryItems(1, pageSize, historyId.value);
+      const meta = metaRs.requireData();
+      if (!meta.allCount || meta.allCount === 0) {
+        messages.value = [];
+        return;
+      }
+      const totalPages = Math.max(meta.allPage, 1);
+      const pageRes = await AgentApi.getChatHistoryItems(totalPages, pageSize, historyId.value);
+      const chunk = pageRes.requireData().items;
+      messages.value = chunk.map((it) => ChatMessage.fromHistoryItem(it));
+      oldestLoadedPage = totalPages;
+      hasMorePage.value = totalPages > 1;
+    } finally {
+      loadingInit.value = false;
+    }
+  }
+
+  async function loadOlder() {
+    if (
+      !historyId.value
+      || !hasMoreOlder.value
+      || loadingMore.value
+      || oldestLoadedPage <= 1
+    ) {
+      return;
+    }
+    loadingMore.value = true;
+    try {
+      const p = oldestLoadedPage - 1;
+      const res = await AgentApi.getChatHistoryItems(p, pageSize, historyId.value);
+      const chunk = res.requireData().items;
+      const prepend = chunk
+        .map((it) => ChatMessage.fromHistoryItem(it))
+        .filter((it) => {
+          //防止在加载之前有新的消息添加,造成分页重复
+          return !messages.value.some((m) => m.id === it.id);
+        });
+      messages.value = [...prepend, ...messages.value];
+      oldestLoadedPage = p;
+      hasMorePage.value = p > 1;
+    } finally {
+      loadingMore.value = false;
+    }
+  }
+
+  function reset() {
+    messages.value = [];
+    hasMorePage.value = false;
+    oldestLoadedPage = 0;
+    loadingInit.value = false;
+    loadingMore.value = false;
+  }
+
+  sessionManager.events.on('pager-reset', () => {
+    reset();
+  });
+  sessionManager.events.on('pager-init', async (id) => {
+    await init();
+  });
+
+  return {
+    messages,
+    loadingInit,
+    loadingMore,
+    isLoading,
+    hasMoreOlder,
+    init,
+    loadOlder,
+    reset,
+  };
+}
+
+export type ChatHistoryItemsPagerManager = ReturnType<typeof useChatHistoryItemsPager>;

+ 339 - 0
src/pages/chat/composables/useChatSession.ts

@@ -0,0 +1,339 @@
+import { computed, ref, type Ref } from 'vue';
+import { ChatMessage } from '../model/Message';
+import { ArrayUtils, EventEmitter, waitTimeOut } from '@imengyu/imengyu-utils';
+import { ChatGroups } from '../core/Groups';
+import AgentApi, { AgentChatHistory } from '@/api/agent/Agent';
+import AgentChatFileApi from '@/api/agent/AgentChatFile';
+import AgentWorkApi from '@/api/agent/AgentWorks';
+import { confirm, toast } from '@/components/utils/DialogAction';
+
+export interface UseChatSessionOptions {
+  /**
+   * 是否启用会话存储
+   * @default true
+   */
+  enableSession: boolean;
+  /**
+   * 会话分组ID
+   * @default ChatGroups.DEFAULT
+   */
+  groupId?: number;
+  messages: Ref<ChatMessage[]>;
+  /**
+   * 停止聊天
+   */
+  onStop: () => void;
+  onScrollToBottom: () => void;
+}
+
+export const CHAT_SESSION_NEW_ID = -1;
+export const CHAT_SESSION_LOCAL_ID = -2;
+const CHAT_SESSION_PAGE_SIZE = 20;
+
+/**
+ * 聊天会话管理
+ * @param options - 选项
+ * @returns 
+ */
+export function useChatSession(options: UseChatSessionOptions) {
+  const {
+    enableSession,
+    groupId = ChatGroups.DEFAULT,
+    messages,
+    onStop,
+    onScrollToBottom,
+  } = options;
+
+  const sessions = ref<AgentChatHistory[]>([]);
+  const sessionsLoading = ref(false);
+  const sessionsHasMore = ref(false);
+  const sessionsPage = ref(1);
+
+  const currentSessionId = ref<number>(CHAT_SESSION_NEW_ID);
+  const currentGroupId = ref<number>(groupId);
+  const currentSessionName = computed(() => {
+    return sessions.value.find(s => s.id === currentSessionId.value)?.desc ?? '新会话';
+  });
+  const currentSession = computed<AgentChatHistory | null>(() =>
+    sessions.value.find(s => s.id === currentSessionId.value) as AgentChatHistory | null ?? null,
+  );
+
+  const searchSession = ref('');
+
+  const renameVisible = ref(false);
+  const renameTitle = ref('');
+  const renameTarget = ref<AgentChatHistory | null>(null);
+
+  const events = new EventEmitter<{
+    'session-loaded': (session: AgentChatHistory) => void;
+    'session-newed': () => void;
+    'pager-reset': () => void;
+    'pager-init': (id: number) => Promise<void>;
+  }>();
+
+  async function loadSessions() {
+    sessionsPage.value = 1;
+    sessionsLoading.value = true;
+    try {
+      const rs = await AgentApi.getChatHistory(1, CHAT_SESSION_PAGE_SIZE, searchSession.value, currentGroupId.value);
+      sessions.value = (rs.requireData().items ?? []) as AgentChatHistory[];
+    } finally {
+      sessionsLoading.value = false;
+    }
+  }
+  async function loadMoreSessions() {
+    sessionsPage.value++;
+    sessionsLoading.value = true;
+    try {
+      const rs = (await AgentApi.getChatHistory(sessionsPage.value, CHAT_SESSION_PAGE_SIZE, searchSession.value, currentGroupId.value)).requireData();
+      sessions.value = [...sessions.value, ...rs.items ?? []];
+      sessionsHasMore.value = rs.allPage > sessionsPage.value;
+    } finally {
+      sessionsLoading.value = false;
+    }
+  }
+
+  /**
+   * 进入临时会话
+   */
+  function onSelectLocal() {
+    onStop();
+    currentSessionId.value = CHAT_SESSION_LOCAL_ID;
+    events.emit('pager-reset');
+    events.emit('session-newed');
+    lateScrollToBottom();
+  }
+  /**
+   * 进入新会话
+   */
+  function onSelectNew() {
+    onStop();
+    currentSessionId.value = CHAT_SESSION_NEW_ID;
+    events.emit('pager-reset');
+    events.emit('session-newed');
+    lateScrollToBottom();
+  }
+  /**
+   * 进入指定会话
+   * @param id 会话ID
+   */
+  async function onSelectSession(id: number) {
+    if (currentSessionId.value === id) {
+      return;
+    }
+    onStop();
+    currentSessionId.value = id;
+    await events.emitAsync('pager-init', id);
+    events.emit('session-loaded', sessions.value.find(s => s.id === id) as AgentChatHistory);
+    lateScrollToBottom();
+  }
+
+  async function lateScrollToBottom() {
+    await waitTimeOut(200);
+    onScrollToBottom();
+  }
+
+  function onRenameSession(row: AgentChatHistory) {
+    const hit = sessions.value.find(s => s.id === row.id);
+    if (!hit) {
+      return;
+    }
+    renameTarget.value = hit;
+    renameTitle.value = hit.desc ?? '';
+    renameVisible.value = true;
+  }
+  function onDeleteSession(row: AgentChatHistory) {
+    const hit = sessions.value.find(s => s.id === row.id);
+    if (!hit) {
+      return;
+    }
+    confirm({
+      title: '删除会话',
+      content: `确定删除「${hit.desc || '未命名'}」吗?聊天记录将一并删除。`,
+    }).then(async (result) => {
+      if (result) {
+        await AgentApi.deleteChatHistory(hit.id);
+        await loadSessions();
+        if (currentSessionId.value === hit.id) 
+          onSelectNew();
+      }
+    });
+  }
+  function onDeleteMessage(id: number) {
+    confirm({
+      title: '删除消息',
+      content: `确定删除该消息吗?`,
+    }).then(async (result) => {
+      if (result) {
+        const hit = messages.value.find(m => m.id === id);
+        if (!hit) 
+          return;
+        ArrayUtils.remove(messages.value, hit);
+        await AgentApi.deleteChatHistoryItems(currentSessionId.value as number, [id]);
+      }
+    });
+  }
+  function onDeleteMessages(ids: number[]) {
+    confirm({
+      title: '删除消息',
+      content: `确定删除选中的消息吗?`,
+    }).then(async (result) => {
+      if (result) {
+        await AgentApi.deleteChatHistoryItems(currentSessionId.value as number, ids);
+        messages.value = messages.value.filter(m => !ids.includes(m.id));
+      }
+    });
+  }
+
+  async function submitRename() {
+    const row = renameTarget.value;
+    if (!row) {
+      return;
+    }
+    const title = renameTitle.value.trim();
+    if (!title) {
+      toast('请输入会话标题');
+      return Promise.reject();
+    }
+    const payload = new AgentChatHistory().setSelfValues({
+      id: row.id,
+      userId: row.userId,
+      desc: title,
+    }) as AgentChatHistory;
+    await AgentApi.updateChatHistory(row.id, payload);
+    renameVisible.value = false;
+    await loadSessions();
+  }
+
+  /**
+   * 保存用户消息并持久化会话
+   * @param userMessages 
+   * @returns 
+   */
+  async function saveUserMessageAndPersistSession(userMessages: ChatMessage[]) {
+    /**
+     * 如果会话不可用或为临时会话,则不保存消息
+     */
+    if (!enableSession || currentSessionId.value === CHAT_SESSION_LOCAL_ID || userMessages.length === 0)
+      return;
+
+    if (currentSessionId.value <= 0) {
+      const overview = await AgentWorkApi.getQuestionOverview(userMessages.map(m => m.content).join('\n'));
+      const historyId = (await AgentApi.createChatHistory(overview, currentGroupId.value)).requireData();
+
+      await addChatHistoryItems(historyId);
+      currentSessionId.value = historyId;
+      await loadSessions();
+      await events.emitAsync('pager-init', historyId);
+      //移除未持久化的消息
+      messages.value = messages.value.filter(m => m.isPersisted);
+    } else {
+      await addChatHistoryItems(currentSessionId.value);
+    }
+
+    async function persistMessages(historyId: number, messages: ChatMessage[]) {
+      if (messages.length === 0)
+        return;
+      const historyItemIds = (await AgentApi.addOrUpdateChatHistoryItems(historyId, messages.map(m => m.toHistoryItem()))).requireData();
+      for (let index = 0; index < messages.length; index++) {
+        const m = messages[index];
+        m.historyId = historyId;
+        m.historyItemId = historyItemIds[index];
+        m.id = historyItemIds[index];
+        if (m.files && m.files.length > 0)
+          await AgentChatFileApi.linkToHistoryItem(m.id, m.files[0].id);
+      }
+    }
+    /**
+     * 添加聊天记录项
+     */
+    async function addChatHistoryItems(historyId: number) {
+      await persistMessages(historyId, [userMessages[0]]);
+      if (userMessages.length > 1) {
+        for (let i = 1; i < userMessages.length; i++) 
+          userMessages[i].parentId = userMessages[0].id;
+        await persistMessages(historyId, userMessages.slice(1));
+      }
+    }
+  }
+  /**
+   * 持久化ai消息(必须有会话)
+   * @param assistantMessages 
+   */
+  async function persistMessages(messages: ChatMessage[]) {
+    try {
+      if (!enableSession || currentSessionId.value < 0) 
+        return;
+      const historyItemIds = (await AgentApi.addOrUpdateChatHistoryItems(currentSessionId.value, messages.map(m => m.toHistoryItem()))).requireData();
+      for (let index = 0; index < messages.length; index++) {
+        const m = messages[index];
+        m.historyId = currentSessionId.value;
+        m.historyItemId = historyItemIds[index];
+        m.id = historyItemIds[index];
+      }
+    } catch (error) {
+      console.error(error);
+      toast('保存聊天记录失败,请稍后重试');
+    }
+  }
+  /**
+   * 在请求流式响应之后持久化ai消息(必须有会话),并且建立之前未创建会话时消息的回复关系
+   * @param aiMessage 
+   */
+  async function persistAssistantAfterStream(aiMessage: ChatMessage, currentUserMessageIds: number[]) {
+    try {
+      if (!enableSession || currentSessionId.value < 0) 
+        return;
+      const messagesToPersist = [ aiMessage ];
+      //设置已回复标记
+      for (const id of currentUserMessageIds) {
+        const message = messages.value.find(m => m.id === id);
+        if (message) {
+          message.replyItemId = aiMessage.id;
+          messagesToPersist.push(message);
+        }
+      }
+      await persistMessages(messagesToPersist);
+    } catch (error) {
+      console.error(error);
+      toast('保存聊天记录失败,请稍后重试');
+    }
+  }
+  /**
+   * 更新会话
+   * @param session 会话
+   */
+  async function updateSession(session: AgentChatHistory) {
+    await AgentApi.updateChatHistory(session.id, session);
+  }
+
+  return {
+    enableSession,
+    searchSession,
+    sessions,
+    sessionsLoading,
+    sessionsHasMore,
+    currentSessionName,
+    currentSessionId,
+    currentSession,
+    renameVisible,
+    renameTitle,
+    events,
+    loadSessions,
+    loadMoreSessions,
+    onSelectLocal,
+    onSelectNew,
+    onSelectSession,
+    onRenameSession,
+    onDeleteMessage,
+    onDeleteMessages,
+    submitRename,
+    onDeleteSession,
+    saveUserMessageAndPersistSession,
+    persistMessages,
+    persistAssistantAfterStream,
+    updateSession,
+  };
+}
+
+export type ChatSessionManager = ReturnType<typeof useChatSession>;

+ 609 - 0
src/pages/chat/core/Chat.ts

@@ -0,0 +1,609 @@
+import AgentApi, { type AgentChatModel } from "@/api/agent/Agent";
+import AgentWorkApi from "@/api/agent/AgentWorks";
+import { nextTick, onBeforeUnmount, onMounted, ref, type Ref } from "vue";
+import { ChatUtils } from "../utils/ChatUtils";
+import { ChatMessage as ChatMessageModel } from "../model/Message";
+import { useMessages, LocalMessageIdPool, mergeSystemMessages } from "./Messages";
+import { useToolCalls } from "./ToolCall";
+import { useChatStaticMessages } from "./StaticMessages";
+import { useChatContext, type ContextMemoryConfig, type ContextMemorySetting } from "./Context";
+import { useChatTools, type ChatToolsManager } from "./Tools";
+import type { ChatSessionManager } from "../composables/useChatSession";
+import type { SSE } from "sse.js";
+import type OpenAI from "openai";
+import { requireNotNull } from "@imengyu/imengyu-utils";
+
+export type ChatModelOptionValue = {
+  temperature: number;
+  top_p: number;
+  top_k: number;
+  presence_penalty: number;
+};
+
+export type ChatAttachmentStatus = 'uploading' | 'success' | 'error';
+export type ChatAttachmentType = 'image' | 'audio' | 'video' | 'text' | 'document' | 'unknown';
+
+export interface ChatAttachmentItem {
+  id?: number;
+  localId: string;
+  name: string;
+  size: number;
+  status: ChatAttachmentStatus;
+  path?: string;
+  url?: string;
+  type: ChatAttachmentType;
+  errorMessage?: string;
+  file: File;
+}
+export type ChatInterfaceManager = {
+  messages: Ref<ChatMessageModel[]>;
+  focusInput?: () => void;
+  setInputValue?: (value: string) => void;
+  scrollToBottom?: () => void;
+  stopMessageEditing?: () => void;
+  getAttachmentList?: () => Promise<ChatAttachmentItem[]>;
+};
+export interface ChatConfig {
+  /**
+   * 默认系统提示词
+   * @default ''
+   */
+  defaultSystemPrompt?: string;
+
+  /**
+   * 上下文记忆设置
+   * @default 'short'
+   */
+  contextMemorySetting?: ContextMemorySetting;
+  /**
+   * 上下文记忆参数配置
+   */
+  contextMemoryConfig?: ContextMemoryConfig;
+
+  /**
+   * 构建欢迎消息
+   */
+  onBuildWelcome?: () => {
+    /**
+     * 欢迎消息
+     * @default ''
+     */
+    welcomeMessage?: string;
+    /**
+     * 欢迎动作
+     * @default []
+     */
+    welcomeActions?: string[];
+  };
+  /**
+   * 获取发送选项
+   * @returns 
+   */
+  onGetSendOptions: () => ChatSendOptions;
+
+  /**
+   * 新对话
+   * @param isNewChat - 是否是新对话
+   */
+  onNewChat?: (isNewChat: boolean) => void;
+  /**
+   * 发送消息前
+   * @param message - 消息
+   */
+  onBeforeSend?: (userMessages: ChatMessageModel[], streamOptions: any) => void;
+  /**
+   * 对话消息结束
+   * @param message - 消息
+   * @param currentUserMessageIds - 当前用户消息ID列表
+   */
+  onAiMessageFinish?: (message: ChatMessageModel, currentUserMessageIds: number[], finishReason: string) => void;
+
+  /**
+   * 是否在ai回答后自动生成可能的问题
+   * @default false
+   */
+  autoGeneratePossibleQuestions?: boolean;
+
+  /**
+   * 初始化工具
+   * @param toolsManager - 工具管理器
+   * @returns 
+   */
+  onInitTools?: (toolsManager: ChatToolsManager) => void;
+}
+
+export type ChatSendOptions = {
+  enableSearch: boolean;
+  enableThinking: boolean;
+  model: string;
+  modelInfo: AgentChatModel ;
+  customSystemPrompt?: string|null;
+  chatOptions: ChatModelOptionValue;
+};
+
+export function useChat(options: {
+  config: ChatConfig;
+  interfaceManager: ChatInterfaceManager;
+  sessionManager: ChatSessionManager;
+}) {
+ 
+  const isLoading = ref(false);
+  const config = options.config;
+  let streamingAiMessageId: number | null = null;
+  let eventSource: SSE | null = null;
+  let startTime = 0;
+
+  const messages = options.interfaceManager.messages;
+  const referenceMessage = ref('');
+  const messagesManager = useMessages(messages);
+  const toolsManager = useChatTools(); options.config?.onInitTools?.(toolsManager);
+  const staticMessagesManager = useChatStaticMessages(config);
+  const sessionManager = options.sessionManager;
+  const interfaceManager = options.interfaceManager;
+  const contextManager = useChatContext({
+    message: messages,
+    contextMemorySetting: config.contextMemorySetting,
+    contextMemoryConfig: config.contextMemoryConfig,
+    sessionManager: sessionManager,
+  });
+  const toolCallsManager = useToolCalls(messagesManager, toolsManager, interfaceManager, sessionManager);
+
+  //新建会话消息构建
+  sessionManager.events.on('session-newed', () => {
+    messages.value = [staticMessagesManager.getWelcomeMessage()];
+    config.onNewChat?.(true);
+    contextManager.estimateTokenUseage(config.onGetSendOptions());
+  });
+  //加载会话消息构建
+  sessionManager.events.on('session-loaded', (session) => {
+    config.onNewChat?.(false);
+    contextManager.estimateTokenUseage(config.onGetSendOptions());
+  });
+
+  async function buildStreamOptions(
+    sendOptions: ChatSendOptions,
+    currentUserMessageIds: number[],
+    limitHistoryStartAtMessageId: number|undefined,
+    openAiTools: OpenAI.Chat.Completions.ChatCompletionTool[],
+  ) {
+    const finalMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [];
+
+    /**
+     * 构建系统消息
+     */
+    const systemMessages = [] as OpenAI.Chat.ChatCompletionSystemMessageParam[];
+    if (sendOptions?.customSystemPrompt)
+      systemMessages.push({ role: 'system', content: sendOptions.customSystemPrompt.trim() });
+    else if (config.defaultSystemPrompt)
+      systemMessages.push({ role: 'system', content: config.defaultSystemPrompt.trim() });
+
+    /**
+     * 构建用户消息
+     */
+    const { 
+      userMessages, 
+      systemMessages: contextSystemMessages 
+    } = await contextManager.convertMessagesToAi(sendOptions, currentUserMessageIds, limitHistoryStartAtMessageId);
+
+    const referenceSystemMessages: OpenAI.Chat.ChatCompletionSystemMessageParam[] = [];
+    if (referenceMessage.value)
+      referenceSystemMessages.push({ role: 'system', content: `[用户引用消息] ${referenceMessage.value.trim()}` });
+
+    finalMessages.push(
+      //合并系统消息
+      ...mergeSystemMessages([
+        ...systemMessages,
+        ...referenceSystemMessages,
+        ...(contextSystemMessages) as OpenAI.Chat.ChatCompletionSystemMessageParam[], 
+      ]),
+      ...userMessages,
+    );
+
+    if (finalMessages.length === 0)
+      throw new Error('消息不能为空');
+  
+    /**
+     * 构建额外选项
+     */
+    const extraOptions = {
+      enable_thinking: sendOptions.enableThinking ?? undefined,
+      enable_search: sendOptions.enableSearch ?? undefined,
+    };
+    const chartOptions: OpenAI.Chat.Completions.ChatCompletionCreateParams = {
+      stream: true,
+      model: sendOptions.model,
+      ...sendOptions.chatOptions || {},
+      messages: finalMessages,
+      tools: openAiTools.length ? openAiTools : undefined,
+      tool_choice: openAiTools.length ? 'auto' : undefined,
+    };
+    return {
+      ...chartOptions,
+      ...extraOptions,
+    };
+  }
+
+  /**
+   * 对话逻辑
+   */
+  async function startStreamForUserMessages(
+    currentUserMessageIds: number[], 
+    aiMessageId?: number,
+    limitHistoryStartAtMessageId?: number
+  ) {
+    const sendOptions = config.onGetSendOptions();
+    if (!sendOptions.model)
+      throw new Error('模型不能为空');
+
+    let aiMessage: ChatMessageModel;
+    if (!aiMessageId) {
+      aiMessageId = LocalMessageIdPool.getNextId();
+      aiMessage = messagesManager.addMessage(ChatMessageModel.createAssistant("", aiMessageId, "loading"));
+    } else {
+      aiMessage = requireNotNull(messagesManager.findMessage(aiMessageId));
+    }
+    aiMessage.parentId = currentUserMessageIds[0];
+    aiMessage.state = "loading";
+    aiMessage.name = sendOptions.modelInfo.name ?? '';
+
+    //持久化消息
+    if (!aiMessage.isPersisted)
+      await sessionManager.persistMessages([aiMessage]);
+    await nextTick();
+    await interfaceManager.scrollToBottom?.();
+
+    streamingAiMessageId = aiMessage.id;
+    startTime = Date.now();
+
+    const toolCallBuffers = new Map<number, { id?: string; name?: string; arguments: string }>();
+    let streamOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming;
+    try {
+      // 用于拼接 tool_calls 的 arguments(流式会分片)
+      streamOptions = await buildStreamOptions(
+        sendOptions,
+        currentUserMessageIds, 
+        limitHistoryStartAtMessageId,
+        toolsManager.openAiTools.value
+      );
+
+      config.onBeforeSend?.(messages.value.filter(m => currentUserMessageIds.includes(m.id)), streamOptions);
+    } catch (error) {
+      console.error("构建流式选项失败:", error);
+      failedAndSetInfo("处理消息失败", error);
+      return;
+    }
+
+    eventSource = AgentApi.chatStream(streamOptions as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming);
+    eventSource.onmessage = async (event) => {
+      try {
+        const data = JSON.parse(event.data);
+
+        // 后端通过 SSE 下发错误(含超时等)
+        if (data?.error) {
+          eventSource?.close();
+          isLoading.value = false;
+          streamingAiMessageId = null;
+
+          const errorType = String(data.errorType || '');
+          const errorText = typeof data.error === 'string' ? data.error : '请求失败';
+          const title =
+            errorType === 'timeout_connect' || errorType === 'timeout_idle'
+              ? '请求超时'
+              : '请求错误';
+          aiMessage.content = errorType.startsWith('timeout')
+            ? '请求超时,请检查模型地址/网络后重试。'
+            : '失败,请稍后重试。';
+          aiMessage.setError(title, errorText);
+          await sessionManager.persistMessages([aiMessage]);
+          return;
+        }
+
+        if (!data.chunk) return;
+        const chunk = data.chunk as OpenAI.Chat.ChatCompletionChunk;
+        const choice = chunk.choices[0];
+        if (!choice) return;
+
+        // 结束处理
+        if (choice.finish_reason) {
+          //结束状态
+          isLoading.value = false;
+          streamingAiMessageId = null;
+
+          const isAiMessageEmpty = aiMessage.content === '' && aiMessage.reasoningContent === '';
+          const finishReason = choice.finish_reason;
+          aiMessage.state = "success";
+          aiMessage.replyTime = Date.now() - startTime;
+
+          switch (finishReason) {
+            case "tool_calls": {
+              // 如果内容与思考内容都为空,则说明ai进行了工具调用
+              if (isAiMessageEmpty)  {
+                aiMessage.content = '准备执行工具调用...';
+              }
+              break;
+            }
+          }
+
+          //持久化消息
+          sessionManager.persistAssistantAfterStream(aiMessage, currentUserMessageIds);
+          config.onAiMessageFinish?.(aiMessage, currentUserMessageIds, finishReason);
+          contextManager.estimateTokenUseage(sendOptions);
+
+          switch (finishReason) {
+            case "stop": {
+              //如果需要自动生成可能的问题,则生成可能的问题
+              if (config.autoGeneratePossibleQuestions && !isAiMessageEmpty) {
+                const userMessage = messages.value.find(m => currentUserMessageIds.includes(m.id))?.content;
+                const possibleQuestions = await AgentWorkApi.autoGeneratePossibleQuestions(userMessage ?? '', aiMessage.content);
+                aiMessage.actions = possibleQuestions;
+              }
+              break;
+            }
+            case "tool_calls": {
+              //工具调用处理
+
+              const toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = [...toolCallBuffers.entries()]
+                .sort((a, b) => a[0] - b[0])
+                .map(([, b]) => ({
+                  id: b.id || `call_${Math.random().toString(16).slice(2)}`,
+                  type: "function",
+                  function: {
+                    name: b.name || "unknown",
+                    arguments: b.arguments || "",
+                  },
+                }));
+
+              // 把 tool_calls 挂在 assistant 消息上,便于下一轮历史回放(如果后端严格校验)
+              aiMessage.toolCalls = toolCalls;
+
+              // 执行工具 -> 追加 tool 消息 -> 再发起一轮 stream 获取最终回答
+              await toolCallsManager.executeToolCalls(toolCalls, aiMessage.parentId);
+
+              // 继续一轮对话(同一批 user messages)
+              isLoading.value = true;
+              await startStreamForUserMessages(currentUserMessageIds);
+              return;
+            }
+          }
+        }
+
+        // 结束后关闭
+        if (data.finished) {
+          closeStream();
+          return;
+        }
+
+        // tool_calls 拼接
+        const delta: any = choice.delta as any;
+        if (Array.isArray(delta?.tool_calls)) {
+          for (const tc of delta.tool_calls as any[]) {
+            const index = tc.index ?? 0;
+            const buf = toolCallBuffers.get(index) ?? { arguments: "" };
+            if (tc.id) buf.id = tc.id;
+            if (tc.function?.name) buf.name = tc.function.name;
+            if (typeof tc.function?.arguments === "string") buf.arguments += tc.function.arguments;
+            toolCallBuffers.set(index, buf);
+          }
+        }
+
+        // 内容/思考内容
+        if (choice?.delta?.content) {
+          aiMessage.content += choice.delta.content;
+          //如果思考内容不为空,则计算推理时间
+          if (aiMessage.reasoningContent && aiMessage.reasoningTime === 0)
+            aiMessage.reasoningTime = Date.now() - startTime;
+        } else if (delta?.reasoning_content) {
+          aiMessage.reasoningContent += delta.reasoning_content;
+        }
+
+      } catch (error) {
+        // 解析失败不应打断整个对话
+        console.error("解析SSE数据失败:", error);
+      }
+    };
+
+    eventSource.onerror = (error) => {
+      console.error("SSE连接错误:", error);
+      eventSource?.close();
+
+      isLoading.value = false;
+      streamingAiMessageId = null;
+      aiMessage.content = "失败,请稍后重试。";
+      aiMessage.setError("请求错误", ChatUtils.formatError(error));
+      sessionManager.persistMessages([aiMessage]);
+    };
+
+    eventSource.stream();
+  }
+
+  /**
+   * 发送消息
+   * @param inputMessage - 输入消息
+   */
+  async function send(inputMessage: string) {
+    if (isLoading.value) return;
+
+    const userMessage = inputMessage.trim();
+    if (!userMessage) {
+      messagesManager.addMessage(staticMessagesManager.getEmptyMessage());
+      return;
+    }
+
+    interfaceManager.stopMessageEditing?.();
+
+    const newMessages = [
+      messagesManager.addMessage(ChatMessageModel.createUser(userMessage, LocalMessageIdPool.getNextId()))
+    ];
+    const attachmentItems = await interfaceManager.getAttachmentList?.() || [];
+    for (const item of attachmentItems) {
+      const m = await ChatMessageModel.createAttachment(item, LocalMessageIdPool.getNextId());
+      m.name = '你';
+      newMessages.push(messagesManager.addMessage(m));
+    }
+
+    // 保存用户消息与会话
+    try {
+      await sessionManager.saveUserMessageAndPersistSession(newMessages);
+
+    } catch (error) {
+      newMessages.forEach((m) => {
+        m.setError("保存会话失败,请稍后重试。", ChatUtils.formatError(error));
+      });
+      return;
+    }
+
+    await nextTick();
+
+    try {
+      isLoading.value = true;
+      await startStreamForUserMessages(newMessages.map((m) => m.id));
+    } catch (error) {
+      console.error("失败:", error);
+      isLoading.value = false;
+      streamingAiMessageId = null;
+      const message = messages.value[messages.value.length - 1];
+      if (message && !message.isUser) {
+        message.content = "失败,请稍后重试。";
+        message.setError("请求错误", ChatUtils.formatError(error));
+      }
+    }
+  }
+  /**
+   * 停止对话
+   */
+  function stop() {
+    failedAndSetInfo("手动停止对话", null);
+    closeStream();
+    isLoading.value = false;
+  }  
+
+  function failedAndSetInfo(message: string, error: any) {
+    isLoading.value = false;
+    if (streamingAiMessageId) {
+      const aiMessage = messagesManager.findMessage(streamingAiMessageId);
+      if (aiMessage) {
+        aiMessage.content = message;
+        aiMessage.setError(message, ChatUtils.formatError(error));
+        sessionManager.persistMessages([aiMessage]);
+      }
+    }
+    streamingAiMessageId = null;
+  }
+  function closeStream() {
+    if (eventSource) {
+      eventSource.close();
+      eventSource = null;
+    }
+  }
+  function prefindUserMessageIds(messageId: number) {
+    let limitHistoryStartAtMessageId: number | undefined;
+    const currentUserMessageIds = [] as number[];
+    for (let i = messages.value.findIndex(m => m.id === messageId); i >= 0; i--) {
+      const message = messages.value[i];
+      if (message.isUser)
+        currentUserMessageIds.push(message.id);
+    }
+    return {
+      currentUserMessageIds,
+      limitHistoryStartAtMessageId,
+    };
+  }
+
+  /**
+   * 用户编辑消息,重新发送
+   * @param messageId 
+   */
+  async function editMessage(messageId: number, newContent: string) {
+    const message = messagesManager.findMessage(messageId);
+    if (!message)
+      return;
+
+    message.content = newContent;
+    if (message.isPersisted)
+      await sessionManager.persistMessages([message]);
+ 
+    //向上查找,找到本轮次所有用户消息ID
+    const { currentUserMessageIds, limitHistoryStartAtMessageId } = prefindUserMessageIds(messageId);
+    if (message.replyItemId) {
+      //如果已有回复,则清空回复内容
+      const replyMessage = messagesManager.findMessage(message.replyItemId);
+      if (replyMessage) {
+        replyMessage.content = '';
+        replyMessage.reasoningContent = '';
+        replyMessage.resetError();
+      }
+      await startStreamForUserMessages(currentUserMessageIds, message.replyItemId, limitHistoryStartAtMessageId);
+    }
+    else
+      await startStreamForUserMessages(currentUserMessageIds, undefined, limitHistoryStartAtMessageId);
+  }
+
+  /**
+   * 重新生成AI消息
+   * @param messageId 
+   */
+  async function regenerateMessage(messageId: number) {
+    const message = messagesManager.findMessage(messageId);
+    if (!message)
+      return;
+    if (!message.isAssistant)
+      return;
+    message.content = '';
+    message.reasoningContent = '';
+    message.resetError();
+    //向上查找,找到本轮次所有用户消息ID
+    const { currentUserMessageIds, limitHistoryStartAtMessageId } = prefindUserMessageIds(messageId);
+    await startStreamForUserMessages(currentUserMessageIds, message.id, limitHistoryStartAtMessageId);
+  }
+
+  /**
+   * 设置参考消息并等待用户输入
+   * @param message - 参考消息
+   */
+  async function setReferenceAndWaitUser(message: string) {
+    referenceMessage.value = message;
+    interfaceManager.focusInput?.();
+  }
+
+  /**
+   * 清空参考消息
+   */
+  function clearReferenceMessage() {
+    referenceMessage.value = '';
+  }
+
+  onMounted(async () => {
+    if (sessionManager.enableSession) {
+      await sessionManager.loadSessions();
+      sessionManager.onSelectNew();
+    } else {
+      sessionManager.onSelectLocal();
+    }
+  });
+  onBeforeUnmount(() => {
+    stop();
+  });
+
+  return {
+    send,
+    stop,
+    editMessage,
+    regenerateMessage,
+    setReferenceAndWaitUser,
+    clearReferenceMessage,
+    config,
+    isLoading,
+    messages,
+    referenceMessage,
+    staticMessagesManager,
+    toolsManager,
+    messagesManager,
+    sessionManager,
+    interfaceManager,
+    toolCallsManager,
+    contextManager,
+  }
+
+}
+
+export type ChatManager = ReturnType<typeof useChat>;

+ 375 - 0
src/pages/chat/core/Context.ts

@@ -0,0 +1,375 @@
+import { ref, type Ref } from "vue";
+import type { ChatSendOptions } from "./Chat";
+import type OpenAI from "openai";
+import { ChatMessage } from "../model/Message";
+import type { ChatSessionManager } from "../composables/useChatSession";
+import { useSlideWindowMemoryStrategy } from "./strategy/ContextSlideWindowStrategy";
+import Agent from "@/api/agent/Agent";
+import AgentChatFile from "@/api/agent/AgentChatFile";
+import { requireNotNull } from "@imengyu/imengyu-utils";
+
+export type ContextMemorySetting = 'slide-window'|'long-summary';
+export type ContextMemoryConfig = {
+  slideWindowShortRelatedLookback?: number;
+  slideWindowShortUnrelatedLookback?: number;
+  slideWindowWindowMinRounds?: number;
+  slideWindowWindowMaxRounds?: number;
+  slideWindowHeadRounds?: number;
+  slideWindowTailRounds?: number;
+  longSummaryReservedRatio?: number;
+  longSummaryShortRounds?: number;
+  longSummaryStageRoundInterval?: number;
+  longSummaryLongMemoryMaxItems?: number;
+  minContextTokens?: number;
+  /**
+   * 推荐上下文token数量
+   * @default 32000
+   */
+  prefferTokens?: number;
+};
+
+export type RoundedMessages = {
+  userMessages: ChatMessage[];
+  aiMessages: ChatMessage[];
+};
+
+export type InnerContextManager =
+{
+  estimateMessageTokens: (message: ChatMessage) => number;
+  estimateMessagesTokens: (messages: ChatMessage[]) => number;
+  estimateRoundedMessagesTokens: (roundedMessages: RoundedMessages[]) => number;
+  getContextTokenBudget: (sendOptions: ChatSendOptions) => number;
+  pickMessagesWithBudgetFromTail: (list: ChatMessage[], maxTokens: number) => ChatMessage[];
+  getRoundedMessages: () => RoundedMessages[];
+  cutRoundedMessagesFromId: (groupedMessages: RoundedMessages[], id: number) => RoundedMessages[];
+  roundedMessagesToMessages: (roundedMessages: RoundedMessages[]) => ChatMessage[];
+};
+
+/**
+ * 聊天上下文管理
+ */
+export function useChatContext(options: {
+  message: Ref<ChatMessage[]>,
+  /**
+   * 上下文记忆设置
+   * @default 'slide-window'
+   * 
+   * * slide-window: 滑动窗口长时记忆 + 首尾保留,尽量记忆所有消息,通常用于对话讨论模式, 同时为了节省会尝试判断当前问题与上几条问题是否有关联,如果无关联,则不记忆。
+   * * long-summary: 分层记忆(三层):短期(最近轮次)/中期(阶段摘要)/长期(重要信息)。
+   */
+  contextMemorySetting?: ContextMemorySetting;
+  /**
+   * 上下文记忆参数配置
+   */
+  contextMemoryConfig?: ContextMemoryConfig,
+  /**
+   * 会话管理器
+   */
+  sessionManager: ChatSessionManager,
+}) {
+  const contextMemorySetting = options.contextMemorySetting || 'slide-window';
+  const contextMemoryConfig = {
+    prefferTokens: 32000,
+    shortRelatedLookback: 8,
+    shortUnrelatedLookback: 0,
+    slideWindowWindowMinRounds: 8,
+    slideWindowWindowMaxRounds: 10,
+    slideWindowHeadRounds: 1,
+    slideWindowTailRounds: 1,
+    longSummaryReservedRatio: 0.35,
+    longSummaryShortRounds: 6,
+    longSummaryStageRoundInterval: 6,
+    longSummaryLongMemoryMaxItems: 12,
+    minContextTokens: 512,
+    ...(options.contextMemoryConfig || {}),
+  } as Required<ContextMemoryConfig>;
+  const sessionManager = options.sessionManager;
+
+  /**
+   * 估算消息token数量
+   * @param message 
+   * @returns 
+   */
+  function estimateMessageTokens(message: ChatMessage): number {
+    if (message.type !== 'text')
+      return 24;
+    const text = `${message.content || ''}\n${message.reasoningContent || ''}\n${message.extra || ''}`;
+    const cjkCount = (text.match(/[\u4e00-\u9fff]/g) || []).length;
+    const nonCjkText = text.replace(/[\u4e00-\u9fff]/g, '');
+    const latinWordCount = (nonCjkText.match(/[A-Za-z0-9_]+/g) || []).length;
+    const punctuationCount = (nonCjkText.match(/[^\w\s]/g) || []).length;
+    // 粗略估算:中文按 1 字≈1 token,英文按 1 单词≈1 token,外加协议开销
+    return Math.max(8, cjkCount + latinWordCount + Math.ceil(punctuationCount / 2) + 6);
+  }
+  /**
+   * 估算消息列表token数量
+   * @param messages 消息列表
+   * @returns 
+   */
+  function estimateMessagesTokens(messages: ChatMessage[]): number {
+    return messages.reduce((sum, item) => sum + estimateMessageTokens(item), 0);
+  }
+  /**
+   * 估算消息列表token数量
+   * @param messages 消息列表
+   * @returns 
+   */
+  function estimateRoundedMessagesTokens(roundedMessages: RoundedMessages[]): number {
+    return roundedMessages.reduce((sum, item) => sum + estimateMessagesTokens(item.userMessages) + 
+      estimateMessagesTokens(item.aiMessages), 0);
+  }
+  /**
+   * 获取上下文token预算(默认为模型最大token的85%)
+   * @param sendOptions 发送选项
+   * @returns 
+   */
+  function getContextTokenBudget(sendOptions: ChatSendOptions): number {
+    const modelMaxTokens = sendOptions.modelInfo?.max_tokens || 0;
+    if (modelMaxTokens <= 0)
+      return 16000;
+    return Math.max(contextMemoryConfig.minContextTokens, Math.floor(modelMaxTokens * 0.85));
+  }
+  /**
+   * 从消息列表末尾开始,按token数量从大到小选择消息,直到超过预算为止
+   * @param list 消息列表
+   * @param maxTokens 预算
+   * @returns 消息列表
+   */
+  function pickMessagesWithBudgetFromTail(list: ChatMessage[], maxTokens: number): ChatMessage[] {
+    const selected: ChatMessage[] = [];
+    let used = 0;
+    for (let i = list.length - 1; i >= 0; i--) {
+      const item = list[i];
+      const itemTokens = estimateMessageTokens(item);
+      if (selected.length > 0 && used + itemTokens > maxTokens)
+        break;
+      selected.push(item);
+      used += itemTokens;
+    }
+    return selected.reverse();
+  }
+
+  /**
+   * 根据用户与AI消息的回复关系,将消息分组。
+   * 
+   * 消息通过parentId分组
+   */
+  function getRoundedMessages() {
+    const messages = options.message.value;
+    const groupedMessages: RoundedMessages[] = messages
+      .filter(m => m.parentId === 0 && m.isUser)
+      .map(m => ({
+        userMessages: [m].concat(messages.filter(k => k.parentId === m.id && k.isUser && (k.content))),
+        aiMessages: messages.filter(k => k.parentId === m.id && (k.isAssistant || k.isTool) && (k.content || k.reasoningContent)),
+      }));
+    return groupedMessages;
+  }
+  /**
+   * 从消息分组中截取指定ID开始至末尾的消息分组,包含该消息
+   * @param groupedMessages 消息分组
+   * @param id 消息ID
+   * @returns 消息分组
+   */
+  function cutRoundedMessagesFromId(groupedMessages: RoundedMessages[], id: number) {
+    for (let i = groupedMessages.length - 1; i >= 0; i--) {
+      const group = groupedMessages[i];
+      if (group.userMessages.some(m => m.id === id) || group.aiMessages.some(m => m.id === id)) {
+        return groupedMessages.slice(i);
+      }
+    }
+    return groupedMessages;
+  }
+  /**
+   * 将消息分组转换为消息列表
+   * @param roundedMessages 消息分组
+   * @returns 消息列表
+   */
+  function roundedMessagesToMessages(roundedMessages: RoundedMessages[]): ChatMessage[] {
+    return roundedMessages.flatMap(group => [...group.userMessages, ...group.aiMessages]);
+  }
+
+  const contextManager = {
+    estimateMessageTokens,
+    estimateMessagesTokens,
+    estimateRoundedMessagesTokens,
+    getContextTokenBudget,
+    pickMessagesWithBudgetFromTail,
+    getRoundedMessages,
+    cutRoundedMessagesFromId,
+    roundedMessagesToMessages,
+  };
+
+  /**
+   * 估算上下文token数量
+   * @default 0
+   */
+  const estimatedTokens = ref(0);
+  /**
+   * 估算上下文token使用量(百分比)
+   * @default 0
+   */
+  const estimatedTokenUseage = ref(0);
+  /**
+   * 估算上下文token最大数量
+   * @default 0
+   */
+  const estimatedTokenMax = ref(0);
+
+  /**
+   * 将会话消息转换为AI消息
+   * 
+   * 2. 如果上下文记忆设置为slide-window,则采用滑动窗口(8–10 轮)+ 首尾保留 模式。
+   *    根据当前问题与上几条问题是否有关联,有则只记忆最近几条消息。如果没有关联,则不记忆。
+   * 3. 如果上下文记忆设置为long-summary,则记忆所有消息,如果检测超出上下文,
+   *    则会在超出位置总结前面信息的摘要,并在截断位置之前创建摘要信息;
+   *    只传递到摘要为止,不传递到超出部分。
+   * 
+   * @param sendOptions 发送选项
+   * @param currentUserMessageIds 当前用户消息ID列表
+   * @param limitHistoryStartAtMessageId 限制历史消息的起始消息ID,包含该消息
+   * @returns AI消息
+   */
+  async function convertMessagesToAi(sendOptions: ChatSendOptions, currentUserMessageIds: number[], limitHistoryStartAtMessageId?: number) {
+    const maxTokens = getContextTokenBudget(sendOptions);
+    const modelInfo = requireNotNull(sendOptions.modelInfo, '模型信息不能为空');
+
+    //截取基础信息
+    let roundedMessages = getRoundedMessages();
+    if (limitHistoryStartAtMessageId)
+      roundedMessages = cutRoundedMessagesFromId(roundedMessages, limitHistoryStartAtMessageId);
+
+    //估算消息列表token数量,供界面显示
+    estimatedTokenMax.value = maxTokens;
+    estimatedTokens.value = estimateRoundedMessagesTokens(roundedMessages);
+    estimatedTokenUseage.value = Math.round((estimatedTokens.value / maxTokens) * 100);
+
+    //根据上下文记忆策略,获取用户消息和系统消息
+    let systemMessages: ChatMessage[] = [];
+
+    switch (contextMemorySetting) {
+      case 'slide-window':
+        roundedMessages = await useSlideWindowMemoryStrategy({
+          roundedMessages,
+          contextMemoryConfig,
+          maxTokens,
+          contextManager,
+        });
+        break;
+      case 'long-summary': {
+        //TODO: 实现长时记忆
+        /* userMessages = layeredMemoryResult.userMessages;
+        systemMessages = layeredMemoryResult.systemMessages;
+        await updateSessionSummary(layeredMemoryResult.summary, layeredMemoryResult.shortMemory, layeredMemoryResult.longMemory); */
+        break;
+      }
+      default:
+        throw new Error(`Unsupported context memory setting: ${contextMemorySetting}`);
+    }
+
+    //添加会话偏好
+    const currentSession = sessionManager.currentSession.value;
+    if (currentSession) {
+      if (currentSession.summary)
+        systemMessages.push(ChatMessage.createSystem(`[会话摘要]:${currentSession.summary}`));
+      if (currentSession.shortMemory)
+        systemMessages.push(ChatMessage.createSystem(`[短期偏好]:${currentSession.shortMemory}`));
+      if (currentSession.longMemory)
+        systemMessages.push(ChatMessage.createSystem(`[长期偏好]:${currentSession.longMemory}`));
+    }
+
+    // 合并一轮中用户消息多条多模态消息为单条合并。
+    // 如果用户上传了文件,则需要将文件上传至AI系统中,并获取文件ID,然后在系统提示词中添加文件ID。
+    const finalUserMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [];
+    const fileIds: string[] = [];
+    const finalMergeSystemMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [];
+    for (let i = 0; i < roundedMessages.length; i++) {
+      const group = roundedMessages[i];
+      const userMessages = group.userMessages.map(m => m.toAi() as OpenAI.Chat.ChatCompletionMessageParam | undefined).filter(item => !!item);
+      const hasMulitMedia = group.userMessages.some(m => m.isMedia);
+      const finalItem: OpenAI.Chat.ChatCompletionMessageParam = {
+        role: 'user',
+        content: '',
+      }
+      if (hasMulitMedia) {
+        for (const m of group.userMessages.filter(m => m.type === 'input_file' && m.files?.[0])) {
+          const file = m.files?.[0]!;
+          m.file_id = (await AgentChatFile.processChatFileToAi(file.id, sendOptions.model)).requireData();
+          fileIds.push(m.file_id);
+        }
+        finalItem.content = group.userMessages.map(m => {
+          const item : Record<string, any> = {
+            ...m.toAi()
+          };
+          delete (item as any).role;
+          if (!item.type && item.content) {
+            item.type = 'text';
+            item.text = item.content;
+            delete item.content;
+          }
+          return item as OpenAI.Chat.ChatCompletionContentPart;
+        });
+        //因为阿里百炼大模型不支持在消息内容中传递文件ID,所以需要将文件ID传递到系统提示词中。此处移除消息内容中的文件ID。
+        if (modelInfo.docunment_transfer_type !== 'in_message_content') {
+          finalItem.content = finalItem.content.filter(m => (m as any).type !== 'input_file');
+          if (finalItem.content.length == 1)
+            finalItem.content = (finalItem.content[0] as any).content;
+        } 
+      } else {
+        finalItem.content = userMessages.map(m => m.content).join('\n');
+      }
+      finalUserMessages.push(finalItem);
+      finalUserMessages.push(...group.aiMessages.map(m => m.toAi() as OpenAI.Chat.ChatCompletionMessageParam | undefined).filter(item => !!item));
+    }
+
+    //将文件ID传递到系统提示词中。
+    if (modelInfo.docunment_transfer_type === 'in_system_prompt') {
+      finalMergeSystemMessages.push({
+        role: 'system',
+        content: `${fileIds.map(id => `${modelInfo.docunment_transfer_key}${id}`).join(',')}`,
+      } as OpenAI.Chat.ChatCompletionSystemMessageParam);
+    }
+    return {
+      userMessages: finalUserMessages,
+      systemMessages: 
+        (systemMessages
+          .map((item) => item.toAi() as OpenAI.Chat.ChatCompletionMessageParam | undefined)
+          .filter((item) => !!item) as OpenAI.Chat.ChatCompletionMessageParam[]
+        )
+        .concat(finalMergeSystemMessages),
+    }
+  }
+
+  function estimateTokenUseage(sendOptions: ChatSendOptions) {
+    //估算消息列表token数量,供界面显示
+    const maxTokens = getContextTokenBudget(sendOptions);
+    const roundedMessages = getRoundedMessages();
+    estimatedTokenMax.value = maxTokens;
+    estimatedTokens.value = estimateRoundedMessagesTokens(roundedMessages);
+    estimatedTokenUseage.value = Math.round((estimatedTokens.value / maxTokens) * 100);
+  }
+
+  async function updateSessionSummary(summary: string, shortMemory: string, longMemory: string) {
+    const currentSession = options.sessionManager.currentSession.value;
+    if (!currentSession)
+      return;
+    if (currentSession.summary === summary &&
+      currentSession.shortMemory === shortMemory &&
+      currentSession.longMemory === longMemory)
+      return;
+    currentSession.summary = summary;
+    currentSession.shortMemory = shortMemory;
+    currentSession.longMemory = longMemory;
+    await options.sessionManager.updateSession(currentSession);
+  }
+
+  return {
+    estimatedTokens,
+    estimatedTokenUseage,
+    estimatedTokenMax,
+    convertMessagesToAi,
+    estimateTokenUseage,
+    ...contextManager,
+  };
+}
+
+export type ContextManager = ReturnType<typeof useChatContext>;

+ 4 - 0
src/pages/chat/core/Groups.ts

@@ -0,0 +1,4 @@
+export const ChatGroups = {
+  NOTE_EDITOR: -1,
+  DEFAULT: 0,
+} as const;

+ 75 - 0
src/pages/chat/core/Messages.ts

@@ -0,0 +1,75 @@
+import { ArrayUtils } from "@imengyu/imengyu-utils";
+import { ChatMessage as ChatMessageModel } from "../model/Message";
+import { reactive, type Ref } from "vue";
+import type OpenAI from "openai";
+
+
+export function useMessages(messages: Ref<ChatMessageModel[]>) {
+
+  function addMessage(message: ChatMessageModel) {
+    const wrappedMessage = reactive(message);
+    messages.value.push(wrappedMessage as ChatMessageModel);
+    return wrappedMessage as ChatMessageModel;
+  }
+  function removeMessage(message: ChatMessageModel) {
+    ArrayUtils.removeAt(messages.value, messages.value.findIndex(m => m.id === message.id));
+  }
+  function findMessage(id: number) {
+    return messages.value.find((m) => m.id === id) as ChatMessageModel | undefined;
+  }
+  function updateMessage(id: number, update: (message: ChatMessageModel) => void) {
+    const message = findMessage(id);
+    if (message) {
+      update(message);
+    } else {
+      console.warn(`Message with id ${id} not found`);
+    }
+  }
+  function clearMessages() {
+    messages.value = [];
+  }
+
+  return {
+    addMessage,
+    removeMessage,
+    findMessage,
+    updateMessage,
+    clearMessages,
+    messages,
+  }
+}
+
+export type ChatMessagesManager = ReturnType<typeof useMessages>;
+
+export const LocalMessageIdPool = {
+  id: 0,
+  getNextId: () => {
+    LocalMessageIdPool.id--;
+    return LocalMessageIdPool.id;
+  },
+}
+
+
+export function mergeSystemMessages(messages: OpenAI.Chat.ChatCompletionSystemMessageParam[]) {
+  if (!messages || messages.length === 0) return [];
+
+  // 将所有内容合并成一条消息,使用换行分隔
+  const combinedContent = messages
+    .map(msg => {
+      if (typeof msg.content === 'string')
+        return msg.content?.trim()
+       return msg.content.map(part => part.text).join('\n\n');
+    })
+    .filter(Boolean)
+    .join('\n\n');
+  
+  if (!combinedContent) 
+    return [];
+
+  return [
+    {
+      role: 'system',
+      content: combinedContent,
+    } as OpenAI.Chat.ChatCompletionSystemMessageParam
+  ];
+}

+ 41 - 0
src/pages/chat/core/StaticMessages.ts

@@ -0,0 +1,41 @@
+import { useAuthStore } from "@/store/auth";
+import { ChatMessage } from "../model/Message";
+import { LocalMessageIdPool } from "./Messages";
+import type { ChatConfig } from "./Chat";
+
+export function useChatStaticMessages(config: ChatConfig) {
+  const authStore = useAuthStore();
+
+  const EMPTY_MESSAGE = ChatMessage.createEphemeralAssistant(
+    '请输入您要提问的内容,让我知道您需要什么。',
+    LocalMessageIdPool.getNextId()
+  );
+
+  return {
+    getWelcomeMessage: () => {
+      let message = '你好!欢迎使用梦鱼的智能助手。发送消息开始聊天。';
+      let actions: string[] = [];
+      if (config.onBuildWelcome) {
+        const { welcomeMessage, welcomeActions } = config.onBuildWelcome();
+        message = welcomeMessage ?? message;
+        actions = welcomeActions ?? actions;
+      } else {
+        if (authStore.userInfo?.name) {
+          if (authStore.userInfo?.id === 1)
+            message = `尊敬的梦鱼可爱小主,欢迎😀 智能助手为您服务。可以问我任何问题。`;
+          else
+            message = `你好!${authStore.userInfo?.name},欢迎使用梦鱼的智能助手。发送消息开始聊天。`;
+        }
+      }
+      const messageObj = ChatMessage.createEphemeralAssistant(
+        message,
+        LocalMessageIdPool.getNextId()
+      );
+      messageObj.actions = actions;
+      return messageObj;
+    },
+    getEmptyMessage: () => EMPTY_MESSAGE,
+  }
+}
+
+export type ChatStaticMessagesManager = ReturnType<typeof useChatStaticMessages>;

+ 75 - 0
src/pages/chat/core/ToolCall.ts

@@ -0,0 +1,75 @@
+import { nextTick, type Ref } from "vue";
+import { LocalMessageIdPool, type ChatMessagesManager } from "./Messages";
+import { ChatMessage as ChatMessageModel } from "../model/Message";
+import { ChatUtils } from "../utils/ChatUtils";
+import type OpenAI from "openai"
+import type { ChatToolContext, ChatToolsManager } from "./Tools";
+import type { ChatInterfaceManager } from "./Chat";
+import type { ChatSessionManager } from "../composables/useChatSession";
+
+export function useToolCalls(
+  messagesManager: ChatMessagesManager,
+  toolsManager: ChatToolsManager,
+  interfaceManager: ChatInterfaceManager,
+  sessionManager: ChatSessionManager
+) {
+  /**
+   * 执行工具调用
+   */
+  async function executeToolCalls(toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[], parentMessageId: number) {
+    const ctx: ChatToolContext = {
+      messages: messagesManager.messages as Ref<ChatMessageModel[]>,
+      scrollToBottom: interfaceManager.scrollToBottom,
+    };
+
+    for (const call of toolCalls) {
+      if (call.type !== "function") continue;
+
+      const name = call.function.name;
+      const tool = toolsManager.toolRegistry.value.get(name);
+      const newToolMessageId = LocalMessageIdPool.getNextId();
+      const toolMsg = messagesManager.addMessage(ChatMessageModel.createTool(name, `调用工具 ${name}`, newToolMessageId) as ChatMessageModel);;
+      toolMsg.parentId = parentMessageId;
+
+      try {
+
+        if (!tool)
+          throw new Error(`未找到工具:${name}`);
+
+        let args: any = call.function.arguments;
+        if (typeof args === "string" && args.trim()) {
+          try {
+            args = JSON.parse(args);
+          } catch (error) { 
+            throw new Error('工具参数解析失败: ' +  (error as Error).message);
+          }
+        }
+        const rs = await tool.handler(args, ctx);
+        if (toolMsg) {
+          toolMsg.content += ' 成功'
+          toolMsg.extra = (typeof rs === "string" ? rs : JSON.stringify(rs, null, 2));
+          toolMsg.state = "success";
+        }
+      } catch (error) {
+        toolMsg.state = "error";
+        toolMsg.content += ' 失败';
+        toolMsg.setError("工具执行失败", ChatUtils.formatError(error));
+      }
+
+      if (toolMsg) {
+        try {
+          await sessionManager.persistMessages([toolMsg]);
+        } catch (error) {
+          console.error(error);
+        }
+      }
+
+      await nextTick();
+      await interfaceManager.scrollToBottom?.();
+    }
+  }
+
+  return {
+    executeToolCalls,
+  }
+}

+ 79 - 0
src/pages/chat/core/Tools.ts

@@ -0,0 +1,79 @@
+import type OpenAI from "openai";
+import { ref, computed, type Ref } from "vue";
+import type { ChatMessage } from "../model/Message";
+
+export type ChatToolContext = {
+  /**
+   * 当前会话消息列表(同 ChatHome 的 messages)
+   */
+  messages: Ref<ChatMessage[]>;
+  /**
+   * 触发一次滚动到底部(可选)
+   */
+  scrollToBottom?: () => void | Promise<void>;
+};
+
+export type ChatToolHandler = (args: any, ctx: ChatToolContext) => Promise<string | object> | string | object;
+
+export type ChatToolDefinition = {
+  /**
+   * OpenAI tools[].function.name
+   */
+  name: string;
+  /**
+   * OpenAI tools[].function.description
+   */
+  description?: string;
+  /**
+   * OpenAI tools[].function.parameters (JSON Schema)
+   */
+  parameters?: Record<string, any>;
+  /**
+   * 当模型触发 tool_calls 时执行
+   */
+  handler: ChatToolHandler;
+};
+
+export type ChatToolsManager = {
+  registerTool: (tool: ChatToolDefinition) => void;
+  unregisterTool: (name: string) => void;
+  clearTools: () => void;
+  toolRegistry: Ref<Map<string, ChatToolDefinition>>;
+  openAiTools: Ref<OpenAI.Chat.Completions.ChatCompletionTool[]>;
+};
+
+export function useChatTools(): ChatToolsManager {
+  const toolRegistry = ref(new Map<string, ChatToolDefinition>());
+  const openAiTools = computed<OpenAI.Chat.Completions.ChatCompletionTool[]>(() => {
+    const arr: OpenAI.Chat.Completions.ChatCompletionTool[] = [];
+    for (const [, def] of toolRegistry.value) {
+      arr.push({
+        type: "function",
+        function: {
+          name: def.name,
+          description: def.description,
+          parameters: def.parameters ?? { type: "object", properties: {} },
+        },
+      });
+    }
+    return arr;
+  });
+
+  function registerTool(tool: ChatToolDefinition) {
+    toolRegistry.value.set(tool.name, tool);
+  }
+  function unregisterTool(name: string) {
+    toolRegistry.value.delete(name);
+  }
+  function clearTools() {
+    toolRegistry.value.clear();
+  }
+
+  return {
+    registerTool,
+    unregisterTool,
+    clearTools,
+    toolRegistry,
+    openAiTools,
+  };
+}

+ 125 - 0
src/pages/chat/core/strategy/ContextSlideWindowStrategy.ts

@@ -0,0 +1,125 @@
+import type { InnerContextManager, ContextMemoryConfig, RoundedMessages } from "../Context";
+import AgentWorkApi from "@/api/agent/AgentWorks";
+
+export async function useSlideWindowMemoryStrategy(options: {
+  roundedMessages: RoundedMessages[];
+  contextMemoryConfig: Required<ContextMemoryConfig>,
+  maxTokens: number,
+  contextManager: InnerContextManager,
+}): Promise<RoundedMessages[]> {
+
+  const {
+    roundedMessages,
+    maxTokens,
+    contextManager,
+  } = options;
+  const {
+    slideWindowShortRelatedLookback,
+    slideWindowShortUnrelatedLookback,
+    slideWindowWindowMinRounds,
+    slideWindowWindowMaxRounds,
+    slideWindowHeadRounds,
+    slideWindowTailRounds,
+  } = options.contextMemoryConfig || {};
+
+  //如果消息列表token数量小于预算,则直接返回消息列表
+  if (contextManager.estimateRoundedMessagesTokens(roundedMessages) <= maxTokens)
+    return roundedMessages;
+
+  const totalRounds = roundedMessages.length;
+  const lastRounded = roundedMessages[totalRounds - 1];
+  const currentQuestion = lastRounded?.userMessages?.[0]?.content || '';
+
+  //检查当前问题与历史问题是否相关,若不相干,则按“短期/不记忆”处理
+  const historyRounds = roundedMessages.slice(0, -1);
+  const prevQuestions = historyRounds.map(r => r.userMessages?.[0]?.content || '').filter(Boolean);
+  const prevAnswers = historyRounds.map(r => (r.aiMessages || []).map(m => m.content).filter(Boolean).join('\n')).filter(Boolean);
+  const relatedLookback = Math.max(0, slideWindowShortRelatedLookback || 0);
+  const isRelated = relatedLookback <= 0
+    ? true
+    : await AgentWorkApi.checkQuestionRelated(
+      currentQuestion,
+      prevQuestions.slice(-relatedLookback),
+      prevAnswers.slice(-relatedLookback),
+    );
+  if (!isRelated) {
+    const unrelatedLookback = Math.max(0, slideWindowShortUnrelatedLookback || 0);
+    const keepRounds = Math.max(1, unrelatedLookback + 1);
+    return roundedMessages.slice(-keepRounds);
+  }
+
+  //采用滑动窗口模式,收尾保留
+  const headRounds = Math.max(0, slideWindowHeadRounds || 0);
+  const tailRounds = Math.max(0, slideWindowTailRounds || 0);
+  const minWindowRounds = Math.max(0, slideWindowWindowMinRounds || 0);
+  const maxWindowRounds = Math.max(minWindowRounds, slideWindowWindowMaxRounds || 0);
+
+  function buildCandidate(windowRounds: number): RoundedMessages[] {
+    const len = roundedMessages.length;
+    const safeHead = Math.min(headRounds, len);
+    const safeTail = Math.min(tailRounds, Math.max(0, len - safeHead));
+
+    const head = roundedMessages.slice(0, safeHead);
+    const tail = safeTail > 0 ? roundedMessages.slice(len - safeTail) : [];
+
+    // middle window is selected from the tail side (more recent first)
+    const availableMiddleEnd = len - safeTail;
+    const middleEnd = Math.max(safeHead, availableMiddleEnd);
+    const desired = Math.min(Math.max(0, windowRounds), Math.max(0, middleEnd - safeHead));
+    const middleStart = Math.max(safeHead, middleEnd - desired);
+    const middle = roundedMessages.slice(middleStart, middleEnd);
+
+    // de-duplicate by reference while keeping order
+    const res: RoundedMessages[] = [];
+    const seen = new Set<RoundedMessages>();
+    for (const g of [...head, ...middle, ...tail]) {
+      if (!seen.has(g)) {
+        seen.add(g);
+        res.push(g);
+      }
+    }
+    return res;
+  }
+
+  // 先按 maxWindowRounds 选择,超预算则逐步收缩
+  let windowRounds = Math.min(maxWindowRounds, totalRounds);
+  let candidate = buildCandidate(windowRounds);
+  while (windowRounds > minWindowRounds && contextManager.estimateRoundedMessagesTokens(candidate) > maxTokens) {
+    windowRounds--;
+    candidate = buildCandidate(windowRounds);
+  }
+
+  // 如果仍超预算,进一步从“中间/头部/尾部(最旧的尾部)”裁剪,确保至少保留最后一轮
+  while (candidate.length > 1 && contextManager.estimateRoundedMessagesTokens(candidate) > maxTokens) {
+    const len = candidate.length;
+    const last = candidate[len - 1];
+
+    // 优先裁掉更旧的中间段(避免伤害 tail)
+    const base = buildCandidate(windowRounds);
+    const headSet = new Set(base.slice(0, Math.min(headRounds, base.length)));
+    const tailSet = new Set(tailRounds > 0 ? base.slice(-Math.min(tailRounds, base.length)) : []);
+
+    let removeIndex = -1;
+    for (let i = 0; i < len - 1; i++) {
+      const g = candidate[i];
+      if (headSet.has(g) || tailSet.has(g)) continue;
+      removeIndex = i;
+      break;
+    }
+    // 其次裁掉 head(从末端裁,尽量保留更“头”的指令/设定)
+    if (removeIndex === -1) {
+      for (let i = len - 2; i >= 0; i--) {
+        const g = candidate[i];
+        if (tailSet.has(g)) continue;
+        removeIndex = i;
+        break;
+      }
+    }
+
+    if (removeIndex === -1) break;
+    const next = candidate.slice(0, removeIndex).concat(candidate.slice(removeIndex + 1));
+    candidate = next.length ? next : [last];
+  }
+  
+  return candidate;
+}

+ 442 - 0
src/pages/chat/model/Message.ts

@@ -0,0 +1,442 @@
+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 type OpenAI from "openai";
+import { StringUtils } from "@imengyu/imengyu-utils";
+
+export type ChatMessageType = 'text' | 'image_url' | 'input_audio' | 'input_file' | 'video' | 'video_url';
+export type ChatMessageState = 'loading' | 'success' | 'error';
+export type ChatMessageRole = "user" | "assistant" | "system" | "tool";
+
+export class ChatMessage {
+  id = 0;
+  /**
+   * 角色
+   */
+  role = 'assistant' as ChatMessageRole;
+  /**
+   * 状态
+   */
+  state = 'loading' as ChatMessageState;
+  /**
+   * type string (必选)
+
+    可选值:
+    text 输入文本时需设为text。
+    image_url 输入图片时需设为image_url。
+    input_audio 输入音频时需设为input_audio。
+    input_file 。
+    video 输入图片列表形式的视频时需设为video。
+    video_url 输入视频文件时需设为video_url。
+   */
+  type = 'text' as ChatMessageType;
+  /**
+   * 内容
+   */
+  content = '';
+  /** 
+   * 思考内容
+   */
+  reasoningContent = '';
+  /**
+   * 额外信息
+   */
+  extra?: string;
+  /**
+   * 回复ID,针对用户消息
+   */
+  replyItemId = 0;
+  /**
+   * 父级ID, 针对非用户消息,链接至用户消息
+   */
+  parentId = 0;
+  /**
+   * 推理时间ms
+   */
+  reasoningTime = 0;
+  /**
+   * 回复时间ms
+   */
+  replyTime = 0;
+  /**
+   * 回答AI名称
+   */
+  name = '';
+  /**
+   * 附件列表
+   */
+  files?: AgentChatFile[];
+
+  //以下为前端临时使用
+  //==========================================
+
+  /**
+   * tool 角色消息关联的 tool_call_id(仅前端临时使用)
+   */
+  tool_call_id?: string;
+  /**
+   * assistant 消息上携带的 tool_calls(仅前端临时使用)
+   */
+  toolCalls?: OpenAI.Chat.ChatCompletionMessageToolCall[];
+  /**
+   * 状态文本,仅用于临时界面显示,不保存数据库
+   */
+  statusText?: string;
+
+  timestamp = new Date() as Date;
+  /**
+   * 动作列表,用于界面用户操作
+   */
+  actions?: string[];
+
+  isEphemeral = false as boolean;
+  
+  /**
+   * 会话ID(本地新建尚未落库时可空)
+   */
+  historyId?: number;
+  /** 
+   * 后端聊天记录项 id(本地新建尚未落库时可空)
+   */
+  historyItemId?: number;
+
+  /**
+   * 输入的图片信息。当type为image_url时是必选参数。
+   */
+  image_url?: string;
+  /**
+   * 输入的音频信息。当type为input_audio时是必选参数。
+   */
+  input_audio?: {
+    /**
+     * 音频的 URL 或Base64 Data URL
+     */
+    data: string;
+    /**
+     * 输入音频的格式,如mp3、wav等。
+     */
+    format: string;
+  };
+  /**
+   * 输入的音频文件信息。当type为audio_url时是必选参数。
+   */
+  audio_url?: string;
+  /**
+   * 输入的视频信息。当type为video时是必选参数。
+   */
+  video?: string[];
+  /**
+   * 输入的视频文件信息。当type为video_url时是必选参数。
+   */
+  video_url?: string;
+  /**
+   * 输入的文件ID。当type为input_file时是必选参数。
+   */
+  file_id?: string;
+
+
+  get isReplied() {
+    return this.replyItemId > 0;
+  }
+  /**
+   * 是否已持久化
+   */
+  get isPersisted() {
+    return this.id > 0;
+  }
+  get isUser() {
+    return this.role === 'user';
+  }
+  get isAssistant() {
+    return this.role === 'assistant';
+  }
+  get isSystem() {
+    return this.role === 'system';
+  }
+  get isTool() {
+    return this.role === 'tool';
+  }
+  get isMedia() {
+    return this.type === 'image_url' || this.type === 'video' 
+    || this.type === 'video_url' || this.type === 'input_audio' || this.type === 'input_file';
+  }
+
+  constructor(props?: Partial<ChatMessage>) {
+    if (props)
+      Object.assign(this, props);
+  }
+
+  setError(title: string, error: string) {
+    this.statusText = title;
+    this.extra = error.trim() ? '```\n' + error + '\n```' : undefined;
+    this.state = 'error';
+  }
+  resetError() {
+    this.statusText = undefined;
+    this.extra = undefined;
+    this.state = 'loading';
+  }
+
+  static fromHistoryItem(item: AgentChatHistoryItem): ChatMessage {
+    const message = new ChatMessage();
+    message.role = item.role as ChatMessageRole;
+    message.timestamp = item.date;
+    message.historyId = item.historyId;
+    message.historyItemId = item.id;
+    message.replyItemId = item.replyItemId;
+    message.parentId = item.parentId;
+    message.reasoningTime = item.reasoningTime;
+    message.replyTime = item.replyTime;
+    message.name = item.name;
+    message.id = item.id;
+    message.content = item.content;
+    message.extra = item.extra;
+    message.reasoningContent = item.reasoningContent;
+    message.files = item.files;
+    message.state = item.state as ChatMessageState;
+    message.type = item.type as ChatMessageType;
+    switch (message.type) {
+      case 'image_url':
+        message.image_url = message.files?.[0]?.url || item.content;
+        break;
+      case 'video_url':
+        message.video_url = message.files?.[0]?.url || item.content;
+        break;
+      case 'input_audio':
+        message.input_audio = {
+          data: message.files?.[0]?.url || item.content,
+          format: StringUtils.path.getFileExt(message.files?.[0]?.url || '') || 'mp3',
+        };
+        break;
+      case 'video':
+        message.video = [ message.files?.[0]?.url || item.content ];
+        break;
+      case 'input_file':
+        message.content = item.content;
+        message.file_id = message.files?.[0]?.aiFileId || '';
+        break;
+      default:
+        message.content = item.content;
+        break;
+    }
+    return message;
+  }
+  toHistoryItem(): AgentChatHistoryItem {
+    const temp = new AgentChatHistoryItem().setSelfValues({
+      id: this.id > 0 ? this.id : undefined,
+      role: this.role,
+      state: this.state,
+      reasoningContent: this.reasoningContent,
+      content: this.content,
+      extra: this.extra,
+      type: this.type,
+      historyId: this.historyId,
+      date: this.timestamp,
+      replyItemId: this.replyItemId,
+      parentId: this.parentId,
+      reasoningTime: this.reasoningTime,
+      replyTime: this.replyTime,
+      name: this.name,
+    });
+    switch (this.type) {
+      case 'image_url':
+        temp.content = this.image_url || '';
+        break;
+      case 'input_audio':
+        temp.content = this.input_audio?.data || '';
+        break;
+      case 'input_file':
+        temp.content = this.content;
+        break;
+      case 'video':
+        //TODO: 多个文件数据
+        temp.content = this.video?.[0] || '';
+        break;
+      case 'video_url':
+        temp.content = this.video_url || '';
+        break;
+      default:
+        temp.content = this.content;
+        break;
+    }
+    return temp;
+  }
+  toAi() {
+    switch (this.role) {
+      case 'tool':
+        return {
+          role: 'tool',
+          tool_call_id: this.tool_call_id,
+          content: this.content + (this.extra ? `\n\n${this.extra}` : ''),
+        } as OpenAI.Chat.ChatCompletionToolMessageParam;
+      case 'assistant':
+        return {
+          role: 'assistant',
+          content: this.content || this.reasoningContent,
+          tool_calls: this.toolCalls?.length ? this.toolCalls : undefined,
+        } as OpenAI.Chat.ChatCompletionAssistantMessageParam;
+      case 'user': {
+        switch (this.type) {
+          case 'image_url':
+            return {
+              type: 'image_url',
+              role: 'user',
+              content: undefined as any,
+              image_url: {
+                url: this.image_url
+              },
+            } as OpenAI.Chat.ChatCompletionUserMessageParam;
+          case 'input_audio':
+            return {
+              type: 'input_audio',
+              role: 'user',
+              content: undefined as any,
+              input_audio: this.input_audio,
+            } as OpenAI.Chat.ChatCompletionUserMessageParam;
+          case 'input_file':
+            return {
+              type: 'input_file',
+              role: 'user',
+              content: undefined as any,
+              file_id: this.file_id,
+            } as OpenAI.Chat.ChatCompletionUserMessageParam;
+          case 'video':
+            return {
+              type: 'video',
+              role: 'user',
+              content: undefined as any,
+              video: this.video,
+            } as OpenAI.Chat.ChatCompletionUserMessageParam;
+          case 'video_url':
+            return {
+              type: 'video_url',
+              role: 'user',
+              content: undefined as any,
+              video_url: {
+                url: this.video_url,
+              },
+            } as OpenAI.Chat.ChatCompletionUserMessageParam;
+          default:
+            return {
+              role: 'user',
+              content: this.content,
+            } as OpenAI.Chat.ChatCompletionUserMessageParam;
+        }
+      }
+      case 'system':
+        return {
+          role: 'system',
+          content: this.content,
+        } as OpenAI.Chat.ChatCompletionSystemMessageParam;
+      default:
+        return undefined
+    }
+  }
+
+  static createUser(content: string, newId: number): ChatMessage {
+    const message = new ChatMessage();
+    message.content = content;
+    message.role = 'user';
+    message.state = 'success';
+    message.id = newId;
+    return message;
+  }
+
+  static async createAttachment(attachment: ChatAttachmentItem, newId: number): Promise<ChatMessage> {
+    const message = new ChatMessage();
+    message.content = attachment.name;
+    message.role = 'user';
+    message.files = [ new AgentChatFile().setSelfValues({
+      id: attachment.id,
+      path: attachment.path,
+      url: attachment.url,
+    }) ];
+    switch (attachment.type) {
+      case 'image':
+        message.type = 'image_url';
+        message.image_url = attachment.url;
+        break;
+      case 'audio':
+        message.type = 'input_audio';
+        message.input_audio = {
+          data: attachment.url || '',
+          format: StringUtils.path.getFileExt(attachment.url || '') || 'mp3',
+        };
+        break;
+      case 'video':
+        message.type = 'video';
+        message.video = [ attachment.url || '' ];
+        break;
+      case 'document':
+        message.type = 'input_file';
+        break;
+      case 'text': {
+        message.type = 'text';
+        const file = attachment.file;
+        if (file) {
+          const text = await file.text();
+          message.content = `## ${attachment.name}\n\`\`\`text\n` + text + '\n\`\`\`';
+        } else {
+          message.content = `[${attachment.name}](${attachment.url})`;
+        }
+        break;
+      }
+    }
+    message.timestamp = new Date();
+    message.state = 'success';
+    message.id = newId;
+    return message;
+  }
+
+  static createUserAudio(input_audio: string, newId: number): ChatMessage {
+    const message = new ChatMessage();
+    message.type = 'input_audio';
+    message.input_audio = {
+      data: input_audio,
+      format: StringUtils.path.getFileExt(input_audio) || 'mp3',
+    };
+    message.timestamp = new Date();
+    message.state = 'success';
+    message.id = newId;
+    return message;
+  }
+
+  static createAssistant(content: string, newId: number, state: ChatMessageState = 'success'): ChatMessage {
+    const message = new ChatMessage();
+    message.content = content;
+    message.role = 'assistant';
+    message.timestamp = new Date();
+    message.state = state;
+    message.id = newId;
+    return message;
+  }
+
+  static createEphemeralAssistant(content: string, newId: number): ChatMessage {
+    const message = new ChatMessage();
+    message.content = content;
+    message.role = 'assistant';
+    message.isEphemeral = true;
+    message.timestamp = new Date();
+    message.state = 'success';
+    message.id = newId;
+    return message;
+  }
+
+  static createSystem(content: string): ChatMessage {
+    const message = new ChatMessage();
+    message.content = content;
+    message.role = 'system';
+    message.timestamp = new Date();
+    return message;
+  }
+
+  static createTool(name: string, content: string, newId: number): ChatMessage {
+    const message = new ChatMessage();
+    message.content = content;
+    message.role = 'tool';
+    message.timestamp = new Date();
+    message.state = 'loading';
+    message.id = newId;
+    return message;
+  }
+}

+ 12 - 0
src/pages/chat/utils/ChatUtils.ts

@@ -0,0 +1,12 @@
+import { RequestApiError } from "@imengyu/imengyu-utils";
+
+export const ChatUtils = {
+  formatError(error: any) {
+    if (error instanceof RequestApiError) 
+      return error.toStringDetail();
+    if (error instanceof Error) 
+      return error.message;
+    return String(error);
+  },
+  
+}

+ 3 - 0
src/pages/home/post/agent.vue

@@ -0,0 +1,3 @@
+<template>
+  
+</template>

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

@@ -1,3 +1,168 @@
 <template>
-  
-</template>
+  <CommonRoot>
+    <FlexCol :innerStyle="{
+      backgroundImage: 'url(https://xy.wenlvti.net/app_static/images/dig/TopBanner.png)',
+      backgroundSize: '100% auto',
+      backgroundRepeat: 'no-repeat',
+      backgroundPosition: 'top center',
+      minHeight: '100vh',
+    }">
+      <StatusBarSpace />
+      <NavBar title="发布微信贴图" leftButton="back" rightButton="add" />
+      <FlexCol padding="space.lg">
+        <ProvideVar
+          :vars="{
+            FieldBackgroundColor: 'transparent',
+            UploaderListAddItemBackgroundImage: 'url(https://xy.wenlvti.net/app_static/images/village/ButtonUpload.png)',
+            UploaderAddIcon: '',
+          }"
+        >
+          <BackgroundBox
+            backgroundImage="https://xy.wenlvti.net/app_static/images/village/BoxLarge.png"
+            :backgroundCutBorder="[50,50,50,50]"
+            :backgroundCutBorderSize="[50,50,50,50]"
+            direction="column"
+            gap="gap.md"
+            :padding="[40, 30]"
+          >
+            <Uploader 
+              v-model="images" 
+              listType="grid"
+              :maxUploadCount="9" 
+              :upload="uploadImage"
+            />
+            <Field v-model="title" type="text" placeholder="请输入标题" :maxLength="30" showWordLimit bac />
+            <Field v-model="content" type="text" multiline placeholder="请输入内容" :maxLength="1000" rows="10" showWordLimit />
+          </BackgroundBox>
+        </ProvideVar>
+        <Height :height="50" />
+        <ImageButton
+          src="https://xy.wenlvti.net/app_static/images/village/ButtonPrimary.png"
+          text="发布"
+          width="500rpx"
+          height="80rpx"
+          @click="publish"
+        />
+      </FlexCol>
+
+      <BackgroundBox 
+        position="fixed" :inset="{ b: 0, l: 0, r: 0 }"
+        backgroundImage="https://xy.wenlvti.net/app_static/images/village/BoxLarge.png"
+        :backgroundCutBorder="[50,50,0,50]"
+        :backgroundCutBorderSize="[50,50,0,50]"
+        direction="column"
+        :padding="[20, 10]"
+      >
+        <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>
+          </FlexRow>
+          <FlexRow align="center" justify="center">
+            <IconButton
+              icon="setting"
+              width="40rpx"
+              height="40rpx"
+            />
+          </FlexRow>
+        </FlexRow>
+        <XBarSpace />
+      </BackgroundBox>
+    </FlexCol>
+    <Popup v-model="showAgentPopup">
+      <Agent />
+    </Popup>
+  </CommonRoot>
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref, watch } from 'vue';
+import { useLoadQuerys } from '@/components/composeabe/LoadQuerys';
+import { Debounce } from '@imengyu/imengyu-utils';
+import { confirm } from '@/components/dialog/CommonRoot';
+import CommonContent from '@/api/CommonContent';
+import CommonRoot from '@/components/dialog/CommonRoot.vue';
+import BackgroundBox from '@/components/display/block/BackgroundBox.vue';
+import Field from '@/components/form/Field.vue';
+import Uploader from '@/components/form/Uploader.vue';
+import FlexCol from '@/components/layout/FlexCol.vue';
+import StatusBarSpace from '@/components/layout/space/StatusBarSpace.vue';
+import NavBar from '@/components/nav/NavBar.vue';
+import ProvideVar from '@/components/theme/ProvideVar.vue';
+import type { UploaderAction } from '@/components/form/Uploader';
+import Height from '@/components/layout/space/Height.vue';
+import ImageButton from '@/components/basic/ImageButton.vue';
+import FlexRow from '@/components/layout/FlexRow.vue';
+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';
+
+const { querys } = useLoadQuerys({
+  tag: '',
+  villageId: 0,
+});
+
+const title = ref('');
+const content = ref('');
+const images = ref([] as string[]);
+
+const saveDraftDebunce = new Debounce(1000, () => {
+  uni.setStorageSync('postDraft', {
+    title: title.value,
+    content: content.value,
+    images: images.value,
+  });
+});
+
+watch(title, () => saveDraftDebunce.executeWithDelay());
+watch(content, () => saveDraftDebunce.executeWithDelay());
+watch(images, () => saveDraftDebunce.executeWithDelay());
+
+function loadDraft() {
+  const draft = uni.getStorageSync('postDraft');
+  if (draft) {
+    confirm({
+      content: '您有上次编辑未完成的草稿,是否要从上次的编辑数据继续?',
+      confirmText: '继续',
+      cancelText: '取消',
+    }).then((res) => {
+      if (res) {
+        title.value = draft.title;
+        content.value = draft.content;
+        images.value = draft.images;
+      }
+    });
+  }
+}
+function uploadImage(item: UploaderAction) {
+  item.onStart('正在上传');
+
+  let task: UniApp.UploadTask | null = null;
+  CommonContent.uploadFile(item.item.filePath, 'image', 'file', undefined, (_task) => {
+    task = _task;
+    task.onProgressUpdate((res) => {
+      item.onProgress(res.progress);
+    });
+  })
+    .then((res) => {
+      item.onFinish({
+        uploadedUrl: res.fullurl,
+        previewUrl: res.fullurl,
+      }, '上传成功');
+    })
+    .catch((err) => {
+      item.onError(err);
+    });
+  return () => {
+    task?.abort();
+  };
+}
+function publish() {
+  console.log('publish', title.value, content.value, images.value);
+}
+
+onMounted(() => {
+  loadDraft();
+});
+</script>