Docker Build and Push / build-image (push) Successful in 4m34s
- 新增 /api/mcp (Streamable HTTP) 与 41 个工具: 项目/看板列/任务/清单/打卡; - 新增 McpToken 模型与迁移, DB 仅存 SHA-256 哈希, token 绑定用户; - 新增 /api/mcp-tokens 增删改查, 支持独立命名 (如 R1); - 「我的」页新增 AI 接入 (MCP) 区块: 地址复制/新建/一次性展示/删除; - 补充 docs/MCP.md 与样式
263 lines
10 KiB
JavaScript
263 lines
10 KiB
JavaScript
// MCP 工具: 打卡 (打卡项 / 打卡记录)
|
|
import { z } from 'zod'
|
|
import { prisma } from '../../db.js'
|
|
import { toDateStr, addDays, periodRange, PERIODS } from '../../checkin/period.js'
|
|
import { tool, reqStr } from '../helpers.js'
|
|
|
|
const BACKFILL_DAYS = 7
|
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
|
|
|
function normDate(v) {
|
|
const s = String(v || '')
|
|
return DATE_RE.test(s) ? s : toDateStr()
|
|
}
|
|
|
|
function groupCounts(records) {
|
|
const map = new Map()
|
|
for (const r of records) {
|
|
if (!map.has(r.habitId)) map.set(r.habitId, new Map())
|
|
const byDate = map.get(r.habitId)
|
|
byDate.set(r.date, (byDate.get(r.date) || 0) + 1)
|
|
}
|
|
return map
|
|
}
|
|
|
|
function sumRange(byDate, start, end) {
|
|
let n = 0
|
|
let cursor = start
|
|
while (cursor <= end) {
|
|
n += byDate.get(cursor) || 0
|
|
cursor = addDays(cursor, 1)
|
|
}
|
|
return n
|
|
}
|
|
|
|
export function registerCheckinTools(server, ctx) {
|
|
const userId = ctx.userId
|
|
|
|
tool(server, 'list_habits', {
|
|
title: '查询打卡项',
|
|
description: '列出打卡项及其指定日期所在周期的完成进度; 默认只返回未归档项。',
|
|
inputSchema: {
|
|
date: z.string().optional().describe('日期 YYYY-MM-DD, 默认今天'),
|
|
includeArchived: z.boolean().optional().describe('是否包含已归档打卡项, 默认 false'),
|
|
},
|
|
annotations: { readOnlyHint: true },
|
|
}, async ({ date, includeArchived = false }) => {
|
|
const today = toDateStr()
|
|
const day = normDate(date)
|
|
const habits = await prisma.habit.findMany({
|
|
where: { userId, ...(includeArchived ? {} : { archived: false }) },
|
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
|
})
|
|
if (!habits.length) return { date: day, today, items: [] }
|
|
|
|
const ranges = new Map(habits.map((h) => [h.id, periodRange(h.period, day)]))
|
|
let min = null
|
|
let max = null
|
|
for (const r of ranges.values()) {
|
|
if (!min || r.start < min) min = r.start
|
|
if (!max || r.end > max) max = r.end
|
|
}
|
|
const records = await prisma.checkIn.findMany({
|
|
where: {
|
|
userId,
|
|
habitId: { in: habits.map((h) => h.id) },
|
|
date: { gte: min, lte: max },
|
|
},
|
|
select: { habitId: true, date: true },
|
|
})
|
|
const grouped = groupCounts(records)
|
|
|
|
const items = habits.map((h) => {
|
|
const range = ranges.get(h.id)
|
|
const byDate = grouped.get(h.id) || new Map()
|
|
const periodDone = sumRange(byDate, range.start, range.end)
|
|
const target = Math.max(1, h.target)
|
|
return {
|
|
...h,
|
|
target,
|
|
periodStart: range.start,
|
|
periodEnd: range.end,
|
|
periodDone,
|
|
completed: periodDone >= target,
|
|
countOnDate: byDate.get(day) || 0,
|
|
}
|
|
})
|
|
return { date: day, today, items }
|
|
})
|
|
|
|
tool(server, 'create_habit', {
|
|
title: '新建打卡项',
|
|
description: '新建一个周期性打卡目标, 例如每天喝 8 杯水、每周运动 3 次。',
|
|
inputSchema: {
|
|
name: z.string().describe('打卡项名称, 最多 20 字'),
|
|
period: z.enum(PERIODS).optional().describe('周期: day/week/month/year, 默认 day'),
|
|
target: z.coerce.number().int().min(1).optional().describe('每周期目标次数, 默认 1'),
|
|
color: z.string().optional().describe('颜色, 默认 #1677ff'),
|
|
icon: z.string().optional().describe('图标 (emoji 或图标名)'),
|
|
note: z.string().optional().describe('备注'),
|
|
},
|
|
}, async ({ name, period = 'day', target = 1, color = '#1677ff', icon = '', note = '' }) => {
|
|
const cleanName = reqStr(name, '名称', 20)
|
|
if (!PERIODS.includes(period)) throw new Error('周期类型不合法')
|
|
const t = Math.floor(Number(target))
|
|
if (!Number.isFinite(t) || t < 1) throw new Error('目标次数需为 ≥1 的整数')
|
|
|
|
const last = await prisma.habit.findFirst({
|
|
where: { userId }, orderBy: { sortOrder: 'desc' }, select: { sortOrder: true },
|
|
})
|
|
const row = await prisma.habit.create({
|
|
data: {
|
|
userId,
|
|
name: cleanName,
|
|
period,
|
|
target: t,
|
|
color: String(color),
|
|
icon: String(icon),
|
|
note: String(note || ''),
|
|
sortOrder: (last?.sortOrder ?? -1) + 1,
|
|
},
|
|
})
|
|
return { item: row }
|
|
})
|
|
|
|
tool(server, 'update_habit', {
|
|
title: '修改打卡项',
|
|
description: '修改打卡项名称、周期、目标次数、颜色、图标、备注、归档状态或排序值。',
|
|
inputSchema: {
|
|
habitId: z.coerce.number().int().positive().describe('打卡项 ID'),
|
|
name: z.string().optional().describe('新名称, 最多 20 字'),
|
|
period: z.enum(PERIODS).optional().describe('新周期'),
|
|
target: z.coerce.number().int().min(1).optional().describe('新目标次数'),
|
|
color: z.string().optional().describe('新颜色'),
|
|
icon: z.string().optional().describe('新图标'),
|
|
note: z.string().optional().describe('新备注'),
|
|
archived: z.boolean().optional().describe('是否归档'),
|
|
sortOrder: z.coerce.number().int().optional().describe('排序值'),
|
|
},
|
|
}, async ({ habitId, name, period, target, color, icon, note, archived, sortOrder }) => {
|
|
const exist = await prisma.habit.findFirst({ where: { id: Number(habitId), userId } })
|
|
if (!exist) throw new Error('打卡项不存在')
|
|
const data = {}
|
|
if (name !== undefined) data.name = reqStr(name, '名称', 20)
|
|
if (period !== undefined) {
|
|
if (!PERIODS.includes(period)) throw new Error('周期类型不合法')
|
|
data.period = period
|
|
}
|
|
if (target !== undefined) {
|
|
const t = Math.floor(Number(target))
|
|
if (!Number.isFinite(t) || t < 1) throw new Error('目标次数需为 ≥1 的整数')
|
|
data.target = t
|
|
}
|
|
if (color !== undefined) data.color = String(color)
|
|
if (icon !== undefined) data.icon = String(icon)
|
|
if (note !== undefined) data.note = String(note)
|
|
if (archived !== undefined) data.archived = !!archived
|
|
if (sortOrder !== undefined) data.sortOrder = Number(sortOrder) || 0
|
|
const row = await prisma.habit.update({ where: { id: Number(habitId) }, data })
|
|
return { item: row }
|
|
})
|
|
|
|
tool(server, 'delete_habit', {
|
|
title: '删除打卡项',
|
|
description: '默认归档并保留历史记录; hard=true 时永久删除打卡项及其全部打卡记录。',
|
|
inputSchema: {
|
|
habitId: z.coerce.number().int().positive().describe('打卡项 ID'),
|
|
hard: z.boolean().optional().describe('true 永久删除, 默认 false 仅归档'),
|
|
},
|
|
annotations: { destructiveHint: true },
|
|
}, async ({ habitId, hard = false }) => {
|
|
const exist = await prisma.habit.findFirst({ where: { id: Number(habitId), userId } })
|
|
if (!exist) throw new Error('打卡项不存在')
|
|
if (hard) {
|
|
await prisma.checkIn.deleteMany({ where: { habitId: Number(habitId) } })
|
|
await prisma.habit.delete({ where: { id: Number(habitId) } })
|
|
return { ok: true, removed: true }
|
|
}
|
|
const row = await prisma.habit.update({
|
|
where: { id: Number(habitId) }, data: { archived: true },
|
|
})
|
|
return { ok: true, removed: false, item: row }
|
|
})
|
|
|
|
tool(server, 'reorder_habits', {
|
|
title: '打卡项排序',
|
|
description: '按传入的打卡项 ID 数组重排, 数组顺序即新顺序。',
|
|
inputSchema: {
|
|
ids: z.array(z.coerce.number().int().positive()).describe('打卡项 ID 的新顺序'),
|
|
},
|
|
}, async ({ ids }) => {
|
|
const uniq = [...new Set(ids.map(Number))]
|
|
if (!uniq.length) throw new Error('ids 不能为空')
|
|
if (uniq.length !== ids.length) throw new Error('ids 不能重复')
|
|
const rows = await prisma.habit.findMany({
|
|
where: { userId, id: { in: uniq } }, select: { id: true },
|
|
})
|
|
if (rows.length !== uniq.length) throw new Error('包含无效的打卡项')
|
|
await prisma.$transaction(
|
|
ids.map((hid, idx) => prisma.habit.update({
|
|
where: { id: Number(hid) }, data: { sortOrder: idx },
|
|
})),
|
|
)
|
|
return { ok: true, orderedIds: ids.map(Number) }
|
|
})
|
|
|
|
tool(server, 'check_habit', {
|
|
title: '打卡',
|
|
description: '为打卡项记录一次完成; 可为最近 7 天补打, 不能打未来日期。',
|
|
inputSchema: {
|
|
habitId: z.coerce.number().int().positive().describe('打卡项 ID'),
|
|
date: z.string().optional().describe('日期 YYYY-MM-DD, 默认今天'),
|
|
note: z.string().optional().describe('打卡备注, 最多 200 字'),
|
|
},
|
|
}, async ({ habitId, date, note }) => {
|
|
const habit = await prisma.habit.findFirst({ where: { id: Number(habitId), userId } })
|
|
if (!habit) throw new Error('打卡项不存在')
|
|
|
|
const today = toDateStr()
|
|
const day = normDate(date)
|
|
if (day > today) throw new Error('不能为未来日期打卡')
|
|
if (day < addDays(today, -BACKFILL_DAYS)) throw new Error(`最多只能补打最近 ${BACKFILL_DAYS} 天`)
|
|
|
|
const dayRecords = await prisma.checkIn.findMany({
|
|
where: { habitId: habit.id, date: day }, select: { seq: true },
|
|
})
|
|
const seq = dayRecords.reduce((m, r) => Math.max(m, r.seq), 0) + 1
|
|
const row = await prisma.checkIn.create({
|
|
data: {
|
|
habitId: habit.id,
|
|
userId,
|
|
date: day,
|
|
seq,
|
|
note: String(note || '').slice(0, 200),
|
|
source: day === today ? 'user' : 'backfill',
|
|
},
|
|
})
|
|
return { ok: true, date: day, seq, item: row }
|
|
})
|
|
|
|
tool(server, 'undo_habit_check', {
|
|
title: '撤销打卡',
|
|
description: '撤销指定日期所在周期内最近一次打卡记录。',
|
|
inputSchema: {
|
|
habitId: z.coerce.number().int().positive().describe('打卡项 ID'),
|
|
date: z.string().optional().describe('日期 YYYY-MM-DD, 默认今天'),
|
|
},
|
|
annotations: { destructiveHint: true },
|
|
}, async ({ habitId, date }) => {
|
|
const habit = await prisma.habit.findFirst({ where: { id: Number(habitId), userId } })
|
|
if (!habit) throw new Error('打卡项不存在')
|
|
|
|
const day = normDate(date)
|
|
const { start, end } = periodRange(habit.period, day)
|
|
const last = await prisma.checkIn.findFirst({
|
|
where: { habitId: habit.id, date: { gte: start, lte: end } },
|
|
orderBy: [{ date: 'desc' }, { seq: 'desc' }],
|
|
})
|
|
if (!last) throw new Error('本周期还没有打卡记录')
|
|
await prisma.checkIn.delete({ where: { id: last.id } })
|
|
return { ok: true, date: last.date, seq: last.seq }
|
|
})
|
|
}
|