generated from template/apps-template
update
This commit is contained in:
@@ -27,23 +27,26 @@
|
||||
"keywords": [],
|
||||
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
|
||||
"license": "MIT",
|
||||
"packageManager": "pnpm@10.22.0",
|
||||
"packageManager": "pnpm@10.24.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@kevisual/ai": "^0.0.11",
|
||||
"@kevisual/local-proxy": "^0.0.8",
|
||||
"@kevisual/noco-auto": "../",
|
||||
"@kevisual/query": "^0.0.29",
|
||||
"@kevisual/router": "0.0.33",
|
||||
"@kevisual/use-config": "^1.0.19",
|
||||
"@kevisual/use-config": "^1.0.21",
|
||||
"archiver": "^7.0.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"es-toolkit": "^1.42.0",
|
||||
"lunar": "^2.0.0",
|
||||
"nanoid": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kevisual/ai": "^0.0.12",
|
||||
"@kevisual/types": "^0.0.10",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/bun": "^1.3.3",
|
||||
"@types/node": "^24.10.1"
|
||||
"@types/node": "^24.10.1",
|
||||
"dotenv": "^17.2.3"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app } from './app.ts'
|
||||
import './router/index.ts';
|
||||
import './routes/index.ts';
|
||||
|
||||
export { app }
|
||||
120
backend/src/query/query-config/query-config.ts
Normal file
120
backend/src/query/query-config/query-config.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 配置查询
|
||||
* @updatedAt 2025-12-03 11:05:00
|
||||
*/
|
||||
import { Query } from '@kevisual/query';
|
||||
import type { Result } from '@kevisual/query/query';
|
||||
type QueryConfigOpts = {
|
||||
query?: Query;
|
||||
};
|
||||
export type Config<T = any> = {
|
||||
id?: string;
|
||||
title?: string;
|
||||
key?: string;
|
||||
description?: string;
|
||||
data?: T;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
export type UploadConfig = {
|
||||
key?: string;
|
||||
version?: string;
|
||||
};
|
||||
type PostOpts = {
|
||||
token?: string;
|
||||
payload?: Record<string, any>;
|
||||
};
|
||||
export const defaultConfigKeys = ['upload.json', 'workspace.json', 'ai.json', 'user.json', 'life.json'] as const;
|
||||
type DefaultConfigKey = (typeof defaultConfigKeys)[number];
|
||||
|
||||
export class QueryConfig {
|
||||
query: Query;
|
||||
constructor(opts?: QueryConfigOpts) {
|
||||
this.query = opts?.query || new Query();
|
||||
}
|
||||
async post<T = Config>(data: any) {
|
||||
return this.query.post<T>({ path: 'config', ...data });
|
||||
}
|
||||
async getConfig({ id, key }: { id?: string; key?: string }, opts?: PostOpts) {
|
||||
return this.post({
|
||||
key: 'get',
|
||||
data: {
|
||||
id,
|
||||
key,
|
||||
},
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
async updateConfig(data: Config, opts?: PostOpts) {
|
||||
return this.post({
|
||||
key: 'update',
|
||||
data,
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
async deleteConfig(data: { id?: string, key?: string }, opts?: PostOpts) {
|
||||
return this.post({
|
||||
key: 'delete',
|
||||
data,
|
||||
});
|
||||
}
|
||||
async listConfig(opts?: PostOpts) {
|
||||
return this.post<{ list: Config[] }>({
|
||||
key: 'list',
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 获取上传配置
|
||||
* @returns
|
||||
*/
|
||||
async getUploadConfig(opts?: PostOpts) {
|
||||
return this.post<Result<Config<UploadConfig>>>({
|
||||
key: 'getUploadConfig',
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 更新上传配置
|
||||
* @param data
|
||||
* @returns
|
||||
*/
|
||||
async updateUploadConfig(data: Config, opts?: PostOpts) {
|
||||
return this.post<Result<Config<UploadConfig>>>({
|
||||
key: 'updateUploadConfig',
|
||||
data,
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测配置是否存在
|
||||
* @param id
|
||||
* @returns
|
||||
*/
|
||||
async detectConfig(opts?: PostOpts) {
|
||||
return this.post<{ updateList: Config[] }>({
|
||||
key: 'detect',
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 获取配置, 获取默认的配置项
|
||||
* @param key
|
||||
* @returns
|
||||
*/
|
||||
async getConfigByKey(key: DefaultConfigKey, opts?: PostOpts) {
|
||||
return this.post<Result<Config>>({
|
||||
key: 'defaultConfig',
|
||||
configKey: key,
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
async getByKey<T = any>(key: string, opts?: PostOpts) {
|
||||
return this.post<Result<Config<T>>>({
|
||||
key: 'get',
|
||||
...opts,
|
||||
data: { key },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
|
||||
// base
|
||||
import { app } from '../app.ts';
|
||||
import './noco/index.ts';
|
||||
import './noco/config.ts';
|
||||
|
||||
// 添加认证中间件路由
|
||||
const hasAuth = app.router.routes.some(r => r.id === 'auth');
|
||||
if (!hasAuth) {
|
||||
console.log('添加认证中间件路由');
|
||||
@@ -11,5 +15,7 @@ if (!hasAuth) {
|
||||
id: 'auth'
|
||||
}).define(async (ctx) => {
|
||||
// 这里可以添加实际的认证逻辑
|
||||
ctx.query.token = process.env.TOKEN || ' ';
|
||||
console.log('本地测试认证通过,设置 token');
|
||||
}).addTo(app);
|
||||
}
|
||||
54
backend/src/routes/noco/config.ts
Normal file
54
backend/src/routes/noco/config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { app } from '@/app.ts'
|
||||
import { NocoLifeService } from './services/life.ts';
|
||||
import { useContextKey } from '@kevisual/context';
|
||||
import { BailianProvider } from '@kevisual/ai';
|
||||
import { createLunarDate, LunarDate, toGregorian } from 'lunar';
|
||||
import dayjs from 'dayjs';
|
||||
app.route({
|
||||
path: 'noco-life',
|
||||
key: 'config-update',
|
||||
description: `多维表格配置更新内容, 参数是{data: {baseURL:string; token:string; baseId:string; tableId?:string}}
|
||||
`,
|
||||
middleware: ['auth']
|
||||
}).define(async (ctx) => {
|
||||
const data = ctx.query.data || {};
|
||||
|
||||
if (!data?.baseURL || !data?.token || !data?.baseId) {
|
||||
ctx.throw(400, '缺少参数 baseURL, token, baseId, tableId');
|
||||
}
|
||||
const token = ctx.query.token || '';
|
||||
const nocoLifeService = new NocoLifeService({ token });
|
||||
const config = await nocoLifeService.getLifeConfig()
|
||||
if (data.baseURL) {
|
||||
config.baseURL = data.baseURL;
|
||||
}
|
||||
if (data.token) {
|
||||
config.token = data.token;
|
||||
}
|
||||
if (data.baseId) {
|
||||
config.baseId = data.baseId;
|
||||
}
|
||||
if (data.tableId) {
|
||||
config.tableId = data.tableId;
|
||||
}
|
||||
// 保存配置
|
||||
const res = await nocoLifeService.updateLifeConfig(config);
|
||||
if (res.code !== 200) {
|
||||
ctx.throw(500, '保存配置失败');
|
||||
}
|
||||
ctx.body = '配置更新成功';
|
||||
}).addTo(app);
|
||||
|
||||
|
||||
app.route({
|
||||
path: 'noco-life',
|
||||
key: 'config-get',
|
||||
description: `多维表格配置获取
|
||||
`,
|
||||
middleware: ['auth']
|
||||
}).define(async (ctx) => {
|
||||
const token = ctx.query.token || '';
|
||||
const nocoLifeService = new NocoLifeService({ token });
|
||||
const config = await nocoLifeService.getLifeConfig()
|
||||
ctx.body = config;
|
||||
}).addTo(app);
|
||||
155
backend/src/routes/noco/index.ts
Normal file
155
backend/src/routes/noco/index.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { app } from '@/app.ts'
|
||||
import { NocoLifeService } from './services/life.ts';
|
||||
import { useContextKey } from '@kevisual/context';
|
||||
import { BailianProvider } from '@kevisual/ai';
|
||||
import { createLunarDate, LunarDate, toGregorian } from 'lunar';
|
||||
import dayjs from 'dayjs';
|
||||
app.route({
|
||||
path: 'noco-life',
|
||||
key: 'chat',
|
||||
description: `多维表格聊天接口, 对自己的多维表格的数据进行操作,参数是 question, `,
|
||||
middleware: ['auth']
|
||||
}).define(async (ctx) => {
|
||||
const question = ctx.query.question || '';
|
||||
if (!question) {
|
||||
ctx.throw(400, '缺少参数 question');
|
||||
}
|
||||
const token = ctx.query.token || '';
|
||||
const nocoLifeService = new NocoLifeService({ token });
|
||||
await nocoLifeService.initConfig()
|
||||
const routes = ctx.queryRouter.getList().filter(r => r.path.startsWith('noco-life') && r.key !== 'chat');
|
||||
const v = `${routes.map((r, index) => `${index + 1}工具名称: ${r.id}\n描述: ${r.description}\n`).join('\n')}\n\n当用户询问时,如果拥有工具,请返回 JSON 数据,不存在工具,则返回分析判断,数据JSON数据类型是{id,payload},外面的id是工具的id。如果工具有参数,在 payload 当中,默认不需要参数,如果工具内部需要id,在payload当中。`
|
||||
const ai = useContextKey('ai');
|
||||
const answer = await ai.chat([
|
||||
{ role: 'system', content: `你是一个多维表格助理,你的任务是帮助用户操作和查询多维表格的数据。你可以使用以下工具来完成任务:\n\n${v}` },
|
||||
{ role: 'user', content: question }
|
||||
])
|
||||
console.log('answer', ai.responseText);
|
||||
let msg: any;
|
||||
try {
|
||||
msg = JSON.parse(ai.responseText || '{}');
|
||||
} catch (e) {
|
||||
ctx.throw(500, 'AI 返回结果解析失败');
|
||||
}
|
||||
console.log('msg', msg);
|
||||
const res = await ctx.call(msg);
|
||||
if (res.code !== 200) {
|
||||
ctx.throw(500, '调用工具失败');
|
||||
}
|
||||
ctx.body = res.body;
|
||||
}).addTo(app);
|
||||
|
||||
|
||||
app.route({
|
||||
path: 'noco-life',
|
||||
key: 'today',
|
||||
description: `获取今天需要做的事情列表`,
|
||||
middleware: ['auth']
|
||||
}).define(async (ctx) => {
|
||||
const token = ctx.query.token || '';
|
||||
const nocoLifeService = new NocoLifeService({ token });
|
||||
await nocoLifeService.initConfig()
|
||||
const life = nocoLifeService.life;
|
||||
const res = await life.getList({
|
||||
fields: ['Id', '标题', '总结', '启动时间', '标签', '任务'],
|
||||
where: "(任务,eq,运行中)~and(启动时间,lt,today)",
|
||||
sort: '启动时间',
|
||||
});
|
||||
if (res.code === 200) {
|
||||
const list = res.data.list || []
|
||||
ctx.body = list;
|
||||
if (list.length === 0) {
|
||||
ctx.message = '今天没有需要做的事情,休息一下吧';
|
||||
}
|
||||
return;
|
||||
}
|
||||
ctx.throw(500, '获取记录列表失败');
|
||||
}).addTo(app);
|
||||
|
||||
app.route({
|
||||
path: 'noco-life',
|
||||
key: 'done',
|
||||
description: `完成某件事情,然后判断下一次运行时间。参数是id,数据类型是number。`,
|
||||
middleware: ['auth']
|
||||
}).define(async (ctx) => {
|
||||
const id = ctx.query.id;
|
||||
if (!id) {
|
||||
ctx.throw(400, '缺少参数 id');
|
||||
}
|
||||
const token = ctx.query.token || '';
|
||||
const nocoLifeService = new NocoLifeService({ token });
|
||||
await nocoLifeService.initConfig()
|
||||
const life = nocoLifeService.life;
|
||||
// 获取记录详情
|
||||
const recordRes = await life.getItem(id);
|
||||
if (recordRes.code !== 200) {
|
||||
ctx.throw(500, '获取记录详情失败');
|
||||
}
|
||||
const record = recordRes.data;
|
||||
|
||||
// 检查启动时间是否大于今天
|
||||
const startTime = record['启动时间'];
|
||||
const today = dayjs().startOf('day');
|
||||
const startDate = dayjs(startTime).startOf('day');
|
||||
|
||||
if (startDate.isAfter(today)) {
|
||||
ctx.throw(400, '还没到今天呢,到时候再做吧');
|
||||
}
|
||||
// 计算下一次运行时间
|
||||
// 1. 知道当前时间
|
||||
// 2. 知道任务类型,如果是每日,则加一天;如果是每周,则加七天;如果是每月,则加一个月,如果是每年农历,需要转为新的,如果是其他,需要智能判断
|
||||
// 3. 更新记录
|
||||
const strTime = (time: string) => {
|
||||
return dayjs(time).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
const currentTime = strTime(new Date().toISOString());
|
||||
const isLuar = record['类型']?.includes?.('农历');
|
||||
let summay = record['总结'] || '无';
|
||||
if (summay.length > 200) {
|
||||
summay = summay.substring(0, 200) + '...';
|
||||
}
|
||||
const prompt = record['提示词'] || '';
|
||||
const type = record['类型'] || '';
|
||||
const content = `上一次执行的时间是${strTime(startTime)},当前时间是${currentTime},请帮我计算下一次的运行时间,如果时间不存在,默认在8点启动。
|
||||
${prompt ? `这是我给你的提示词,帮你更好地理解我的需求:${prompt}` : ''}
|
||||
|
||||
相关资料是
|
||||
任务:${record['标题']}
|
||||
总结:${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 {
|
||||
ctx.throw(500, 'AI 返回的时间格式无效,无法格式化');
|
||||
}
|
||||
} catch (e) {
|
||||
ctx.throw(500, 'AI 返回结果解析失败');
|
||||
}
|
||||
const update = await life.updateItem({ Id: id, '启动时间': nextTime });
|
||||
if (update.code !== 200) {
|
||||
ctx.throw(500, '更新记录失败');
|
||||
}
|
||||
ctx.body = {
|
||||
id,
|
||||
nextTime,
|
||||
showCNTime: dayjs(nextTime).format('YYYY-MM-DD HH:mm:ss')
|
||||
};
|
||||
|
||||
}).addTo(app);
|
||||
|
||||
91
backend/src/routes/noco/services/life.ts
Normal file
91
backend/src/routes/noco/services/life.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Life, NocoApi } from '@kevisual/noco-auto';
|
||||
import { initConfig } from "@kevisual/use-config";
|
||||
import { QueryConfig } from "@/query/query-config/query-config.ts";
|
||||
import { Query } from "@kevisual/query/query";
|
||||
import { CustomError } from '@kevisual/router'
|
||||
type NocoLifeServiceOpts = {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export type NocoLifeConfig = {
|
||||
baseURL?: string;
|
||||
baseId: string;
|
||||
token: string;
|
||||
tableId?: string;
|
||||
}
|
||||
export class NocoLifeService {
|
||||
token: string;
|
||||
nocoApi: NocoApi;
|
||||
life: Life;
|
||||
queryConfig: QueryConfig;
|
||||
|
||||
constructor(opts: NocoLifeServiceOpts) {
|
||||
this.token = opts.token;
|
||||
this.initEnv();
|
||||
}
|
||||
initEnv() {
|
||||
const config = new QueryConfig({
|
||||
query: new Query({ url: "https://kevisual.xiongxiao.me/api/router" })
|
||||
});
|
||||
this.queryConfig = config;
|
||||
}
|
||||
async getLifeConfig(): Promise<NocoLifeConfig> {
|
||||
const res = await this.queryConfig.getByKey('life.json', { token: this.token });
|
||||
if (res.code !== 200) {
|
||||
return {} as NocoLifeConfig;
|
||||
}
|
||||
return res.data?.data as NocoLifeConfig;
|
||||
}
|
||||
async updateLifeConfig(data: NocoLifeConfig) {
|
||||
const res = await this.queryConfig.updateConfig({
|
||||
key: 'life.json',
|
||||
data,
|
||||
}, { token: this.token });
|
||||
return res;
|
||||
}
|
||||
/**
|
||||
* 需要从服务端获取自己保存的配置,包括 nocodb 地址,apiKey 等
|
||||
*/
|
||||
async initConfig() {
|
||||
const res = await this.queryConfig.getByKey('life.json', { token: this.token });
|
||||
if (res.code !== 200) {
|
||||
console.error('获取配置失败', res);
|
||||
throw new CustomError(res.code, `获取配置失败: ${res.message}`);
|
||||
}
|
||||
const lifeConfig: NocoLifeConfig = res.data?.data as NocoLifeConfig;
|
||||
if (!lifeConfig || !lifeConfig.token || !lifeConfig.baseId || !lifeConfig.baseURL) {
|
||||
throw new CustomError(400, `配置不完整,请先设置正确的配置, baseURL, baseId, token 都是必须的.`);
|
||||
}
|
||||
const nocoApi = new NocoApi({
|
||||
baseURL: lifeConfig.baseURL || '',
|
||||
token: lifeConfig.token || '',
|
||||
});
|
||||
|
||||
this.nocoApi = nocoApi;
|
||||
const life = new Life({ nocoApi, baseId: lifeConfig.baseId });
|
||||
const tableId = lifeConfig.tableId || '';
|
||||
if (!tableId) {
|
||||
const newTable = await life.createTable()
|
||||
if (newTable.code !== 200) {
|
||||
throw new CustomError(500, `创建默认表失败: ${newTable.message}`);
|
||||
}
|
||||
lifeConfig.tableId = newTable.data?.id;
|
||||
// 保存 tableId 到配置中
|
||||
const res = await this.queryConfig.updateConfig({
|
||||
key: 'life.json',
|
||||
data: lifeConfig,
|
||||
}, { token: this.token });
|
||||
if (res.code === 200) {
|
||||
console.log('默认表创建成功,配置已更新');
|
||||
}
|
||||
}
|
||||
life.tableId = lifeConfig.tableId || '';
|
||||
nocoApi.record.table = life.tableId;
|
||||
this.life = life;
|
||||
return lifeConfig;
|
||||
}
|
||||
initNocoApi() {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
15
backend/test/chat.ts
Normal file
15
backend/test/chat.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { app, sleep } from './common';
|
||||
|
||||
|
||||
const res = await app.call({
|
||||
path: 'noco-life',
|
||||
key: "chat",
|
||||
payload: {
|
||||
// question: '今天我需要做什么事情?',
|
||||
// question: '任务5 完成了,帮我判断下一次运行时间应该是什么时候?',
|
||||
// question: '任务59 完成了',
|
||||
question: '我的多维表格配置'
|
||||
}
|
||||
})
|
||||
|
||||
console.log('res', res.code, res.body, res.message);
|
||||
18
backend/test/common.ts
Normal file
18
backend/test/common.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { app } from '../src/index.ts';
|
||||
export const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
// await sleep(1000); // 等待服务启动
|
||||
import { useContextKey } from '@kevisual/context';
|
||||
import { BailianProvider } from '@kevisual/ai';
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
console.log('process.env.BAILIAN_API_KEY', process.env.BAILIAN_API_KEY);
|
||||
const ai = useContextKey('ai', () => {
|
||||
return new BailianProvider({
|
||||
apiKey: process.env.BAILIAN_API_KEY || '',
|
||||
model: 'qwen-turbo'
|
||||
});
|
||||
});
|
||||
export {
|
||||
app,
|
||||
ai,
|
||||
}
|
||||
13
backend/test/done.ts
Normal file
13
backend/test/done.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { app, sleep } from './common';
|
||||
|
||||
|
||||
const res = await app.call({
|
||||
path: 'noco-life',
|
||||
key: "done",
|
||||
payload: {
|
||||
id: 59, // 洗漱
|
||||
// id:4, // 爸爸
|
||||
}
|
||||
})
|
||||
|
||||
console.log('res', res.code, res.message, res.body);
|
||||
9
backend/test/today.ts
Normal file
9
backend/test/today.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { app, sleep } from './common';
|
||||
|
||||
|
||||
const res = await app.call({
|
||||
path: 'noco-life',
|
||||
key: "today"
|
||||
})
|
||||
|
||||
console.log('res', res.body);
|
||||
Reference in New Issue
Block a user