194 lines
7.5 KiB
TypeScript
194 lines
7.5 KiB
TypeScript
import type { ToolDef } from '../../llm/openai-compat.js';
|
||
import type { ToolContext, ToolResult } from './core.js';
|
||
import type { Repository } from '../../db/repository.js';
|
||
|
||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||
const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||
const MAX_TITLE_LEN = 200;
|
||
const MAX_DESC_LEN = 4000;
|
||
|
||
let _repo: Repository | null = null;
|
||
|
||
export function setCalendarRepo(repo: Repository | null): void {
|
||
_repo = repo;
|
||
}
|
||
|
||
const ADD_CALENDAR_EVENT_DEF: ToolDef = {
|
||
type: 'function',
|
||
function: {
|
||
name: 'AddCalendarEvent',
|
||
description:
|
||
'実行中タスクのスペースのカレンダーに予定を1件追加する(created_by=agent)。詳細は ReadToolDoc({ name: "AddCalendarEvent" }) で取得可能。',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
title: { type: 'string', description: '予定のタイトル(必須)' },
|
||
date: { type: 'string', description: '開始日 YYYY-MM-DD(ローカル日付、必須)' },
|
||
end_date: { type: 'string', description: '終了日 YYYY-MM-DD。複数日にまたがる予定のとき指定。省略すると単日' },
|
||
time: { type: 'string', description: '開始 HH:MM。省略すると終日扱い' },
|
||
end_time: { type: 'string', description: '終了 HH:MM。time(開始)があるときのみ有効。単日は開始以降のみ' },
|
||
description: { type: 'string', description: '予定の補足説明(任意)' },
|
||
},
|
||
required: ['title', 'date'],
|
||
},
|
||
},
|
||
};
|
||
|
||
const LIST_CALENDAR_EVENTS_DEF: ToolDef = {
|
||
type: 'function',
|
||
function: {
|
||
name: 'ListCalendarEvents',
|
||
description:
|
||
'実行中タスクのスペースのカレンダー予定を日付範囲で一覧する。詳細は ReadToolDoc({ name: "AddCalendarEvent" }) で取得可能。',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
from: { type: 'string', description: '範囲開始 YYYY-MM-DD(任意)' },
|
||
to: { type: 'string', description: '範囲終了 YYYY-MM-DD(任意)' },
|
||
},
|
||
required: [],
|
||
},
|
||
},
|
||
};
|
||
|
||
export const TOOL_DEFS: Record<string, ToolDef> = {
|
||
AddCalendarEvent: ADD_CALENDAR_EVENT_DEF,
|
||
ListCalendarEvents: LIST_CALENDAR_EVENTS_DEF,
|
||
};
|
||
|
||
export async function executeTool(
|
||
name: string,
|
||
input: Record<string, unknown>,
|
||
ctx: ToolContext,
|
||
): Promise<ToolResult | null> {
|
||
if (name === 'AddCalendarEvent') return executeAddCalendarEvent(input, ctx);
|
||
if (name === 'ListCalendarEvents') return executeListCalendarEvents(input, ctx);
|
||
return null;
|
||
}
|
||
|
||
async function executeAddCalendarEvent(
|
||
input: Record<string, unknown>,
|
||
ctx: ToolContext,
|
||
): Promise<ToolResult> {
|
||
if (!_repo) {
|
||
return { output: 'Calendar repo is not initialized', isError: true };
|
||
}
|
||
const spaceId = ctx.spaceId;
|
||
if (!spaceId) {
|
||
return {
|
||
output:
|
||
'このタスクはスペースに属していないため予定を追加できません(旧 local タスクには spaceId がありません)',
|
||
isError: true,
|
||
};
|
||
}
|
||
const title = input['title'];
|
||
const date = input['date'];
|
||
const endDateInput = input['end_date'];
|
||
const time = input['time'];
|
||
const endTimeInput = input['end_time'];
|
||
const description = input['description'];
|
||
|
||
if (typeof title !== 'string' || !title.trim()) {
|
||
return { output: 'title は必須です', isError: true };
|
||
}
|
||
if (title.length > MAX_TITLE_LEN) {
|
||
return { output: `title が長すぎます(最大 ${MAX_TITLE_LEN} 文字)`, isError: true };
|
||
}
|
||
if (typeof date !== 'string' || !DATE_PATTERN.test(date)) {
|
||
return { output: 'date は YYYY-MM-DD 形式で指定してください', isError: true };
|
||
}
|
||
let endDate: string | null = null;
|
||
if (endDateInput !== undefined && endDateInput !== null && endDateInput !== '') {
|
||
if (typeof endDateInput !== 'string' || !DATE_PATTERN.test(endDateInput)) {
|
||
return { output: 'end_date は YYYY-MM-DD 形式で指定してください', isError: true };
|
||
}
|
||
if (endDateInput < date) {
|
||
return { output: 'end_date は date 以降の日付にしてください', isError: true };
|
||
}
|
||
endDate = endDateInput;
|
||
}
|
||
const startTime = typeof time === 'string' && time !== '' ? time : null;
|
||
if (time !== undefined && time !== null && time !== '') {
|
||
if (typeof time !== 'string' || !TIME_PATTERN.test(time)) {
|
||
return { output: 'time は HH:MM 形式で指定してください', isError: true };
|
||
}
|
||
}
|
||
let endTime: string | null = null;
|
||
if (endTimeInput !== undefined && endTimeInput !== null && endTimeInput !== '') {
|
||
if (typeof endTimeInput !== 'string' || !TIME_PATTERN.test(endTimeInput)) {
|
||
return { output: 'end_time は HH:MM 形式で指定してください', isError: true };
|
||
}
|
||
if (startTime == null) {
|
||
return { output: 'end_time は time(開始時刻)とあわせて指定してください', isError: true };
|
||
}
|
||
const multiDay = endDate != null && endDate > date;
|
||
if (!multiDay && endTimeInput < startTime) {
|
||
return { output: 'end_time は開始時刻以降にしてください(単日の予定)', isError: true };
|
||
}
|
||
endTime = endTimeInput;
|
||
}
|
||
if (description !== undefined && description !== null) {
|
||
if (typeof description !== 'string') {
|
||
return { output: 'description は文字列で指定してください', isError: true };
|
||
}
|
||
if (description.length > MAX_DESC_LEN) {
|
||
return { output: `description が長すぎます(最大 ${MAX_DESC_LEN} 文字)`, isError: true };
|
||
}
|
||
}
|
||
|
||
const sourceTaskId = ctx.taskId ? Number(ctx.taskId) : null;
|
||
const ev = await _repo.createCalendarEvent({
|
||
spaceId,
|
||
ownerId: ctx.ownerId ?? null,
|
||
date,
|
||
endDate,
|
||
time: startTime,
|
||
endTime,
|
||
title: title.trim(),
|
||
description: typeof description === 'string' ? description : null,
|
||
createdBy: 'agent',
|
||
sourceTaskId: Number.isFinite(sourceTaskId) ? sourceTaskId : null,
|
||
});
|
||
const timeLabel = ev.time ? (ev.endTime ? `${ev.time}–${ev.endTime}` : ev.time) : '(終日)';
|
||
return {
|
||
output: `予定を追加しました: ${ev.date}${ev.endDate ? `–${ev.endDate}` : ''} ${timeLabel} 「${ev.title}」(id=${ev.id})`,
|
||
isError: false,
|
||
};
|
||
}
|
||
|
||
async function executeListCalendarEvents(
|
||
input: Record<string, unknown>,
|
||
ctx: ToolContext,
|
||
): Promise<ToolResult> {
|
||
if (!_repo) {
|
||
return { output: 'Calendar repo is not initialized', isError: true };
|
||
}
|
||
const spaceId = ctx.spaceId;
|
||
if (!spaceId) {
|
||
return {
|
||
output: 'このタスクはスペースに属していないため予定を一覧できません',
|
||
isError: true,
|
||
};
|
||
}
|
||
const from = input['from'];
|
||
const to = input['to'];
|
||
if (from !== undefined && (typeof from !== 'string' || !DATE_PATTERN.test(from))) {
|
||
return { output: 'from は YYYY-MM-DD 形式で指定してください', isError: true };
|
||
}
|
||
if (to !== undefined && (typeof to !== 'string' || !DATE_PATTERN.test(to))) {
|
||
return { output: 'to は YYYY-MM-DD 形式で指定してください', isError: true };
|
||
}
|
||
const events = await _repo.listCalendarEvents(spaceId, {
|
||
from: typeof from === 'string' ? from : undefined,
|
||
to: typeof to === 'string' ? to : undefined,
|
||
});
|
||
if (events.length === 0) {
|
||
return { output: '指定範囲に予定はありません', isError: false };
|
||
}
|
||
const lines = events.map((e) => {
|
||
const timeLabel = e.time ? (e.endTime ? `${e.time}–${e.endTime}` : e.time) : '(終日)';
|
||
return `- ${e.date}${e.endDate ? `–${e.endDate}` : ''} ${timeLabel} 「${e.title}」(id=${e.id}, ${e.createdBy})`;
|
||
});
|
||
return { output: lines.join('\n'), isError: false };
|
||
}
|