tmp
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import 'dotenv/config';
|
||||
import { createDeepAgent, FilesystemBackend } from "deepagents";
|
||||
import { AIMessageChunk, ToolMessage } from "langchain";
|
||||
import { HumanMessage } from "@langchain/core/messages";
|
||||
import { ChatDeepSeek } from "@langchain/deepseek";
|
||||
import config from '../../conf.json' with { type: 'json' };
|
||||
import logger from '../../utils/logger.js';
|
||||
|
||||
// 密钥由 conf.json 统一提供;百度地图 skill 通过 env_get 读环境变量,此处桥接
|
||||
process.env.TAVILY_API_KEY ??= config.agent.tavily?.apiKey;
|
||||
process.env.BAIDU_MAP_AUTH_TOKEN ??= config.agent.baiduMap?.authToken;
|
||||
import EscortAdminPrompts from "./prompts.js";
|
||||
import {
|
||||
getEnvTool, webFetchTool, webSearchTool, getCalendarInfoTool,
|
||||
getLunarCalendarInfoTool, getYearHolidaysTool, getYearTermsTool, getLatLngTool,
|
||||
httpGetTool, httpPostTool, escortRecordQueryTool, escortRecordSetTool
|
||||
} from "./tools/index.js";
|
||||
|
||||
export default class EscortAdminAgent {
|
||||
constructor() {
|
||||
// 按用户隔离会话:userId -> { agent, messages }
|
||||
this.sessions = new Map();
|
||||
this.maxSessions = 100;
|
||||
}
|
||||
|
||||
clearMessages(userInfo) {
|
||||
this.sessions.delete(userInfo._id);
|
||||
}
|
||||
|
||||
// msg: { agent: 'escort-admin', type: 'chat', ts: "2023-08-01 10:00:00", content: "你好" }
|
||||
async streamChat(userInfo, msgs, callback) {
|
||||
if (!msgs.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = userInfo._id;
|
||||
|
||||
// LRU:按用户取会话,并更新使用顺序
|
||||
let session = this.sessions.get(userId);
|
||||
if (session) {
|
||||
this.sessions.delete(userId);
|
||||
} else {
|
||||
session = { agent: null, messages: [] };
|
||||
}
|
||||
this.sessions.set(userId, session);
|
||||
|
||||
// 超出上限时淘汰最久未使用的会话,避免内存泄漏
|
||||
if (this.sessions.size > this.maxSessions) {
|
||||
const oldest = this.sessions.keys().next().value;
|
||||
this.sessions.delete(oldest);
|
||||
}
|
||||
|
||||
msgs.forEach(msg => {
|
||||
if (msg.type === "clear") {
|
||||
session.messages = [];
|
||||
session.agent = null;
|
||||
} else {
|
||||
session.messages.push(new HumanMessage(`${msg.ts} - ${msg.content}`));
|
||||
}
|
||||
});
|
||||
|
||||
if (session.messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = session.agent ?? this._genAgent(userInfo);
|
||||
session.agent = agent;
|
||||
|
||||
const INTERESTING_NODES = new Set(["model_request", "tools"]);
|
||||
for await (const [namespace, mode, data] of await agent.stream(
|
||||
{ messages: session.messages },
|
||||
{
|
||||
recursion_limit: 50,
|
||||
streamMode: ["updates", "messages", "custom"], subgraphs: true,
|
||||
configurable: {
|
||||
thread_id: userId
|
||||
}
|
||||
})) {
|
||||
const isSubagent = namespace.some(s => s.startsWith("tools:"));
|
||||
const source = isSubagent ? "subagent" : "main";
|
||||
if (mode === "updates") {
|
||||
// Main agent updates (empty namespace)
|
||||
if (namespace.length === 0) {
|
||||
for (const [nodeName, data_] of Object.entries(data)) {
|
||||
if (!INTERESTING_NODES.has(nodeName)) continue;
|
||||
if (nodeName === "tools") {
|
||||
// 工具结果必须并入历史,否则下一轮会出现 tool_calls 缺少对应 tool 结果的断链
|
||||
for (const msg of data_.messages ?? []) {
|
||||
if (msg.type === "tool") {
|
||||
session.messages.push(msg);
|
||||
logger.info(`Tool complete: ${msg.name}, Result: ${String(msg.content).slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
} else if (nodeName === "model_request") {
|
||||
session.messages.push(...data_.messages);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Subagent updates (non-empty namespace)
|
||||
for (const nodeName of Object.keys(data)) {
|
||||
if (!INTERESTING_NODES.has(nodeName)) continue;
|
||||
logger.info(`[${namespace[0]}] step: ${nodeName}`);
|
||||
}
|
||||
}
|
||||
} else if (mode === "messages") {
|
||||
const [message] = data;
|
||||
if (message.tool_call_chunks?.length) {
|
||||
continue;
|
||||
}
|
||||
if (AIMessageChunk.isInstance(message)) {
|
||||
if (message.text) {
|
||||
callback(source, "ai", message.text, message.id);
|
||||
}
|
||||
if (message.additional_kwargs.reasoning_content) {
|
||||
callback(source, "reasoning", message.additional_kwargs.reasoning_content, message.id);
|
||||
}
|
||||
}
|
||||
if (ToolMessage.isInstance(message) && message.text) {
|
||||
callback(source, "tool", message.text, message.id);
|
||||
}
|
||||
} else if (mode === "custom") {
|
||||
logger.info("custom: ", data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_genAgent(userInfo) {
|
||||
const rootDir = process.cwd();
|
||||
const backend = new FilesystemBackend({ rootDir });
|
||||
|
||||
this.flashModel = new ChatDeepSeek({
|
||||
model: config.agent.deepseek.flashModel,
|
||||
apiKey: config.agent.deepseek.apiKey,
|
||||
temperature: 0.0
|
||||
});
|
||||
this.proModel = new ChatDeepSeek({
|
||||
model: config.agent.deepseek.proModel,
|
||||
apiKey: config.agent.deepseek.apiKey,
|
||||
temperature: 0.3
|
||||
});
|
||||
|
||||
const escortRecordOperSubagent = {
|
||||
name: "escort-record-oper-subagent",
|
||||
description: "查询和设置陪诊预约记录",
|
||||
systemPrompt: "根据用户指令,调用工具完成查询和设置陪诊记录。",
|
||||
model: this.flashModel,
|
||||
tools: [escortRecordQueryTool, escortRecordSetTool],
|
||||
};
|
||||
|
||||
const escortResearchSubagent = {
|
||||
name: "escort-research-subagent",
|
||||
description: "陪诊(陪同就医)行业问题研究和解答",
|
||||
systemPrompt: "你是陪诊(陪同就医)行业政策、发展趋势、行业知识研究和解答专家。",
|
||||
model: this.proModel,
|
||||
tools: [webFetchTool, webSearchTool],
|
||||
};
|
||||
|
||||
return createDeepAgent({
|
||||
name: "deep-agent",
|
||||
model: this.flashModel,
|
||||
systemPrompt: EscortAdminPrompts.buildSystemPrompt(userInfo),
|
||||
backend,
|
||||
tools: [getEnvTool, webFetchTool, webSearchTool, getLatLngTool, httpGetTool, httpPostTool,
|
||||
getCalendarInfoTool, getLunarCalendarInfoTool, getYearHolidaysTool, getYearTermsTool,
|
||||
escortRecordQueryTool, escortRecordSetTool],
|
||||
subagents: [escortRecordOperSubagent, escortResearchSubagent]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const adminAgent = new EscortAdminAgent();
|
||||
export { adminAgent };
|
||||
@@ -0,0 +1,33 @@
|
||||
import services from "../../resource/services.js";
|
||||
import agreement from "../../resource/agreement.js";
|
||||
|
||||
class EscortAdminPrompts {
|
||||
static buildSystemPrompt(userInfo) {
|
||||
let userInfo_str = "用户未登录,提示用户先登录,并在'我的'中完善个人信息";
|
||||
if (userInfo) {
|
||||
userInfo_str = JSON.stringify({
|
||||
name: userInfo.profile.name,
|
||||
mobile: userInfo.profile.mobile,
|
||||
role: userInfo.app
|
||||
});
|
||||
}
|
||||
|
||||
return `
|
||||
# 角色定义
|
||||
你是小暖,暖橙陪诊平台的管理助手,直接、高效的解决平台问题。
|
||||
|
||||
# 核心能力
|
||||
- 解答服务流程、价格、注意事项
|
||||
- 提供就诊准备建议
|
||||
- 通过tools和skills,为用户提供其他服务。如:查询天气、路线、查询医院信息、查询医生信息等。
|
||||
|
||||
# 铁律(必须遵守)
|
||||
1. 思考和理解任务意图,专业、谨慎、高效、专注的完成任务。
|
||||
|
||||
## 参考信息
|
||||
管理员信息:${userInfo_str};
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export default EscortAdminPrompts;
|
||||
@@ -0,0 +1,16 @@
|
||||
# 用户记忆管理
|
||||
|
||||
## 记忆目录
|
||||
`/memories/` 目录用于持久化存储用户信息,在每次会话开始时自动加载。
|
||||
|
||||
## 长期记忆
|
||||
当用户分享以下信息时,使用 `write_file` 将其保存到 `/memories/user_memory.txt`:
|
||||
- 个人基本信息、生活习惯、个人喜好
|
||||
- 健康或医疗相关的任何信息(身体健康、看病、住院、手术、病情、用药、过敏、体质、病历、检查报告、长期健康目标等)
|
||||
- 医疗信息要记录对应的日期时间,如果用户没有提供具体的,要根据前后信息记录大概时间。
|
||||
- 如果有些信息需要准确的时间点,请跟用户确认时间后记录下来。
|
||||
|
||||
## 维护规范
|
||||
- 文件内容使用 UTF-8 编码
|
||||
- 每次写入时,将新内容与已有记忆合并整理后再保存
|
||||
- 为保护用户隐私,除非用户询问自己的健康或医疗相关资料,否则/memories/user_memory.txt里的信息不轻易输出给用户。
|
||||
@@ -0,0 +1,206 @@
|
||||
import 'dotenv/config';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createDeepAgent, FilesystemBackend, CompositeBackend } from "deepagents";
|
||||
import { ChatOpenAI } from "@langchain/openai";
|
||||
import { AIMessageChunk, ToolMessage } from "langchain";
|
||||
import { HumanMessage } from "@langchain/core/messages";
|
||||
import { ChatDeepSeek } from "@langchain/deepseek";
|
||||
import config from '../../conf.json' with { type: 'json' };
|
||||
import logger from '../../utils/logger.js';
|
||||
|
||||
// 密钥由 conf.json 统一提供;百度地图 skill 通过 env_get 读环境变量,此处桥接
|
||||
process.env.TAVILY_API_KEY ??= config.agent.tavily?.apiKey;
|
||||
process.env.BAIDU_MAP_AUTH_TOKEN ??= config.agent.baiduMap?.authToken;
|
||||
import Prompts from "./prompts.js";
|
||||
import {
|
||||
getEnvTool, webFetchTool, webSearchTool, getCalendarInfoTool,
|
||||
getLunarCalendarInfoTool, getYearHolidaysTool, getYearTermsTool, getLatLngTool,
|
||||
httpGetTool, httpPostTool, createEscortRecordQueryTool
|
||||
} from "./tools/index.js";
|
||||
|
||||
export default class EscortAgent {
|
||||
// 历史消息字符数上限,超出时从头部丢弃完整轮次
|
||||
static MAX_HISTORY_CHARS = config.agent.maxHistoryChars || 40000;
|
||||
|
||||
constructor() {
|
||||
this.messages = [];
|
||||
}
|
||||
|
||||
clearMessages() {
|
||||
this.messages = [];
|
||||
}
|
||||
|
||||
// 按字符数裁剪历史:只以 HumanMessage 为界从头部删除完整轮次,
|
||||
// 避免切断 AI(tool_calls) -> Tool 的链式结构;永远保留最新一轮
|
||||
_trimHistory() {
|
||||
const size = (msgs) => msgs.reduce((sum, m) => sum + (m.text?.length ?? 0), 0);
|
||||
if (size(this.messages) <= EscortAgent.MAX_HISTORY_CHARS) {
|
||||
return;
|
||||
}
|
||||
|
||||
const starts = [];
|
||||
this.messages.forEach((m, i) => {
|
||||
if (HumanMessage.isInstance(m)) starts.push(i);
|
||||
});
|
||||
if (starts.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cut = 0;
|
||||
for (let i = 0; i < starts.length - 1; i++) {
|
||||
cut = starts[i + 1];
|
||||
if (size(this.messages.slice(cut)) <= EscortAgent.MAX_HISTORY_CHARS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.messages = this.messages.slice(cut);
|
||||
logger.info(`History trimmed to ${this.messages.length} messages, ${size(this.messages)} chars`);
|
||||
}
|
||||
|
||||
// msg: { ts: "2023-08-01 10:00:00", content: "你好" }
|
||||
async streamChat(userInfo, msgs, callback) {
|
||||
if (!msgs.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
msgs.forEach(msg => {
|
||||
if (msg.type === "clear") {
|
||||
this.messages = [];
|
||||
this.agent = null;
|
||||
} else {
|
||||
this.messages.push(new HumanMessage(`${msg.ts} - ${msg.content}`));
|
||||
}
|
||||
});
|
||||
|
||||
if (this.messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._trimHistory();
|
||||
|
||||
// 在处理完 clear 消息后再生成 agent,确保清空后使用新实例
|
||||
const agent = this._genAgent(userInfo);
|
||||
const INTERESTING_NODES = new Set(["model_request", "tools"]);
|
||||
for await (const [namespace, mode, data] of await agent.stream(
|
||||
{ messages: this.messages },
|
||||
{
|
||||
recursion_limit: 50,
|
||||
streamMode: ["updates", "messages", "custom"], subgraphs: true,
|
||||
configurable: {
|
||||
thread_id: msgs[0].userId || msgs[0].appId
|
||||
}
|
||||
})) {
|
||||
const isSubagent = namespace.some(s => s.startsWith("tools:"));
|
||||
const source = isSubagent ? "subagent" : "main";
|
||||
if (mode === "updates") {
|
||||
// Main agent updates (empty namespace)
|
||||
if (namespace.length === 0) {
|
||||
for (const [nodeName, data_] of Object.entries(data)) {
|
||||
if (!INTERESTING_NODES.has(nodeName)) continue;
|
||||
if (nodeName === "tools") {
|
||||
// 工具结果必须并入历史,否则下一轮会出现 tool_calls 缺少对应 tool 结果的断链
|
||||
for (const msg of data_.messages ?? []) {
|
||||
if (msg.type === "tool") {
|
||||
this.messages.push(msg);
|
||||
logger.info(`Tool complete: ${msg.name}, Result: ${String(msg.content).slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
} else if (nodeName === "model_request") {
|
||||
this.messages.push(...data_.messages);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Subagent updates (non-empty namespace)
|
||||
for (const nodeName of Object.keys(data)) {
|
||||
if (!INTERESTING_NODES.has(nodeName)) continue;
|
||||
logger.info(`[${namespace[0]}] step: ${nodeName}`);
|
||||
}
|
||||
}
|
||||
} else if (mode === "messages") {
|
||||
const [message, metadata] = data;
|
||||
if (message.tool_call_chunks?.length) {
|
||||
continue;
|
||||
}
|
||||
if (metadata?.lcSource === "summarization" || metadata?.lc_source === "summarization") {
|
||||
continue;
|
||||
}
|
||||
if (AIMessageChunk.isInstance(message)) {
|
||||
if (message.text) {
|
||||
callback(source, "ai", message.text, message.id);
|
||||
}
|
||||
if (message.additional_kwargs.reasoning_content) {
|
||||
callback(source, "reasoning", message.additional_kwargs.reasoning_content, message.id);
|
||||
}
|
||||
}
|
||||
if (ToolMessage.isInstance(message) && message.text) {
|
||||
callback(source, "tool", message.text, message.id);
|
||||
}
|
||||
} else if (mode === "custom") {
|
||||
logger.info("custom: ", data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_genAgent(userInfo) {
|
||||
const rootDir = process.cwd();
|
||||
const memoryFile = userInfo
|
||||
? path.join(rootDir, "data", userInfo._id, "memories", "user_memory.txt")
|
||||
: null;
|
||||
|
||||
// 会话中记忆文件被更新后重建 agent,使 systemPrompt 中的用户记忆保持最新
|
||||
if (this.agent && this._memoryMtime !== this._statMemory(memoryFile)) {
|
||||
this.agent = null;
|
||||
}
|
||||
if (this.agent) {
|
||||
return this.agent;
|
||||
}
|
||||
|
||||
let backend = new FilesystemBackend({ rootDir });
|
||||
|
||||
if (userInfo) {
|
||||
const userMemoryPath = path.join(rootDir, "data", userInfo._id, "memories");
|
||||
backend = new CompositeBackend(
|
||||
new FilesystemBackend({ rootDir }),
|
||||
{
|
||||
"/memories/": new FilesystemBackend({
|
||||
rootDir: userMemoryPath,
|
||||
virtualMode: true
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
this.flashModel = new ChatDeepSeek({
|
||||
model: config.agent.deepseek.flashModel,
|
||||
apiKey: config.agent.deepseek.apiKey,
|
||||
temperature: 0.0
|
||||
});
|
||||
|
||||
this._memoryMtime = this._statMemory(memoryFile);
|
||||
|
||||
this.agent = createDeepAgent({
|
||||
name: "deep-agent",
|
||||
model: this.flashModel,
|
||||
systemPrompt: Prompts.buildSystemPrompt(userInfo),
|
||||
memory: ["./agent/escort/AGENTS.md"],
|
||||
backend,
|
||||
tools: [getEnvTool, webFetchTool, webSearchTool, getLatLngTool, httpGetTool, httpPostTool,
|
||||
getCalendarInfoTool, getLunarCalendarInfoTool, getYearHolidaysTool, getYearTermsTool,
|
||||
createEscortRecordQueryTool(userInfo)],
|
||||
skills: ["./agent/escort/skills/"],
|
||||
});
|
||||
|
||||
return this.agent;
|
||||
}
|
||||
|
||||
// 返回记忆文件 mtime,文件不存在时返回 null
|
||||
_statMemory(memoryFile) {
|
||||
if (!memoryFile) return null;
|
||||
try {
|
||||
return fs.statSync(memoryFile).mtimeMs;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import services from "../../resource/services.js";
|
||||
import agreement from "../../resource/agreement.js";
|
||||
import logger from "../../utils/logger.js";
|
||||
|
||||
class Prompts {
|
||||
static buildSystemPrompt(userInfo) {
|
||||
let userInfo_str = "用户未登录,提示用户先登录,并在'我的'中完善个人信息";
|
||||
let usermem_str = "";
|
||||
if (userInfo) {
|
||||
userInfo_str = JSON.stringify({
|
||||
name: userInfo.profile.name,
|
||||
mobile: userInfo.profile.mobile,
|
||||
sex: userInfo.profile.sex,
|
||||
birth: userInfo.profile.birth,
|
||||
province: userInfo.location.province,
|
||||
city: userInfo.location.city,
|
||||
address: userInfo.addresses || [],
|
||||
});
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const userMemoryPath = path.join(rootDir, "data", userInfo._id, "memories", "user_memory.txt");
|
||||
try {
|
||||
usermem_str = fs.readFileSync(userMemoryPath, 'utf8');
|
||||
} catch (err) {
|
||||
logger.error('读取用户记忆失败', err);
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
# 角色定义
|
||||
你是小橙,为需要就医和陪诊的用户提供咨询和情绪价值。温暖、共情、简洁、专业。
|
||||
|
||||
# 能力
|
||||
- 基于服务项目和服务协议,解答陪诊流程、价格、注意事项
|
||||
- 提供就医规划,就诊准备建议,情绪价值(不提供医疗诊断)
|
||||
- 就诊准备要专业,符合医疗规范
|
||||
- 调用工具查询天气、路线、医院、医生等信息
|
||||
|
||||
# 铁律
|
||||
1. 服务相关回答必须基于参考信息,不编造。
|
||||
2. 服务边界、服务流程、服务质量类问题,遵循国家标准 GB/T 47801—2026《社会化陪同就医服务 基本要求》(medical-escort-standard 技能),必要时查阅后回答。
|
||||
3. 感知用户情绪,给予共情回应。
|
||||
4. 涉及医疗问题,提醒以医生诊断为准。
|
||||
5. 超出能力范围,引导联系客服。
|
||||
6. 保护用户隐私,提示AI内容需甄别。
|
||||
|
||||
## 参考信息
|
||||
用户信息:${userInfo_str};
|
||||
用户记忆:${usermem_str};
|
||||
服务项目:${JSON.stringify(services)};
|
||||
服务协议:${JSON.stringify(agreement)};
|
||||
联系电话: 18618162956(微信同号)
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export default Prompts;
|
||||
@@ -0,0 +1,43 @@
|
||||
import EscortAgent from "./agent.js";
|
||||
|
||||
class ChatTask {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
modelProvider: options.modelProvider || "deepseek",
|
||||
apiKey: options.apiKey,
|
||||
baseURL: options.baseURL,
|
||||
modelName: options.modelName,
|
||||
temperature: options.temperature ?? 0.7,
|
||||
maxIterations: options.maxIterations || 10,
|
||||
};
|
||||
|
||||
this.agents = new Map();
|
||||
this.maxAgents = options.maxAgents || 100;
|
||||
}
|
||||
|
||||
async streamChat(userInfo, message, callback) {
|
||||
const userId = userInfo ? userInfo._id : message.appId;
|
||||
|
||||
let agent = this.agents.get(userId);
|
||||
if (agent) {
|
||||
// LRU:重新插入以更新使用顺序
|
||||
this.agents.delete(userId);
|
||||
} else {
|
||||
agent = new EscortAgent();
|
||||
}
|
||||
this.agents.set(userId, agent);
|
||||
|
||||
// 超出上限时淘汰最久未使用的 Agent,避免内存泄漏
|
||||
if (this.agents.size > this.maxAgents) {
|
||||
const oldest = this.agents.keys().next().value;
|
||||
this.agents.delete(oldest);
|
||||
}
|
||||
|
||||
return agent.streamChat(userInfo, [message], callback);
|
||||
}
|
||||
}
|
||||
|
||||
const chatTask = new ChatTask();
|
||||
|
||||
export { ChatTask, chatTask };
|
||||
export default chatTask;
|
||||
Reference in New Issue
Block a user