feat: implement AI agent for flowme-life interactions

- Add agent-run module to handle AI interactions with tools and messages.
- Create routes for proxying requests to OpenAI and Anthropic APIs.
- Implement flowme-life chat route for user queries and task management.
- Add services for retrieving and updating life records in the database.
- Implement logic for fetching today's tasks and marking tasks as done with next execution time calculation.
- Introduce tests for flowme-life functionalities.
This commit is contained in:
2026-03-11 01:44:29 +08:00
parent 027cbecab6
commit 66a19139b7
22 changed files with 5190 additions and 676 deletions

View File

@@ -0,0 +1,44 @@
import { schema, app, cnb, models } from '@/app.ts'
import z from 'zod';
import { runAgent } from '@kevisual/ai/agent'
app.route({
path: 'flowme-life',
key: 'chat',
description: `聊天接口, 对自己的数据进行操作,参数是 question或messagesquestion是用户的提问messages是对话消息列表优先级高于 question`,
middleware: ['auth']
, metadata: {
args: {
question: z.string().describe('用户的提问'),
messages: z.any().optional().describe('对话消息列表,优先级高于 question'),
}
}
}).define(async (ctx) => {
const question = ctx.query.question || '';
const _messages = ctx.query.messages;
const token = ctx.query.token || '';
if (!question && !_messages) {
ctx.throw(400, '缺少参数 question 或 messages');
}
const routes = ctx.app.getList().filter(r => r.path.startsWith('flowme-life') && r.key !== 'chat');
const messages = _messages || [
{
"role": "system" as const,
"content": `你是我的智能助手,协助我操作我的数据, 请根据我的提问选择合适的接口进行调用。`
},
{
"role": "user" as const,
"content": question
}
]
const res = await runAgent({
app: app,
messages: messages,
languageModel: cnb(models['auto']),
// query: 'WHERE path LIKE 'flowme-life%',
routes,
token,
});
ctx.body = res
}).addTo(app);

View File

@@ -1 +1,3 @@
import './list.ts'
import './today.ts'
import './chat.ts'

View File

@@ -0,0 +1,38 @@
import { eq } from 'drizzle-orm';
import { schema, db } from '@/app.ts';
export type LifeItem = typeof schema.life.$inferSelect;
/**
* 根据 id 获取 life 记录
*/
export async function getLifeItem(id: string): Promise<{ code: number; data?: LifeItem; message?: string }> {
try {
const result = await db.select().from(schema.life).where(eq(schema.life.id, id)).limit(1);
if (result.length === 0) {
return { code: 404, message: `记录 ${id} 不存在` };
}
return { code: 200, data: result[0] };
} catch (e) {
return { code: 500, message: `获取记录 ${id} 失败: ${e?.message || e}` };
}
}
/**
* 更新 life 记录的 effectiveAt下次执行时间
*/
export async function updateLifeEffectiveAt(id: string, effectiveAt: string | Date): Promise<{ code: number; data?: LifeItem; message?: string }> {
try {
const result = await db
.update(schema.life)
.set({ effectiveAt: new Date(effectiveAt) })
.where(eq(schema.life.id, id))
.returning();
if (result.length === 0) {
return { code: 404, message: `记录 ${id} 不存在` };
}
return { code: 200, data: result[0] };
} catch (e) {
return { code: 500, message: `更新记录 ${id} 失败: ${e?.message || e}` };
}
}

View File

@@ -1,6 +1,7 @@
import { desc, eq, count, or, like, and } from 'drizzle-orm';
import { schema, app, db } from '@/app.ts'
import z from 'zod';
import dayjs from 'dayjs';
app.route({
path: 'flowme-life',
key: 'list',
@@ -72,7 +73,7 @@ app.route({
link: z.string().describe('链接').optional(),
data: z.record(z.string(), z.any()).describe('数据').optional(),
effectiveAt: z.string().describe('生效日期').optional(),
type: z.string().describe('类型').optional(),
type: z.string().describe('类型: 智能, 每年农历, 备忘, 归档等.默认智能').optional(),
prompt: z.string().describe('提示词').optional(),
taskType: z.string().describe('任务类型').optional(),
taskResult: z.record(z.string(), z.any()).describe('任务结果').optional(),
@@ -82,6 +83,11 @@ app.route({
}).define(async (ctx) => {
const { uid, updatedAt, createdAt, ...rest } = ctx.query.data || {};
const tokenUser = ctx.state.tokenUser;
if (rest.effectiveAt && isNaN(Date.parse(rest.effectiveAt))) {
rest.effectiveAt = null;
} else if (rest.effectiveAt) {
rest.effectiveAt = dayjs(rest.effectiveAt).toISOString();
}
const lifeItem = await db.insert(schema.life).values({
title: rest.title || '',
summary: rest.summary || '',
@@ -90,7 +96,7 @@ app.route({
link: rest.link || '',
data: rest.data || {},
effectiveAt: rest.effectiveAt || '',
type: rest.type || '',
type: rest.type || '智能',
prompt: rest.prompt || '',
taskType: rest.taskType || '',
taskResult: rest.taskResult || {},
@@ -103,7 +109,7 @@ app.route({
path: 'flowme-life',
key: 'update',
middleware: ['auth'],
description: '更新一个 flowme-life',
description: '更新一个 flowme-life 的数据',
metadata: {
args: {
data: z.object({
@@ -135,6 +141,11 @@ app.route({
if (existing[0].uid !== tokenUser.id) {
ctx.throw(403, '没有权限更新该 flowme-life');
}
if (rest.effectiveAt && isNaN(Date.parse(rest.effectiveAt))) {
rest.effectiveAt = null;
} else if (rest.effectiveAt) {
rest.effectiveAt = dayjs(rest.effectiveAt).toISOString();
}
const lifeItem = await db.update(schema.life).set({
title: rest.title,
summary: rest.summary,
@@ -155,17 +166,15 @@ app.route({
path: 'flowme-life',
key: 'delete',
middleware: ['auth'],
description: '删除 flowme-life',
description: '删除单个 flowme-life, 参数: id 必填',
metadata: {
args: {
data: z.object({
id: z.string().describe('ID'),
})
id: z.string().describe('ID'),
}
}
}).define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const { id } = ctx.query.data || {};
const { id } = ctx.query || {};
if (!id) {
ctx.throw(400, 'id 参数缺失');
}
@@ -184,10 +193,15 @@ app.route({
path: 'flowme-life',
key: 'get',
middleware: ['auth'],
description: '获取单个 flowme-life, 参数: data.id 必填',
description: '获取单个 flowme-life, 参数: id 必填',
metadata: {
args: {
id: z.string().describe('ID'),
}
}
}).define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const { id } = ctx.query.data || {};
const { id } = ctx.query || {};
if (!id) {
ctx.throw(400, 'id 参数缺失');
}

View File

@@ -0,0 +1,174 @@
import { desc, eq, count, like, and, lt } from 'drizzle-orm';
import { schema, app, db } from '@/app.ts'
import z from 'zod';
import dayjs from 'dayjs';
import { useContextKey } from '@kevisual/context';
import { createLunarDate, toGregorian } from 'lunar';
import { getLifeItem, updateLifeEffectiveAt } from './life.services.ts';
app.route({
path: 'flowme-life',
key: 'today',
description: `获取今天需要做的事情列表`,
middleware: ['auth'],
}).define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const uid = tokenUser.id;
const tomorrow = dayjs().add(1, 'day').startOf('day').toDate();
let whereCondition = eq(schema.life.uid, uid);
whereCondition = and(
eq(schema.life.uid, uid),
eq(schema.life.taskType, '运行中'),
lt(schema.life.effectiveAt, tomorrow)
);
const list = await db.select()
.from(schema.life)
.where(whereCondition)
.orderBy(desc(schema.life.effectiveAt));
console.log('today res', list.map(i => i['title']));
if (list.length > 0) {
ctx.body = {
list,
content: list.map(item => {
return `任务id:[${item['id']}]\n标题: ${item['title']}\n启动时间: ${dayjs(item['effectiveAt']).format('YYYY-MM-DD HH:mm:ss')}。标签: ${item['tags'] || '无'} \n总结: ${item['summary'] || '无'}`;
}).join('\n')
};
} else {
ctx.body = {
list,
content: '今天没有需要做的事情了,休息一下吧'
}
}
}).addTo(app);
app.route({
path: 'flowme-life',
key: 'done',
description: `完成某件事情然后判断下一次运行时间。参数是idstring数据类型是string。如果多个存在则是ids的string数组`,
middleware: ['auth'],
metadata: {
args: {
id: z.string().optional().describe('记录id'),
ids: z.array(z.string()).optional().describe('记录id数组'),
}
}
}).define(async (ctx) => {
const id = ctx.query.id;
const ids: string[] = ctx.query.ids || [];
if (!id && ids.length === 0) {
ctx.throw(400, '缺少参数 id');
}
if (ids.length === 0 && id) {
ids.push(String(id));
}
console.log('id', id, ids);
const messages = [];
const changeItem = async (id: string) => {
// 获取记录详情
const recordRes = await getLifeItem(id);
if (recordRes.code !== 200) {
messages.push({
id,
content: `获取记录 ${id} 详情失败`,
});
return;
}
const record = recordRes.data;
// 检查启动时间是否大于今天
const startTime = record.effectiveAt;
const today = dayjs().startOf('day');
const startDate = dayjs(startTime).startOf('day');
if (startDate.isAfter(today)) {
messages.push({
id,
content: `记录 ${id} 的启动时间是 ${dayjs(startTime).format('YYYY-MM-DD HH:mm:ss')},还没到今天呢,到时候再做吧`,
});
return;
}
// 计算下一次运行时间
// 1. 知道当前时间
// 2. 知道任务类型,如果是每日,则加一天;如果是每周,则加七天;如果是每月,则加一个月,如果是每年农历,需要转为新的,如果是其他,需要智能判断
// 3. 更新记录
const strTime = (time: string | Date) => {
return dayjs(time).format('YYYY-MM-DD HH:mm:ss');
}
const currentTime = strTime(new Date().toISOString());
const title = record.title || '无标题';
const isLuar = record.type?.includes?.('农历') || title.includes('农历');
let summay = record.summary || '无';
if (summay.length > 200) {
summay = summay.substring(0, 200) + '...';
}
const prompt = record.prompt || '';
const type = record.type || '';
const content = `上一次执行的时间是${strTime(startTime)},当前时间是${currentTime}请帮我计算下一次的运行时间如果时间不存在默认在8点启动。
${prompt ? `这是我给你的提示词,帮你更好地理解我的需求:${prompt}` : ''}
相关资料是
任务:${record.title}
总结:${summay}
类型: ${type}
`
const ai = useContextKey('ai');
await ai.chat([
{ role: 'system', content: `你是一个时间计算专家擅长根据任务类型和时间计算下一次运行时间。只返回我对应的日期的结果格式是YYYY-MM-DD HH:mm:ss。如果类型是每日则加一天如果是每周则加七天如果是每月则加一个月,如果是每年农历,需要转为新的,如果是其他,需要智能判断` },
{ role: 'user', content }
])
let nextTime = ai.responseText?.trim();
try {
// 判断返回的时间是否可以格式化
if (nextTime && dayjs(nextTime).isValid()) {
const time = dayjs(nextTime);
if (isLuar) {
const festival = createLunarDate({ year: time.year(), month: time.month() + 1, day: time.date() });
const { date } = toGregorian(festival);
nextTime = dayjs(date).toISOString();
} else {
nextTime = time.toISOString();
}
} else {
messages.push({
id,
content: `记录 ${id} 的任务 "${record.title}"AI 返回的时间格式无效,无法格式化,返回内容是:${ai.responseText}`,
});
return;
}
} catch (e) {
messages.push({
id,
content: `记录 ${id} 的任务 "${record.title}"AI 返回结果解析失败,返回内容是:${ai.responseText}`,
});
return;
}
const update = await updateLifeEffectiveAt(id, nextTime);
if (update.code !== 200) {
messages.push({
id,
content: `记录 ${id} 的任务 "${record.title}",更新记录失败`,
});
return;
}
const msg = {
id,
nextTime,
showCNTime: dayjs(nextTime).format('YYYY-MM-DD HH:mm:ss'),
content: `任务 "${record.title}" 已标记为完成。下一次运行时间是 ${dayjs(nextTime).format('YYYY-MM-DD HH:mm:ss')}`
};
messages.push(msg);
}
for (const _id of ids) {
await changeItem(String(_id));
}
ctx.body = {
content: messages.map(m => m.content).join('\n'),
list: messages
};
}).addTo(app);