前回はリッチテキストエディタにAI文章作成アシスタントを組み込んで使用する方法をご紹介しました。
今回は上記で作成したアプリをベースに、AI文章作成アシスタントで使用できるメニューを制御したり、独自メニューを追加したりといったカスタマイズ方法をご紹介します。

前回作成したアプリは以下よりダウンロード可能です。
AI文章作成アシスタントにはデフォルトの組み込みのアシスタントメニューとして、文章の生成や校正、要約、翻訳などの多数のメニューが用意されています。

これらのメニューは要件に合わせてエンドユーザーが使えるメニューを制御することができます。
「client/src/main.js」の中で、AITextAssistantConfigのmenuItemsオプションに、GcTextAssistantBehavior列挙型からユーザーが使用可能なアシスタントメニューを定義します。ここでは「文章を生成」「文章を校正」「文章を要約」の3つのみを使用可能に設定しています。
・・・(中略)・・・
const gcRichTextEditor = new GC.InputMan.GcRichTextEditor(
document.querySelector('#gcRichTextEditor1'),
{
width: 1250,
height: 600,
toolbar: ['newdocument', 'print', 'undo', 'redo', 'cut', 'copy', 'paste', 'pastetext', 'selectall',
'blockquote', 'bold', 'italic', 'underline', 'strikethrough', 'superscript', 'subscript',
'styles', 'fontfamily', 'fontsize', 'align', 'lineheight', 'forecolor', 'backcolor', 'removeformat',
'outdent', 'indent', 'aitextassistant'
],
AITextAssistantConfig: {
menuItems: [
// 組み込みのAI動作
{ name: GC.InputMan.AI.GcTextAssistantBehavior.GenerateText },
{ name: GC.InputMan.AI.GcTextAssistantBehavior.SummarizeContent },
{ name: GC.InputMan.AI.GcTextAssistantBehavior.ReviseText },
],
behaviorConfig: {
dialogConfig: {
width: 700,
height: 300,
}
}
},
・・・(中略)・・・実行すると設定したアシスタントメニューのみが使用可能になります。

AI文章作成アシスタントでは、独自のアシスタントメニューを追加して使用することもできます。今回は広告の文章に法令・ガイドライン違反の可能性がないかをチェックして修正するアシスタント機能を追加してみます。
「client/src/main.js」の中で、registerBehaviorメソッドを使用してカスタムメニューで実行するプロンプトの定義や、ダイアログをモーダル表示にするかどうかや、タイトルなどのオプション設定を行います。定義したカスタムメニューは組み込みのメニュー同様にmenuItemsオプションから追加します。
・・・(中略)・・・
GC.InputMan.AI.GcTextAssistant.registerBehavior('広告チェック', {
prompt: '文章について、法令・ガイドライン違反の可能性(薬機法、景品表示法、誇大表現、優良誤認など)がないか、消費者に誤解を与える表現がないか、根拠不明・断定的・過度な効果表現がないか、炎上・クレームにつながるリスク表現がないか、広告としての分かりやすさ・信頼性、などの観点でチェックして修正してください。',
dialogConfig: {
isModal: true,
resource: {
dialogTitle: '広告チェック',
generatedLabel: 'チェック結果',
}
}
});
・・・(中略)・・・
menuItems: [
// 組み込みのAI動作
{ name: GC.InputMan.AI.GcTextAssistantBehavior.GenerateText },
{ name: GC.InputMan.AI.GcTextAssistantBehavior.SummarizeContent },
{ name: GC.InputMan.AI.GcTextAssistantBehavior.ReviseText },
{ name: "広告チェック" },
],
・・・(中略)・・・実行するとアシスタントメニューに独自で追加した「広告チェック」のメニューが表示されます。

早速追加したメニューを実行して試してみたいと思います。今回はサンプルとして以下のような架空の商品(健康関連商材を想定した)の広告文を使用します。
※ 以下の広告文では広告チェック機能のデモを目的として、意図的にリスクのある表現を含めています。実在の商品・ブランドとは一切関係ありません。将来、同一または類似の名称の商品が存在した場合でも、本記事とは無関係です。
DailySampleBalance緑茶を飲むだけで、毎日の不調をすべてリセット。
年齢や体質に関係なく、誰でもスッキリした毎日を実感できます。
続けることで、疲れにくい体づくりをしっかりサポートします。
多くの愛用者に選ばれている、毎日の健康習慣です。
健康が気になる方にとって、最適な一本です。「飲むだけ」「毎日の不調を全てリセット」「年齢や体質に関係なく、誰でも」「実感できます」など、優良誤認や断定表現、効果保証に近い表現など、薬機法や景品表示法に抵触するリスクのある広告文を例として使用します。本サンプルでは、これらの表現を広告表現としてどのように評価・修正できるかという観点で動作を確認します。
こちらの文章に対して広告チェックを実行してみると、問題部分を書き替えた修正案を返してくれます。
修正後の文章にはまだ軽微な注意点はありますが、リスクはかなり取り除かれました。
※ AIの回答は法令遵守を保証するものではありませんので、最終的な適法性判断・表現可否は、各社の基準に基づき人手で確認してください。
今回作成したサンプルは以下よりダウンロード可能です。
今回はInputManJSのリッチテキストエディタの新機能「AI文章作成アシスタント」のアシスタントメニューをカスタマイズして使用する方法をご紹介しました。AI文章作成アシスタントのアシスタントメニューはユーザーの業種や職種にあわせて柔軟にカスタマイズして使用できますので、ぜひ試してみてください。
製品サイトでは、InputManJSの機能を手軽に体験できるデモアプリケーションやトライアル版も公開しておりますので、こちらもご確認ください。
また、ご導入前の製品に関するご相談、ご導入後の各種サービスに関するご質問など、お気軽にお問合せください。
]]>
「V19J」では、以下のようなアップデートを実施しています。
各種AIプラットフォームのAPIを利用したAI連携機能を追加しました。
※ これらの機能の使用には、あらかじめ各種AIプラットフォームのAPIキーを準備・設定する必要があります。
表計算関数として使用できるSpreadJS独自のAI関数を3種追加しました。

数式エディタにAI機能を追加し、自然言語による指示から数式を自動生成できるようになりました。複数の関数を使用した複雑な数式も簡単に作成が可能です。また、入力した数式の処理内容を分析し、詳細な説明を取得することもできます。複雑な数式の処理内容を把握したい場合などに有用です。
ピボットテーブルで使用するピボットパネルにAI機能を追加し、自然言語による指示からピボットテーブルを自動生成できるようになりました。ユーザーはAIに指示するだけで手軽にデータ分析を行うことができます。AIによる提案からのピボットテーブルの生成や、ピボットテーブルの内容をAIが分析し、その結果を取得することもできます。
レポートシートの各種デザイン機能やExcel出力機能を強化し、よりさまざまなユースケースにおいて使いやすくなりました。
テンプレートシート上に「ヘッダー」「詳細」「フッター」といったテンプレート範囲(セルのブロック)を定義し、動的なデータ構造(繰り返しセクション、階層構造、グループ化された集計など)を持つレポートをより簡単にデザインできるようになりました。従来のようにコンテキストを設定しなくてもデータの親子関係を自動的に認識してくれるので、デザインにかかる手間が軽減されます。

レポートシートのセルに設定された数式を維持したままExcelにエクスポートできるようになりました。作成した帳票をExcel出力して二次利用したい場合などに有用です。

このほかにも、ピボットテーブルやデータチャートなどの既存機能において、約20の新機能の追加や機能強化を実施しています。
製品のWebサイトではこれらのアップデート内容を動画や画像付きで紹介していますので、是非ご確認ください。
また、本件に関するニュースリリースは以下をご覧ください。
]]>
第2回目となる前回は「日報登録に必要となるデータベースの準備、登録処理、そして帳票レイアウトファイル取得の実装」を中心に解説してきました。
シリーズの最後となる今回の記事では、Next.jsを利用してフロントエンドを実装し、チャットUI上にActiveReportsJSビューワを組み込んで、取得した帳票レイアウトファイルを表示する方法を解説いたします。

それでは、さっそくフロントエンド部分の実装を進めていきます。画面上にはチャットUIを配置し、あわせてチャットUI内に ActiveReportsJSビューワを表示するため、それぞれの機能をコンポーネントとして実装していきます。
まず最初に、ビューワを利用するために ActiveReportsJSパッケージをインストールします。以下のコマンドを実行して、インストールを行ってください。
npm install @mescius/[email protected] @mescius/[email protected]続いて、コンポーネントを実装するため、プロジェクト直下に「components」フォルダを作成します。

「components」フォルダ内に、ビューワコンポーネントを実装します。次の「report-viewer.tsx」を追加してください。
'use client';
import { Viewer } from '@mescius/activereportsjs-react';
import { Props as ViewerProps } from '@mescius/activereportsjs-react';
import '@mescius/activereportsjs/pdfexport';
import '@mescius/activereportsjs/xlsxexport';
import '@mescius/activereportsjs-i18n';
import React from 'react';
import '@mescius/activereportsjs/styles/ar-js-ui.css';
import '@mescius/activereportsjs/styles/ar-js-viewer.css';
import * as Core from "@mescius/activereportsjs/core";
//配布ライセンスキーを設定(トライアル版で利用する場合は、以下のコードは不要です)
Core.setLicenseKey(process.env.NEXT_PUBLIC_ACTIVEREPORTSJS_KEY);
// ─── 型定義 ───────────────────────────────────────────────
// レポート定義(JSON)の最小型
interface ReportData {
id?: string;
title?: string;
[key: string]: unknown;
}
// Viewer の標準 props + 画面専用 props
export type ViewerWrapperProps = ViewerProps & {
report?: ReportData;
language?: string;
parameters?: Record<string, unknown>;
};
// ─── メインコンポーネント ──────────────────────────────────
const ViewerWrapper = (props: ViewerWrapperProps) => {
// Viewer インスタンス参照
const viewerRef = React.useRef<Viewer>(null);
// report / parameters 変更時にレポートを再オープン
React.useEffect(() => {
if (!props.report || !viewerRef.current) return;
// open へ渡すレポート定義
const reportDef = props.report as Record<string, unknown>;
// Viewer 初期化待ちのため、少し遅延して open
const timer = setTimeout(() => {
try {
// parameters がある場合は reportParameters として渡す
if (props.parameters && typeof props.parameters === 'object') {
const reportSettings = {
reportParameters: props.parameters as Record<string, unknown>
};
// open(レポート定義, 設定)
viewerRef.current?.open(
reportDef as unknown as Parameters<typeof viewerRef.current.open>[0],
reportSettings as unknown as Parameters<typeof viewerRef.current.open>[1]
);
} else {
// parameters がない場合はレポート定義のみ
viewerRef.current?.open(reportDef as unknown as Parameters<typeof viewerRef.current.open>[0]);
}
} catch (e) {
console.error('[ReportViewer] Report open failed:', e);
}
}, 10);
// タイマーのクリーンアップ
return () => clearTimeout(timer);
}, [props.report, props.parameters]);
const { ...rest } = props;
return (
<Viewer
{...rest}
ref={viewerRef}
language={props.language || 'ja'}
zoom="FitPage"
/>
);
};
export default ViewerWrapper;
あわせて、上記のコード内で使用している環境変数も「.env.local」ファイルに追加します。以下の強調表示箇所を追加してください。
AZURE_OPENAI_ENDPOINT=https://your-end-point/openai
AZURE_OPENAI_API_VERSION=2025-01-01-preview
AZURE_OPENAI_KEY=your-api-key
AZURE_OPENAI_RESOURCE_NAME=your-resource-name
NEXT_PUBLIC_ACTIVEREPORTSJS_KEY='YOUR LICENSE KEY'
続いて、チャットUIコンポーネントの実装を行っていきます。ビューワコンポーネントと同様に「components」内に次の「chat-component.tsx」を追加します。
'use client';
import { useState, useRef, useEffect, useMemo } from 'react';
import dynamic from 'next/dynamic';
import { useMastra } from '../app/hooks/use-mastra';
import { parseMessage, type DataSource } from '../lib/report-parser';
// SSR を避けるため動的インポート(ActiveReports ビューワー)
const ReportViewer = dynamic<Record<string, unknown>>(
async () => (await import('./report-viewer')).default,
{ ssr: false },
);
// ─── 型定義 ───────────────────────────────────────────────
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
report?: Record<string, unknown>;
dataSources?: DataSource[];
parameters?: Record<string, unknown>;
}
interface ChatComponentProps {
agentId?: string;
}
// ─── 定数 ─────────────────────────────────────────────────
const HEADER_BG = '#697481';
const USER_BUBBLE_BG = '#505050';
const MESSAGE_AREA_BG = '#EFEDEA';
const REPORT_HEIGHT = 500;
const REPORT_INNER_HEIGHT = 490;
// ─── サブコンポーネント ────────────────────────────────────
/** ページ上部のヘッダー */
function ChatHeader() {
return (
<div
className="border-b border-gray-100 px-6 py-2 shadow-sm"
style={{ background: HEADER_BG }}
>
<h1 className="text-2xl font-bold text-white">
業務日報エージェント
</h1>
<p className="text-sm text-white">
日報の登録・ユーザー管理・帳票表示をサポートします
</p>
</div>
);
}
/** メッセージが無いときのウェルカム表示 */
function WelcomeMessage() {
return (
<div className="rounded-lg bg-white p-8 text-center shadow">
<p className="text-lg text-gray-500">
👋 ようこそ!業務日報エージェントです
</p>
<p className="mt-2 text-sm text-gray-400">
以下のような操作ができます:
</p>
<ul className="mt-3 space-y-1 text-sm text-gray-400">
<li>📝 日報の登録・更新・削除 ― 例:「今日の作業報告を登録して」</li>
<li>👤 ユーザーの登録・一覧 ― 例:「ユーザーを登録して」</li>
<li>📊 帳票の表示 ― 例:「〇月×日の業務日報を表示して」</li>
</ul>
</div>
);
}
/** 個々のチャットメッセージ(吹き出し + レポート) */
function MessageBubble({ message }: { message: ChatMessage }) {
const isUser = message.role === 'user';
return (
<div className="space-y-2">
{/* 吹き出し */}
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-xs rounded-lg px-4 py-3 lg:max-w-md ${
isUser ? 'text-white' : 'bg-white text-gray-900 shadow'
}`}
style={isUser ? { background: USER_BUBBLE_BG } : undefined}
>
<p className="whitespace-pre-wrap text-sm leading-relaxed">
{message.content}
</p>
<p
className={`mt-2 text-xs ${
isUser ? 'text-blue-100' : 'text-gray-500'
}`}
>
{message.timestamp.toLocaleTimeString()}
</p>
</div>
</div>
{/* レポートビューア */}
{message.report && <ReportPanel report={message.report} parameters={message.parameters} />}
</div>
);
}
/** レポートの埋め込み表示 */
function ReportPanel({
report,
parameters,
}: {
report: Record<string, unknown>;
parameters?: Record<string, unknown>;
}) {
return (
<div className="mx-auto mt-4 w-full max-w-3xl">
<div
className="rounded-lg bg-gray-100 shadow-lg overflow-hidden p-4"
style={{ height: REPORT_HEIGHT }}
>
<div
style={{
width: '100%',
height: REPORT_INNER_HEIGHT,
transformOrigin: 'top center',
overflow: 'hidden',
}}
>
<ReportViewer report={report} language="ja" parameters={parameters} />
</div>
</div>
</div>
);
}
/** ローディングアニメーション */
function LoadingIndicator() {
return (
<div className="flex justify-start">
<div className="rounded-lg bg-white px-4 py-3 shadow">
<div className="flex gap-2">
{[0, 0.2, 0.4].map((delay) => (
<div
key={delay}
className="h-3 w-3 animate-bounce rounded-full bg-gray-400"
style={{ animationDelay: `${delay}s` }}
/>
))}
</div>
</div>
</div>
);
}
/** エラー表示 */
function ErrorMessage({ message }: { message: string }) {
return (
<div className="flex justify-start">
<div className="rounded-lg bg-red-100 px-4 py-3 text-red-900 shadow">
<p className="font-semibold">エラー</p>
<p className="text-sm">{message}</p>
</div>
</div>
);
}
/** メッセージ入力フォーム */
function ChatInput({
value,
onChange,
onSubmit,
loading,
}: {
value: string;
onChange: (v: string) => void;
onSubmit: () => void;
loading: boolean;
}) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSubmit();
}
};
return (
<div className="border-t border-gray-200 bg-white px-6 py-4 shadow-lg">
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
className="mx-auto max-w-3xl"
>
<div className="flex gap-3">
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="日報の登録、ユーザー管理、帳票表示などを依頼できます (Shift+Enter で改行)"
rows={3}
className="flex-1 rounded-lg border border-gray-300 px-4 py-3 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 resize-none"
disabled={loading}
/>
<button
type="submit"
disabled={loading || !value.trim()}
className="rounded-lg bg-blue-500 px-6 py-3 font-semibold text-white hover:bg-blue-600 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{loading ? '...' : '送信'}
</button>
</div>
</form>
</div>
);
}
// ─── メインコンポーネント ──────────────────────────────────
export function ChatComponent({ agentId = 'workreport-agent' }: ChatComponentProps) {
const { messages: hookMessages, loading, error, sendMessage } = useMastra({ agentId });
const [input, setInput] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
// フックのメッセージをパースして ChatMessage 形式に変換
const chatMessages = useMemo<ChatMessage[]>(
() =>
hookMessages.map((msg, index) => {
const parsed = parseMessage(msg.content);
return {
id: `msg-${index}`,
role: msg.role as 'user' | 'assistant',
content: parsed.content,
timestamp: msg.timestamp || new Date(), // hook から付与された timestamp を使用
report: parsed.report,
dataSources: parsed.dataSources,
parameters: parsed.parameters,
};
}),
[hookMessages],
);
// 新しいメッセージ追加時に自動スクロール
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [chatMessages]);
const handleSubmit = async () => {
if (!input.trim() || loading) return;
const userInput = input;
setInput('');
try {
await sendMessage(userInput);
} catch (err) {
console.error('メッセージ送信エラー:', err);
}
};
return (
<div className="flex h-screen flex-col bg-gradient-to-br from-blue-50 to-indigo-50">
<ChatHeader />
{/* メッセージ表示エリア */}
<div
className="flex-1 overflow-y-auto px-6 py-4"
style={{ background: MESSAGE_AREA_BG }}
>
<div className="mx-auto max-w-3xl space-y-4">
{chatMessages.length === 0 && !loading && <WelcomeMessage />}
{chatMessages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
{loading && <LoadingIndicator />}
{error && <ErrorMessage message={error} />}
<div ref={messagesEndRef} />
</div>
</div>
<ChatInput
value={input}
onChange={setInput}
onSubmit={handleSubmit}
loading={loading}
/>
</div>
);
}async () => (await import('./report-viewer')).default,続いて、チャットUIからMastraAPIと連携するためのカスタムHookを実装します。最初に「app」フォルダ配下に「hooks」フォルダを追加し、その中に「use-mastra.ts」を追加します。

追加するコードは以下の通りです。
'use client';
import { useState, useCallback } from 'react';
// ─── 型定義 ───────────────────────────────────────────────
export interface Message {
role: 'user' | 'assistant';
content: string;
timestamp?: Date; // 画面表示用の送信時刻
}
export interface UseMastraOptions {
agentId: string;
}
// ─── 定数 ─────────────────────────────────────────────────
// API のベースURL(末尾の / は消しておく)
const MASTRA_API_BASE_URL =
(process.env.NEXT_PUBLIC_MASTRA_API_BASE_URL ?? 'http://localhost:4111').replace(/\/$/, '');
// ─── メインフック ──────────────────────────────────────────
/** Mastra エージェントとのチャット通信を管理するカスタムフック */
export function useMastra({ agentId }: UseMastraOptions) {
// 画面で使う状態
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(false); // 送信中なら true
const [error, setError] = useState<string | null>(null); // エラーメッセージ
// メッセージを送って、返答を履歴に追加する
const sendMessage = useCallback(
async (userMessage: string) => {
try {
// 新しい送信を始めるので、前回エラーをリセット
setError(null);
// 送信中フラグを ON(ボタン無効化などに使える)
setLoading(true);
// 1) ユーザー発言を先に履歴へ追加(即時に画面へ表示)
const userMsg: Message = {
role: 'user',
content: userMessage,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMsg]);
// 2) Mastra の generate API を呼び出す
const response = await fetch(`${MASTRA_API_BASE_URL}/api/agents/${agentId}/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
// setMessages は非同期なので、最新履歴をここで明示的に組み立てて送る
messages: [...messages, userMsg],
}),
});
// 3) HTTP エラーはここで例外化
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Mastra API error: ${response.status} - ${errorText}`);
}
// 4) JSON レスポンスを受け取る
const data = await response.json();
// 5) まずは通常テキストを返答として採用
let responseText = data.text || JSON.stringify(data);
// 6) toolResults から帳票データ(reportTool)を探す
// 見つかったら JSON 文字列にして responseText を上書きする
if (Array.isArray(data.toolResults)) {
for (const tr of data.toolResults) {
const payload = tr?.payload;
if (payload?.toolName === 'reportTool' && payload?.result?.report) {
// 後段の parseMessage() が解釈できる形で保持
responseText = JSON.stringify(payload.result);
break;
}
}
}
// 7) 上で見つからない場合は、steps 側も確認する
if (!responseText.includes('"report"') && Array.isArray(data.steps)) {
for (const step of data.steps) {
if (!Array.isArray(step.toolResults)) continue;
for (const tr of step.toolResults) {
const payload = tr?.payload;
if (payload?.toolName === 'reportTool' && payload?.result?.report) {
responseText = JSON.stringify(payload.result);
break;
}
}
if (responseText.includes('"report"')) break;
}
}
// 8) アシスタント返答を履歴へ追加
const assistantMsg: Message = {
role: 'assistant',
content: responseText,
timestamp: new Date(),
};
setMessages((prev) => [...prev, assistantMsg]);
// 呼び出し元でも使えるよう返答本文を返す
return assistantMsg.content;
} catch (err) {
// 9) 例外を画面表示用のエラー状態へ反映
const errorMessage = err instanceof Error ? err.message : String(err);
console.error('[Hook] Error:', errorMessage);
setError(errorMessage);
throw err;
} finally {
// 成功/失敗どちらでも送信中フラグを OFF
setLoading(false);
}
},
[agentId, messages]
);
// チャット履歴とエラーを初期化
const clearMessages = useCallback(() => {
setMessages([]);
setError(null);
}, []);
return {
messages,
loading,
error,
sendMessage,
clearMessages,
};
}あわせて、上記のコード内で使用している環境変数も「.env.local」ファイルに追加します。以下の強調表示箇所を追加してください。
AZURE_OPENAI_ENDPOINT=https://your-end-point/openai
AZURE_OPENAI_API_VERSION=2025-01-01-preview
AZURE_OPENAI_KEY=your-api-key
AZURE_OPENAI_RESOURCE_NAME=your-resource-name
NEXT_PUBLIC_ACTIVEREPORTSJS_KEY='YOUR LICENSE KEY'
NEXT_PUBLIC_MASTRA_API_BASE_URL=http://localhost:4111Mastraエージェントのレスポンスをパースし、チャットUIとレポートビューアに渡す形に変換するライブラリの実装を行います。新たにプロジェクト直下に「lib」フォルダを追加し、「report-parser.ts」を追加します。

コードは以下の内容を追加してください。
/**
* レポート解析ユーティリティ
* JSON レスポンスからレポート情報を抽出する共通処理
*/
export interface DataSource {
name: string;
data: unknown[];
}
export interface ReportData {
report: Record<string, unknown>;
fileName: string;
dataSources?: DataSource[];
parameters?: Record<string, unknown>;
}
export interface ParsedMessageResult {
content: string;
report?: Record<string, unknown>;
dataSources?: DataSource[];
parameters?: Record<string, unknown>;
}
// ─── ヘルパー ─────────────────────────────────────────────
/**
* report フィールドを確実にオブジェクトとして取得する。
* - オブジェクトならそのまま返す
* - JSON 文字列なら parse して返す
* - それ以外は null
*/
function resolveReportObject(value: unknown): Record<string, unknown> | null {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
// parse 失敗 → null
}
}
return null;
}
/**
* DataSource 配列を安全に取得する
*/
function resolveDataSources(value: unknown): DataSource[] | undefined {
return Array.isArray(value) ? (value as DataSource[]) : undefined;
}
/**
* パラメータを安全に取得する
*/
function resolveParameters(value: unknown): Record<string, unknown> | undefined {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return undefined;
}
// ─── メインロジック ───────────────────────────────────────
/**
* パース済みオブジェクトからレポート情報を抽出する。
* 複数の形式に対応:
* A) { report (string|object), fileName, ... } ← reportTool の標準出力
* B) { reportObject (object), fileName, ... } ← reportTool の補助フィールド
* C) { Name, FixedPage|Page, fileName, ... } ← トップレベル展開形式
* D) { tool_results: [{ report, fileName, ... }, ...] } ← 複数ツール結果
* E) { toolUse: { result: { report, fileName, ... } } } ← 単一ツール結果
*/
function extractReportFromObject(obj: Record<string, unknown>): ReportData | null {
// --- A) report + fileName ---
if (obj.fileName && obj.report !== undefined) {
const reportObj = resolveReportObject(obj.report);
if (reportObj) {
return {
report: reportObj,
fileName: String(obj.fileName),
dataSources: resolveDataSources(obj.dataSources),
parameters: resolveParameters(obj.parameters),
};
}
}
// --- B) reportObject + fileName ---
if (obj.fileName && obj.reportObject !== undefined) {
const reportObj = resolveReportObject(obj.reportObject);
if (reportObj) {
return {
report: reportObj,
fileName: String(obj.fileName),
dataSources: resolveDataSources(obj.dataSources),
parameters: resolveParameters(obj.parameters),
};
}
}
// --- C) トップレベル展開形式(fileName + Name + FixedPage|Page) ---
if (obj.fileName && obj.Name && (obj.FixedPage || obj.Page)) {
const { fileName, parameters, dataSources, ...reportDef } = obj;
return {
report: reportDef as Record<string, unknown>,
fileName: String(fileName),
dataSources: resolveDataSources(dataSources),
parameters: resolveParameters(parameters),
};
}
// --- D) tool_results 配列 ---
if (Array.isArray(obj.tool_results)) {
for (const item of obj.tool_results as Record<string, unknown>[]) {
const result = extractReportFromObject(item);
if (result) return result;
}
}
// --- E) toolUse.result ---
if (obj.toolUse && typeof obj.toolUse === 'object') {
const toolUse = obj.toolUse as Record<string, unknown>;
if (toolUse.result && typeof toolUse.result === 'object') {
const result = extractReportFromObject(toolUse.result as Record<string, unknown>);
if (result) return result;
}
}
return null;
}
/**
* JSON 文字列からレポート情報を抽出。
* まず全体を JSON.parse し、失敗したらテキスト中の JSON ブロックを探す。
*/
function extractReportFromJson(text: string): ReportData | null {
// 1) 全体が JSON の場合
try {
const parsed = JSON.parse(text) as Record<string, unknown>;
const result = extractReportFromObject(parsed);
if (result) return result;
} catch {
// 全体が JSON でない → 2 へ
}
// 2) テキスト中に埋め込まれた JSON ブロックを探す( ```json ... ``` や { ... } )
const jsonPatterns = [
/```json\s*([\s\S]*?)```/g, // Markdown コードブロック
/(\{[\s\S]*"fileName"[\s\S]*\})/g, // fileName を含む JSON オブジェクト
];
for (const pattern of jsonPatterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
try {
const candidate = JSON.parse(match[1]) as Record<string, unknown>;
const result = extractReportFromObject(candidate);
if (result) return result;
} catch {
// この候補は無効 → 次へ
}
}
}
return null;
}
// ─── 公開 API ─────────────────────────────────────────────
/**
* メッセージをパースしてレポート情報を抽出
*/
export function parseMessage(content: string): ParsedMessageResult {
const reportData = extractReportFromJson(content);
if (reportData) {
return {
content: `レポート「${reportData.fileName}」を読み込みました`,
report: reportData.report,
dataSources: reportData.dataSources,
parameters: reportData.parameters,
};
}
return { content };
}
/**
* 複数のメッセージをバッチパース
*/
export function parseMessages(contents: string[]): ParsedMessageResult[] {
return contents.map(parseMessage);
}コンポーネントの実装と、カスタムフック、レポート解析ライブラリの実装が完了しましたので、これらを利用してエージェントUI画面の実装を行っていきます。
「app」フォルダ配下に「worklog-agent」フォルダを作成し、Next.jsのページコンポーネント「page.tsx」を配置します。

追加するコードは以下の通りです。チャットUIコンポーネント側で大半の処理を実装しているためコンポーネントの呼び出しがメインです。
'use client';
import { ChatComponent } from '@/components/chat-component-org';
export default function WorklogAgentPage() {
return <ChatComponent agentId="workreportAgent" />;
}
続いて、「app\page.tsx」のデフォルトトップページを以下のように変更し、エージェントUI画面への導線を追加します。
import Link from "next/link";
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50">
<main className="flex flex-col items-center gap-8 text-center px-8">
<div className="flex flex-col items-center gap-3">
<p className="text-4xl">📋</p>
<h1 className="text-3xl font-bold text-zinc-800">業務日報エージェント</h1>
<p className="text-zinc-500 text-base max-w-sm">
AI との会話で日報の登録・検索・帳票出力ができます
</p>
</div>
<Link
href="/worklog-agent"
style={{ background: '#3f3f46', color: '#ffffff' }}
className="rounded-full px-8 py-3 font-semibold text-base transition-colors hover:opacity-80"
>
エージェントを開く →
</Link>
</main>
</div>
);
}
さらに、Webアプリケーション全体のレイアウトとスタイル設定を管理する「app\layout.tsx」も以下の強調表示箇所に従ってアプリケーションタイトルに関する部分を修正します。
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Work Report Agent",
description: "AI-powered work report generation and analysis tool",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}
フロントエンド実装が完了したため、次のコマンドで動作確認を行っていきます。
動作確認には、ターミナルを複数立ち上げ、Next.jsとMastraそれぞれを実行します。
npx mastra dev --dir ./mastranpm run dev動作確認では以下の内容を確認します。
CRUD処理は、前回Mastra Studioで確認した時と同様に動作していることが確認できます。
帳票ビューワも正常に動作しています。チャットUI上に配置されたビューワに、指定した帳票レイアウト(前回記事で追加済みのテストレポート「test.rdlx-json」)が表示されます。
基本的な機能の実装と動作確認が完了しましたので、最後に業務日報用の帳票レイアウトを追加し、データベースのデータを帳票に設定する処理を実装します。
今回は以下の業務日報用の帳票レイアウトを利用します。「reports」フォルダの中にファイルを追加してください。

データソースには、あらかじめ作成済みの「dailyReports」テーブルのサンプルデータを設定し、それを基に帳票レイアウトを作成しています。

続いて、帳票レイアウトの取得を行う「reports-tool」に対して、「crud-tool」を用いてテーブルからデータを取得する処理を追加します。
既存のツール構成を変更せずに、CRUDツールを利用して帳票用データを取得し、レポートビューワに設定する構成も検討しましたが、この場合は一度LLMにデータを渡す必要があります。
その結果、トークン量の増加によるコスト増加やレスポンス低下が懸念されるため、帳票レイアウトの取得およびデータ取得を各ツール内で完結させ、処理効率を向上させる構成へ変更しました。
強調表示した箇所が変更部分ですが、修正箇所が多いため、以下の内容を上書きしてください。
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';
import { promises as fs } from 'fs';
import { join, resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { crudTool } from './crud-tool';
// レポートデータの型定義(任意のキーと値のペアを持つオブジェクト)
type ReportData = Record<string, unknown>;
// 現在のファイルのディレクトリを取得(ES モジュール環境用)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// レポート読み込み結果の型定義
type ReportResult = {
report: ReportData; // パースされたレポート定義
fileName: string; // ロードされた帳票レイアウトファイルの名前
parameters?: Record<string, unknown>; // レポートに渡されるパラメータ
};
/**
* ========================================
* レポート生成ツール
* ========================================
*
* 機能:
* 1. RDLX-JSON形式のレポート定義ファイルを読み込む
* 2. 不要な場合は、reportData パラメータから渡されたデータを使用
* 3. 必要な場合は、reportName と filters(reportDate, userId)から
* このツール内で crud-tool 相当の処理を実行してデータ取得
*
* 用途:複数のレポートテンプレートから、指定されたレポートを
* 動的にロードしてエージェントに提供する
* + データ取得も一元化(エージェント側で crud-tool を呼ぶ必要がない)
*/
export const reportTool = createTool({
id: 'reports-tool',
description: 'RDLX-JSON形式のレポート定義ファイルをロードし、必要に応じてデータ取得してレポート定義を返します。',
// ========== 入力スキーマ(ユーザーやエージェントからの入力) ==========
inputSchema: z.object({
reportName: z.string().describe('帳票レイアウトファイルの名前(拡張子なし)。例: "daily_reports"、"test"'),
reportData: z.string().optional().describe('レポートに渡すデータ(JSON文字列形式、外部で取得済みの場合)'),
filters: z.object({
reportDate: z.string().optional().describe('絞込: 報告日(YYYY-MM-DD)'),
userId: z.number().optional().describe('絞込: ユーザーID'),
}).optional().describe('データベースから取得する場合のフィルタ条件(reportData が未指定時に使用)'),
parameters: z.record(z.string(), z.any()).optional().describe('レポートに渡すパラメータ'),
}),
// ========== 出力スキーマ(このツールの戻り値) ==========
outputSchema: z.object({
// レポートはJSON文字列で返される
report: z.string().describe('レポート定義のJSON文字列'),
fileName: z.string().describe('ロードされた帳票レイアウトファイル名'),
reportData: z.string().optional().describe('レポートに渡すデータ(JSON文字列)'),
parameters: z.record(z.string(), z.unknown()).optional().describe('ビューアに渡すレポートパラメータ'),
}),
// ========== メイン処理 ==========
execute: async (args: unknown) => {
/**
* Mastra や createTool からツール入力を受け取る際、複数の形式が考えられるため、
* それぞれのケースに対応する処理を行う:
* 1. 直接オブジェクトで渡される場合
* 2. { context: {...} } の形で渡される場合
* 3. JSON文字列で渡される場合
* 4. { argsJson: '...' } の形で渡される場合
*/
let payload: unknown = args;
// ========== パターン1: JSON文字列として渡された場合の処理 ==========
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch {
// パースに失敗しても続行(下流のバリデーションでエラーが出る)
}
}
// ========== パターン2: { argsJson: '...' } の形で渡された場合の処理 ==========
// 一部のランタイムでは、引数がこのフォーマットで包装されることがある
const argsObj = args as Record<string, unknown> | null;
if (!payload && argsObj?.argsJson) {
try {
payload = JSON.parse(argsObj.argsJson as string);
} catch {
// パースに失敗した場合はpayloadのままで続行
}
}
// ========== パターン3: { context: {...} } の形で渡された場合の処理 ==========
// この場合は、context内に実際の引数が含まれているため抽出する
const payloadObj = payload as Record<string, unknown> | null;
if (payloadObj?.context && typeof payloadObj.context === 'object') {
payload = { ...payloadObj.context };
}
const finalPayload = payload as Record<string, unknown> | null;
const reportName = finalPayload?.reportName as string | undefined;
const parameters = finalPayload?.parameters as Record<string, unknown> | undefined;
let reportData = finalPayload?.reportData as string | undefined;
const filters = finalPayload?.filters as Record<string, unknown> | undefined;
// ========== 必須パラメータの検証 ==========
// reportName は帳票レイアウトファイルを特定するために必須
if (!reportName) {
throw new Error('reportName is required');
}
// ========== [短縮] reportData が未指定の場合は DB から取得 ==========
// filters がなくても全件取得する(条件なし = 全件)
// crud-tool を使用して汎用的にデータ取得(エージェント側で crud-tool を呼ぶ必要がない)
// ただし、テストレポートなどデータ不要なレポートはスキップ
const noDataReports = ['test']; // データが不要なレポート名
if (!reportData && !noDataReports.includes(reportName)) {
try {
// テーブル名とフィルタ条件をマッピング
const tableMapping: Record<string, string> = {
'daily_reports': 'dailyReports',
};
const tableName = tableMapping[reportName] || reportName;
// crud-tool の read 操作を使用してデータを取得
// filters がない場合は undefined または空オブジェクト を渡して全件取得
const crudResult = await crudTool.execute({
operation: 'read',
table: tableName,
where: (filters && Object.keys(filters).length > 0) ? (filters as Record<string, unknown>) : undefined,
});
if (crudResult.success) {
reportData = JSON.stringify(crudResult.data || []);
} else {
console.error('[reportTool] crud-tool read error:', crudResult.message);
throw new Error(crudResult.message || 'Failed to fetch data from crud-tool');
}
} catch (err) {
console.error('[reportTool] DB fetch error:', err);
throw err;
}
} else if (!reportData && noDataReports.includes(reportName)) {
// テストレポート等、データが不要な場合は空配列を設定
reportData = JSON.stringify([]);
}
// ========== レポート定義のロード ==========
// 指定された帳票レイアウトファイルを各候補ディレクトリから検索して読み込む
const res = await loadReportDefinition(reportName, parameters);
// ========== レスポンスの作成 ==========
// レポートはJSON文字列で返す(LLMコンテキストを軽減)
const singleJson = JSON.stringify(res.report);
const response: Record<string, unknown> = {
report: singleJson,
fileName: res.fileName,
};
// reportData が渡されている場合は結果に含める
if (reportData) {
response.reportData = reportData;
}
// parameters が存在する場合も結果に含める
if (res.parameters && Object.keys(res.parameters).length > 0) {
response.parameters = res.parameters;
}
return response;
},
});
/**
* ========================================
* レポート定義ロード関数
* ========================================
*
* 機能:指定されたレポート名から、RDLX-JSON形式のレポート定義ファイルを
* プロジェクト直下のreportsフォルダから検索してロードする
*
* パラメータ:
* @param reportName - 帳票レイアウトファイルの名前(拡張子なし)
* @param parameters - レポートに渡す動的パラメータ(ユーザーデータなど)
*
* 戻り値:
* @returns レポート定義、ファイル名、パラメータを含むオブジェクト
*/
async function loadReportDefinition(reportName: string, parameters?: Record<string, unknown>): Promise<ReportResult> {
// ========== 帳票レイアウトファイルのパス ==========
// プロジェクト直下の reports フォルダを参照
// 複数のレポートフォルダの候補を試す:
// 1. process.cwd() をベースにしたパス(通常はプロジェクトルート)
// 2. __dirname から相対的に上がったパス
const candidates = [
join(process.cwd(), 'reports'),
resolve(__dirname, '../../../reports'), // コンパイル出力からプロジェクトルートへのパス
];
let reportPath: string | null = null;
// ========== 複数の候補パスから検索 ==========
for (const candidate of candidates) {
const path = join(candidate, `${reportName}.rdlx-json`);
try {
await fs.access(path);
reportPath = path;
break;
} catch {
// このパスは見つからない、次を試す
}
}
if (!reportPath) {
throw new Error(
`レポート "${reportName}" をロードできません。次の場所を検索しました: ${candidates
.map((c) => join(c, `${reportName}.rdlx-json`))
.join(', ')}`
);
}
// ========== ファイルを読み込んでパース ==========
try {
const fileContent = await fs.readFile(reportPath, 'utf8');
const report = JSON.parse(fileContent) as ReportData;
const result: ReportResult = {
report,
fileName: `${reportName}.rdlx-json`,
};
// ========== パラメータを結果に含める ==========
// パラメータが存在する場合のみ結果に含める
if (parameters && Object.keys(parameters).length > 0) {
result.parameters = parameters;
}
return result;
} catch (error) {
// ========== ファイル読み込み/パースエラーの処理 ==========
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`レポート "${reportName}" をロードできません(パス: ${reportPath}): ${errorMessage}`);
}
}crud-toolは、データ取得処理で抽出条件設定の処理を一部見直しています。こちらも同様に書き換えてください。
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';
import { db } from '@/database';
import * as schema from '@/database/schema';
import { eq, and } from 'drizzle-orm';
/**
* ========== 概要 ==========
*
* このファイルは Mastra エージェント用の汎用 CRUD ツールを提供します。
* AI エージェントがデータベースに対して CREATE / READ / UPDATE / DELETE 操作
* を実行できるようにします。
*/
/**
* database/schema.ts に定義されているテーブル名の型
*
* 使用例:
* - 'daily_reports': 日報テーブル
* - 'users': ユーザーテーブル
* - など schema.ts で定義されたテーブルすべて
*/
type TableName = keyof typeof schema;
/**
* ========== 汎用 CRUD ツール ==========
*
* 設計思想:
* - 入力値の妥当性チェックは Drizzle ORM のスキーマ制約に委譲
* (NOT NULL、UNIQUE、FOREIGN KEY などの制約)
* - ツール側では try/catch で例外をハンドル
* - エラーメッセージはユーザーフレンドリーに変換
*
* 対応操作:
* 1. CREATE: 新規記録を作成
* 2. READ: 記録を取得(WHERE条件対応)
* 3. UPDATE: 記録を更新(id指定必須)
* 4. DELETE: 記録を削除(id指定必須)
*/
export const crudTool = createTool({
id: 'crud-tool',
description: 'Generic CRUD tool based on Drizzle schema constraints',
/**
* ========== 入力スキーマ ==========
*
* @param operation - 実行する操作
* - 'create': レコード新規作成、data パラメータを使用
* - 'read': レコード検索、where パラメータで条件指定可能
* - 'update': レコード更新、where.id で対象を指定、data で新値を指定
* - 'delete': レコード削除、where.id で対象を指定
*
* @param table - テーブル名(schema.ts で定義されている必要があります)
*
* @param data - INSERT/UPDATE する値
* 例){ name: 'John', email: '[email protected]' }
*
* @param where - WHERE 条件(通常は id を指定)
* 例){ id: 1 }, { id: 5 }
*/
inputSchema: z.object({
operation: z.enum(['create', 'read', 'update', 'delete']),
table: z.string(),
data: z.record(z.string(), z.unknown()).optional(),
where: z.record(z.string(), z.unknown()).optional(),
}),
/**
* ========== 出力スキーマ ==========
*
* @param success - 操作が成功したかどうか
* @param data - 操作結果のデータ(CREATE/READ/UPDATE で返却)
* @param message - エラーメッセージ(成功時は省略)
*/
outputSchema: z.object({
success: z.boolean(),
data: z.unknown().optional(),
message: z.string().optional(),
}),
/**
* ========== メイン処理 ==========
*/
execute: async ({ operation, table, data, where }) => {
// ========== テーブルの存在確認 ==========
const targetTable = schema[table as TableName];
if (!targetTable) {
return { success: false, message: `Unknown table: ${table}` };
}
// ========== ID カラムの検出 ==========
// UPDATE/DELETE 操作で WHERE 條件として id を使用するため
// 対象テーブルの id カラムを動的に取得
type TargetTableType = typeof targetTable;
const idColumn = ('id' in targetTable ? (targetTable as { id: unknown }).id : null) as (TargetTableType extends { id: infer T } ? T : null);
try {
switch (operation) {
/**
* ========== CREATE 操作 ==========
* 新規レコードをテーブルに挿入します
*
* 処理フロー:
* 1. INSERT 文を生成
* 2. data パラメータの値を使用
* 3. 新規作成されたレコードを RETURNING で返す
* 4. スキーマの制約違反がある場合は error catch へ
*
* エラー例:
* - NOT NULL 制約違反
* - UNIQUE 制約違反(重複した値)
* - 型の不一致
*/
case 'create': {
const result = await db
.insert(targetTable)
.values(data ?? {})
.returning();
return { success: true, data: result };
}
/**
* ========== READ 操作 ==========
* テーブルからレコードを検索します
*
* 処理フロー:
* 1. where パラメータに複数の条件を指定可能
* 2. すべての条件が AND で結合される
* 3. where が指定されていない場合は全件取得
*
* 使用例:
* - { table: 'daily_reports', operation: 'read', where: { id: 1 } }
* → ID=1 の日報を1件取得
*
* - { table: 'daily_reports', operation: 'read', where: { report_date: '2026-02-06', user_id: 1 } }
* → 2026-02-06 かつ user_id=1 の日報を取得
*
* - { table: 'daily_reports', operation: 'read' }
* → 全日報を取得
*/
case 'read': {
let result;
if (where && Object.keys(where).length > 0) {
// テーブルのカラムを取得
const tableColumns = targetTable;
const conditions: any[] = [];
// where条件の各キーをチェック
for (const [key, value] of Object.entries(where)) {
// テーブル定義からカラムを取得
if (key in tableColumns) {
const columnDef = (tableColumns as any)[key];
conditions.push(eq(columnDef, value));
}
}
// 条件がある場合は AND で結合して検索
if (conditions.length > 0) {
result = await db
.select()
.from(targetTable)
.where(and(...conditions));
} else {
// マッチするカラムが見つからない場合は全件取得
result = await db.select().from(targetTable);
}
} else {
// where 条件がない場合は全件取得
result = await db.select().from(targetTable);
}
return { success: true, data: result };
}
/**
* ========== UPDATE 操作 ==========
* 既存レコードを更新します
*
* 必須条件:
* - where.id が必ず指定される必要があります
* (複数レコードの同時更新を防ぐため)
*
* 処理フロー:
* 1. where.id から対象レコードを特定
* 2. data パラメータの値で上書き
* 3. 更新後のレコードを RETURNING で返す
* 4. id の不在または型の不一致は error へ
*
* エラー例:
* - id が指定されていない
* - UNIQUE 制約違反
* - データ型の不一致
*/
case 'update': {
if (!idColumn || where?.id === undefined) {
throw new Error('id is required for update');
}
const result = await db
.update(targetTable)
.set(data ?? {})
.where(eq(idColumn, Number(where.id)))
.returning();
return { success: true, data: result };
}
/**
* ========== DELETE 操作 ==========
* 指定したレコードを削除します
*
* 必須条件:
* - where.id が必ず指定される必要があります
* (複数レコードの誤削除を防ぐため)
*
* 処理フロー:
* 1. where.id から対象レコードを特定
* 2. そのレコードを削除
* 3. 削除対象が見つからない場合も成功として返す
* 4. id の不在は error へ
*
* 注意:
* - 削除後のデータ復旧はできません
* - FOREIGN KEY 制約の対象になっている場合は削除失敗
*/
case 'delete': {
if (!idColumn || where?.id === undefined) {
throw new Error('id is required for delete');
}
await db
.delete(targetTable)
.where(eq(idColumn, Number(where.id)));
return { success: true };
}
}
} catch (error) {
return {
success: false,
message: formatDbError(error),
};
}
},
});
/**
* ========== エラーメッセージの変換 ==========
*
* Drizzle ORM / SQLite が返すエラーメッセージを解釈し、
* ユーザーフレンドリーな日本語メッセージに変換します
*
* 対応するエラータイプ:
*
* 1. NOT NULL 制約違反
* - 必須項目が空白で送信された
* - 例:{ name: null }
*
* 2. UNIQUE 制約違反
* - 重複する値をINSERT/UPDATEしようとした
* - 例:メールアドレスが既に登録されている
*
* 3. FOREIGN KEY 制約違反
* - 関連する親レコードが存在しない
* - 例:存在しないユーザーID を参照している
*
* 4. CHECK 制約違反
* - カスタム検証ルールに違反している
* - 例:年齢が負の数
*
* @param error - Drizzle ORM から投げられたエラーオブジェクト
* @returns ユーザーフレンドリーなエラーメッセージ
*/
function formatDbError(error: unknown): string {
if (!(error instanceof Error)) {
return 'Unknown database error';
}
const msg = error.message;
if (msg.includes('NOT NULL')) {
return '必須項目が不足しています';
}
if (msg.includes('UNIQUE')) {
return '一意制約に違反しています';
}
if (msg.includes('FOREIGN KEY')) {
return '関連データが存在しません';
}
if (msg.includes('CHECK')) {
return '入力値が制約条件を満たしていません';
}
return msg;
}続いて、「reports-tool」の結果を解析する、「report-parser.ts」も変更します。強調表示箇所が追加・変更箇所です。データソースの受け渡しができるようにパラメータが追加されています。
/**
* レポート解析ユーティリティ
* JSON レスポンスからレポート情報を抽出する共通処理
*/
export interface DataSource {
name: string;
data: unknown[];
}
export interface ReportData {
report: Record<string, unknown>;
fileName: string;
reportData?: string | null;
dataSources?: DataSource[];
parameters?: Record<string, unknown>;
}
export interface ParsedMessageResult {
content: string;
report?: Record<string, unknown>;
reportData?: string | null;
dataSources?: DataSource[];
parameters?: Record<string, unknown>;
}
// ─── ヘルパー ─────────────────────────────────────────────
/**
* report フィールドを確実にオブジェクトとして取得する。
* - オブジェクトならそのまま返す
* - JSON 文字列なら parse して返す
* - それ以外は null
*/
function resolveReportObject(value: unknown): Record<string, unknown> | null {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
// parse 失敗 → null
}
}
return null;
}
/**
* DataSource 配列を安全に取得する
*/
function resolveDataSources(value: unknown): DataSource[] | undefined {
return Array.isArray(value) ? (value as DataSource[]) : undefined;
}
/**
* パラメータを安全に取得する
*/
function resolveParameters(value: unknown): Record<string, unknown> | undefined {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return undefined;
}
// ─── メインロジック ───────────────────────────────────────
/**
* パース済みオブジェクトからレポート情報を抽出する。
* 複数の形式に対応:
* A) { report (string|object), fileName, ... } ← reportTool の標準出力
* B) { reportObject (object), fileName, ... } ← reportTool の補助フィールド
* C) { Name, FixedPage|Page, fileName, ... } ← トップレベル展開形式
* D) { tool_results: [{ report, fileName, ... }, ...] } ← 複数ツール結果
* E) { toolUse: { result: { report, fileName, ... } } } ← 単一ツール結果
*/
function extractReportFromObject(obj: Record<string, unknown>): ReportData | null {
// --- A) report + fileName ---
if (obj.fileName && obj.report !== undefined) {
const reportObj = resolveReportObject(obj.report);
if (reportObj) {
return {
report: reportObj,
fileName: String(obj.fileName),
reportData: typeof obj.reportData === 'string' ? obj.reportData : null,
dataSources: resolveDataSources(obj.dataSources),
parameters: resolveParameters(obj.parameters),
};
}
}
// --- B) reportObject + fileName ---
if (obj.fileName && obj.reportObject !== undefined) {
const reportObj = resolveReportObject(obj.reportObject);
if (reportObj) {
return {
report: reportObj,
fileName: String(obj.fileName),
reportData: typeof obj.reportData === 'string' ? obj.reportData : null,
dataSources: resolveDataSources(obj.dataSources),
parameters: resolveParameters(obj.parameters),
};
}
}
// --- C) トップレベル展開形式(fileName + Name + FixedPage|Page) ---
if (obj.fileName && obj.Name && (obj.FixedPage || obj.Page)) {
const { fileName, reportData, parameters, dataSources, ...reportDef } = obj;
return {
report: reportDef as Record<string, unknown>,
fileName: String(fileName),
reportData: typeof reportData === 'string' ? reportData : null,
dataSources: resolveDataSources(dataSources),
parameters: resolveParameters(parameters),
};
}
// --- D) tool_results 配列 ---
if (Array.isArray(obj.tool_results)) {
for (const item of obj.tool_results as Record<string, unknown>[]) {
const result = extractReportFromObject(item);
if (result) return result;
}
}
// --- E) toolUse.result ---
if (obj.toolUse && typeof obj.toolUse === 'object') {
const toolUse = obj.toolUse as Record<string, unknown>;
if (toolUse.result && typeof toolUse.result === 'object') {
const result = extractReportFromObject(toolUse.result as Record<string, unknown>);
if (result) return result;
}
}
return null;
}
/**
* JSON 文字列からレポート情報を抽出。
* まず全体を JSON.parse し、失敗したらテキスト中の JSON ブロックを探す。
*/
function extractReportFromJson(text: string): ReportData | null {
// 1) 全体が JSON の場合
try {
const parsed = JSON.parse(text) as Record<string, unknown>;
const result = extractReportFromObject(parsed);
if (result) return result;
} catch {
// 全体が JSON でない → 2 へ
}
// 2) テキスト中に埋め込まれた JSON ブロックを探す( ```json ... ``` や { ... } )
const jsonPatterns = [
/```json\s*([\s\S]*?)```/g, // Markdown コードブロック
/(\{[\s\S]*"fileName"[\s\S]*\})/g, // fileName を含む JSON オブジェクト
];
for (const pattern of jsonPatterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
try {
const candidate = JSON.parse(match[1]) as Record<string, unknown>;
const result = extractReportFromObject(candidate);
if (result) return result;
} catch {
// この候補は無効 → 次へ
}
}
}
return null;
}
// ─── 公開 API ─────────────────────────────────────────────
/**
* メッセージをパースしてレポート情報を抽出
*/
export function parseMessage(content: string): ParsedMessageResult {
const reportData = extractReportFromJson(content);
if (reportData) {
const result = {
content: `レポート「${reportData.fileName}」を読み込みました`,
report: reportData.report,
reportData: reportData.reportData,
dataSources: reportData.dataSources,
parameters: reportData.parameters,
};
return result;
}
return { content };
}
/**
* 複数のメッセージをバッチパース
*/
export function parseMessages(contents: string[]): ParsedMessageResult[] {
return contents.map(parseMessage);
}続いて、ビューワコンポーネントと、チャットUIコンポーネントにもそれぞれデータソースが渡せるように変更していきます。
また、ビューワコンポーネントに関しては、チャットUI上でより表示部分が広がるようにUIをカスタマイズしてみます。
'use client';
import { Viewer } from '@mescius/activereportsjs-react';
import { Props as ViewerProps } from '@mescius/activereportsjs-react';
import '@mescius/activereportsjs/pdfexport';
import '@mescius/activereportsjs/xlsxexport';
import '@mescius/activereportsjs-i18n';
import React from 'react';
import '@mescius/activereportsjs/styles/ar-js-ui.css';
import '@mescius/activereportsjs/styles/ar-js-viewer.css';
import * as Core from "@mescius/activereportsjs/core";
//配布ライセンスキーを設定(トライアル版で利用する場合は、以下のコードは不要です)
Core.setLicenseKey(process.env.NEXT_PUBLIC_ACTIVEREPORTSJS_KEY?.toString() || '');
// ─── 型定義 ───────────────────────────────────────────────
// レポート定義(JSON)の最小型
interface ReportData {
id?: string;
title?: string;
[key: string]: unknown;
}
// Viewer の標準 props + 画面専用 props
export type ViewerWrapperProps = ViewerProps & {
report?: ReportData;
language?: string;
parameters?: Record<string, unknown>;
reportData?: string | null;
};
// DataSource の接続設定
type ReportConnectionProperties = {
DataProvider?: string;
ConnectString?: string;
};
// DataSource まわりのみ扱う最小型
type ReportWithDataSources = {
DataSources?: { ConnectionProperties?: ReportConnectionProperties }[];
};
// ツールバー追加ボタン
type ToolbarButton = {
key: string;
icon: {
type: 'font';
iconCssClass: string;
fontSize: string;
};
text: string;
enabled: boolean;
action: () => void;
};
// ツールバーのレイアウト
type ToolbarLayout = {
default: string[];
};
// export API のフォーマット
type ExportFormat = 'pdf' | 'xlsx';
// export API の戻り値
type ExportResult = {
download?: (fileName: string) => void;
};
// Viewer の内部 API(公開型にない部分)
type ViewerInternalApi = {
toolbar?: {
addItem: (item: ToolbarButton) => void;
updateLayout: (layout: ToolbarLayout) => void;
};
toggleSidebar?: () => void;
};
// Viewer 型 + 内部 API + export
type ViewerWithInternalApi = Viewer & {
Viewer?: ViewerInternalApi;
export: (format: ExportFormat, options: { filename: string }) => Promise<ExportResult>;
};
// ─── メインコンポーネント ──────────────────────────────────
const ViewerWrapper = (props: ViewerWrapperProps) => {
// Viewer インスタンス参照
const viewerRef = React.useRef<Viewer>(null);
// reportData を JSON データソース接続文字列へ変換
const getConnectionString = React.useCallback((reportData: string | null): string => {
// データなしの場合
if (!reportData) {
return 'jsondata=null';
}
try {
// オブジェクトの場合は JSON 文字列へ変換
const jsonData = typeof reportData === 'string' ? reportData : JSON.stringify(reportData);
const result = `jsondata=${jsonData}`;
return result;
} catch (error) {
// 変換失敗時は安全側へフォールバック
console.error('[ReportViewer] 接続文字列の生成中にエラーが発生しました:', error);
return 'jsondata=null';
}
}, []);
// レポート定義の JSON 接続文字列を更新
const setReportDataConnection = React.useCallback((
report: ReportWithDataSources | null,
reportData: string | null,
): void => {
try {
if (report?.DataSources?.[0]?.ConnectionProperties) {
const connectionProperties = report.DataSources[0].ConnectionProperties;
if (
connectionProperties?.DataProvider?.includes('JSON') &&
connectionProperties?.ConnectString?.includes('jsondata')
) {
const newConnectionString = getConnectionString(reportData);
connectionProperties.ConnectString = newConnectionString;
} else {
console.warn('[ReportViewer] 接続プロパティが必要な条件を満たしていません。');
console.warn('[ReportViewer] DataProvider:', connectionProperties?.DataProvider);
console.warn('[ReportViewer] ConnectString:', connectionProperties?.ConnectString);
}
} else {
console.warn('[ReportViewer] レポートに有効な接続プロパティがありません。');
console.warn('[ReportViewer] DataSources:', report?.DataSources);
}
} catch (error) {
console.error('[ReportViewer] 接続プロパティの更新中にエラーが発生しました:', error);
}
}, [getConnectionString]);
// 初回表示時にツールバーを拡張
React.useEffect(() => {
if (!viewerRef.current) return;
// ツールバー初期化処理
const handleReportLoaded = () => {
try {
// 非公開 API を型付けして扱う
const viewer = viewerRef.current as unknown as ViewerWithInternalApi;
// toolbar 未初期化なら処理しない
if (!viewer?.Viewer?.toolbar || typeof viewer.Viewer.toolbar !== 'object') {
return;
}
// 初期表示時はサイドバーを閉じる
if (
viewer?.Viewer?.toggleSidebar &&
typeof viewer.Viewer.toggleSidebar === 'function'
) {
try {
viewer.Viewer.toggleSidebar();
} catch (e) {
console.debug('[ReportViewer] toggleSidebar error:', e);
}
}
// PDF 出力ボタン
const pdfExportButton: ToolbarButton = {
key: '$pdfExport',
icon: {
type: 'font',
iconCssClass: 'mdi mdi-file-pdf-box',
fontSize: '26px',
},
text: '',
enabled: true,
action: function () {
viewer
.export('pdf', { filename: 'レポート' })
.then((result: ExportResult) => result.download?.('レポート'))
.catch((e: unknown) =>
console.error('[ReportViewer] PDF export failed:', e),
);
},
};
// Excel 出力ボタン
const excelExportButton: ToolbarButton = {
key: '$excelExport',
icon: {
type: 'font',
iconCssClass: 'mdi mdi-file-excel-box',
fontSize: '26px',
},
text: '',
enabled: true,
action: function () {
viewer
.export('xlsx', { filename: 'レポート' })
.then((result: ExportResult) => result.download?.('レポート'))
.catch((e: unknown) =>
console.error('[ReportViewer] Excel export failed:', e),
);
},
};
// ボタン追加 + レイアウト更新
if (
viewer.Viewer.toolbar &&
typeof viewer.Viewer.toolbar.addItem === 'function'
) {
viewer.Viewer.toolbar.addItem(pdfExportButton);
viewer.Viewer.toolbar.addItem(excelExportButton);
viewer.Viewer.toolbar.updateLayout({
default: ['$navigation', '$zoom', '$print', '$pdfExport', '$excelExport'],
});
}
} catch (e) {
console.error('[ReportViewer] Toolbar customization failed:', e);
}
};
// Viewer の内部初期化完了後に実行
const timer = setTimeout(handleReportLoaded, 0);
return () => clearTimeout(timer);
}, []);
// report / reportData / parameters 変更時に再オープン
React.useEffect(() => {
if (!props.report || !viewerRef.current) return;
// open へ渡すレポート定義
const reportDef = props.report as Record<string, unknown>;
// Viewer 初期化待ちのため、少し遅延して open
const timer = setTimeout(() => {
try {
// 先に接続文字列を更新
setReportDataConnection(
reportDef as ReportWithDataSources | null,
props.reportData || null,
);
// parameters がある場合は reportParameters として渡す
if (props.parameters && typeof props.parameters === 'object') {
const reportSettings = {
reportParameters: props.parameters as Record<string, unknown>,
};
// open(レポート定義, 設定)
viewerRef.current?.open(
reportDef as unknown as Parameters<typeof viewerRef.current.open>[0],
reportSettings as unknown as Parameters<typeof viewerRef.current.open>[1],
);
} else {
// parameters がない場合はレポート定義のみ
viewerRef.current?.open(reportDef as unknown as Parameters<typeof viewerRef.current.open>[0]);
}
} catch (e) {
console.error('[ReportViewer] Report open failed:', e);
}
}, 10);
// タイマーのクリーンアップ
return () => clearTimeout(timer);
}, [props.report, props.reportData, props.parameters, setReportDataConnection]);
const { ...rest } = props;
return (
<Viewer
{...rest}
ref={viewerRef}
language={props.language || 'ja'}
zoom="FitPage"
/>
);
};
export default ViewerWrapper;上記のコードに加えて、ビューワのカスタマイズで使用しているアイコンを読込むため「globals.css」に以下の強調表示箇所を追加してください。
@import url("https://cdn.materialdesignicons.com/2.8.94/css/materialdesignicons.min.css");
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
~~ 以下省略 ~~'use client';
import { useState, useRef, useEffect, useMemo } from 'react';
import dynamic from 'next/dynamic';
import { useMastra } from '../app/hooks/use-mastra';
import { parseMessage, type DataSource } from '../lib/report-parser';
// SSR を避けるため動的インポート(ActiveReports ビューワー + PDF/Excel エクスポート付き)
const ReportViewer = dynamic<Record<string, unknown>>(
async () => (await import('./report-viewer')).default,
{ ssr: false },
);
// ─── 型定義 ───────────────────────────────────────────────
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
report?: Record<string, unknown>;
reportData?: string | null;
dataSources?: DataSource[];
parameters?: Record<string, unknown>;
}
interface ChatComponentProps {
agentId?: string;
}
// ─── 定数 ─────────────────────────────────────────────────
const HEADER_BG = '#697481';
const USER_BUBBLE_BG = '#505050';
const MESSAGE_AREA_BG = '#EFEDEA';
const REPORT_HEIGHT = 500;
const REPORT_INNER_HEIGHT = 490;
// ─── サブコンポーネント ────────────────────────────────────
/** ページ上部のヘッダー */
function ChatHeader() {
return (
<div
className="border-b border-gray-100 px-6 py-2 shadow-sm"
style={{ background: HEADER_BG }}
>
<h1 className="text-2xl font-bold text-white">
業務日報エージェント
</h1>
<p className="text-sm text-white">
日報の登録・ユーザー管理・帳票表示をサポートします
</p>
</div>
);
}
/** メッセージが無いときのウェルカム表示 */
function WelcomeMessage() {
return (
<div className="rounded-lg bg-white p-8 text-center shadow">
<p className="text-lg text-gray-500">
👋 ようこそ!業務日報エージェントです
</p>
<p className="mt-2 text-sm text-gray-400">
以下のような操作ができます:
</p>
<ul className="mt-3 space-y-1 text-sm text-gray-400">
<li>📝 日報の登録・更新・削除 ― 例:「今日の作業報告を登録して」</li>
<li>👤 ユーザーの登録・一覧 ― 例:「ユーザーを登録して」</li>
<li>📊 帳票の表示 ― 例:「〇月×日の業務日報を表示して」</li>
</ul>
</div>
);
}
/** 個々のチャットメッセージ(吹き出し + レポート) */
function MessageBubble({ message }: { message: ChatMessage }) {
const isUser = message.role === 'user';
return (
<div className="space-y-2">
{/* 吹き出し */}
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-xs rounded-lg px-4 py-3 lg:max-w-md ${
isUser ? 'text-white' : 'bg-white text-gray-900 shadow'
}`}
style={isUser ? { background: USER_BUBBLE_BG } : undefined}
>
<p className="whitespace-pre-wrap text-sm leading-relaxed">
{message.content}
</p>
<p
className={`mt-2 text-xs ${
isUser ? 'text-blue-100' : 'text-gray-500'
}`}
>
{message.timestamp.toLocaleTimeString()}
</p>
</div>
</div>
{/* レポートビューア */}
{message.report && <ReportPanel report={message.report} reportData={message.reportData} parameters={message.parameters} />}
</div>
);
}
/** レポートの埋め込み表示 */
function ReportPanel({
report,
reportData,
parameters,
}: {
report: Record<string, unknown>;
reportData?: string | null;
parameters?: Record<string, unknown>;
}) {
return (
<div className="mx-auto mt-4 w-full max-w-3xl">
<div
className="rounded-lg bg-gray-100 shadow-lg overflow-hidden p-4"
style={{ height: REPORT_HEIGHT }}
>
<div
style={{
width: '100%',
height: REPORT_INNER_HEIGHT,
transformOrigin: 'top center',
overflow: 'hidden',
}}
>
<ReportViewer report={report} reportData={reportData} language="ja" parameters={parameters} />
</div>
</div>
</div>
);
}
/** ローディングアニメーション */
function LoadingIndicator() {
return (
<div className="flex justify-start">
<div className="rounded-lg bg-white px-4 py-3 shadow">
<div className="flex gap-2">
{[0, 0.2, 0.4].map((delay) => (
<div
key={delay}
className="h-3 w-3 animate-bounce rounded-full bg-gray-400"
style={{ animationDelay: `${delay}s` }}
/>
))}
</div>
</div>
</div>
);
}
/** エラー表示 */
function ErrorMessage({ message }: { message: string }) {
return (
<div className="flex justify-start">
<div className="rounded-lg bg-red-100 px-4 py-3 text-red-900 shadow">
<p className="font-semibold">エラー</p>
<p className="text-sm">{message}</p>
</div>
</div>
);
}
/** メッセージ入力フォーム */
function ChatInput({
value,
onChange,
onSubmit,
loading,
}: {
value: string;
onChange: (v: string) => void;
onSubmit: () => void;
loading: boolean;
}) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSubmit();
}
};
return (
<div className="border-t border-gray-200 bg-white px-6 py-4 shadow-lg">
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
className="mx-auto max-w-3xl"
>
<div className="flex gap-3">
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="日報の登録、ユーザー管理、帳票表示などを依頼できます (Shift+Enter で改行)"
rows={3}
className="flex-1 rounded-lg border border-gray-300 px-4 py-3 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 resize-none"
disabled={loading}
/>
<button
type="submit"
disabled={loading || !value.trim()}
className="rounded-lg bg-blue-500 px-6 py-3 font-semibold text-white hover:bg-blue-600 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{loading ? '...' : '送信'}
</button>
</div>
</form>
</div>
);
}
// ─── メインコンポーネント ──────────────────────────────────
export function ChatComponent({ agentId = 'workreport-agent' }: ChatComponentProps) {
const { messages: hookMessages, loading, error, sendMessage } = useMastra({ agentId });
const [input, setInput] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
// フックのメッセージをパースして ChatMessage 形式に変換
const chatMessages = useMemo<ChatMessage[]>(
() =>
hookMessages.map((msg, index) => {
const parsed = parseMessage(msg.content);
return {
id: `msg-${index}`,
role: msg.role as 'user' | 'assistant',
content: parsed.content,
timestamp: msg.timestamp || new Date(), // hook から付与された timestamp を使用
report: parsed.report,
reportData: parsed.reportData,
dataSources: parsed.dataSources,
parameters: parsed.parameters,
};
}),
[hookMessages],
);
// 新しいメッセージ追加時に自動スクロール
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [chatMessages]);
const handleSubmit = async () => {
if (!input.trim() || loading) return;
const userInput = input;
setInput('');
try {
await sendMessage(userInput);
} catch (err) {
console.error('メッセージ送信エラー:', err);
}
};
return (
<div className="flex h-screen flex-col bg-gradient-to-br from-blue-50 to-indigo-50">
<ChatHeader />
{/* メッセージ表示エリア */}
<div
className="flex-1 overflow-y-auto px-6 py-4"
style={{ background: MESSAGE_AREA_BG }}
>
<div className="mx-auto max-w-3xl space-y-4">
{chatMessages.length === 0 && !loading && <WelcomeMessage />}
{chatMessages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
{loading && <LoadingIndicator />}
{error && <ErrorMessage message={error} />}
<div ref={messagesEndRef} />
</div>
</div>
<ChatInput
value={input}
onChange={setInput}
onSubmit={handleSubmit}
loading={loading}
/>
</div>
);
}
最後に、追加したレポートやレポートツールのデータ取得に対応するようにプロンプトを変更します。また、プロンプト自体も冗長な表現などがあったため見直しを行い、以下のように変更します。
export const workreportInstructions: string = `
## ロール
日報データベース操作と帳票表示を担当するエージェント
## 主な処理
- データ操作: crud-tool (create/read/update/delete)
- 天気取得: weather-tool → dailyReports に格納
- レポート表示(帳票化): reportTool
* ユーザー確認後に実行
* reportName と filters を指定 → 内部でデータ取得・レポート生成
## 一般ルール
1. **簡潔な出力**: 必要な情報だけを返す
2. **表示・プレビュー要求時**
- **条件が明確な場合(例:「2/6のレポート表示して」「帳票形式でみせて」)**
→ 確認スキップ、直接 reportTool を実行
- **条件が不明な場合(例:「日報を表示して」)**
→ 「帳票形式とテキスト形式のどちらで表示しますか?」と確認
→ ユーザー選択後に reportTool を実行
3. **不足情報**: 必要な値が不足時は具体的に短く質問
## DB スキーマ
- users: id, name, email, created_at
- dailyReports: id, userId, reportDate, workContent, issues, nextActions, weather, temperature, weatherSource, rawInput
## CRUD 使用例
- 作成: { operation: "create", table: "users", data: { name: "...", email: "..." } }
- 読込: { operation: "read", table: "dailyReports", where: { userId: 1, reportDate: "2026-03-02" } }
- 更新: { operation: "update", table: "dailyReports", data: { workContent: "..." }, where: { id: 123 } }
- 削除: { operation: "delete", table: "dailyReports", where: { id: 123 } }
## weather-tool
- 位置情報は "location" パラメータで指定する
- 指定なし → 場所を質問する
- 複合的な地域表記 → 代表的な地名を抽出して設定する
* 例: 「東京都新宿区」→「新宿」(より詳細な地名を優先)
* 例: 「福岡県」→「福岡」(代表的な市名)
* 例: 「大阪府大阪市北区」→「大阪」または「北区」(重要度合いで判定)
- 日本語の場所 → 英語に翻訳する(例: 東京 → Tokyo、新宿 → Shinjuku)
- 湿度、風、降水量なども含めた詳細情報を取得・組み込む
- **取得した天気情報をデータベースに登録する際は、必ず日本語で登録すること** (例: "Sunny" → "晴れ")
- 取得結果を weather, temperature, weatherSource フィールドに格納する
- ユーザーが天気予報に基づいた活動を尋ねた場合は、その天気に基づいた活動・提案を行う
## 日報作成・更新フロー
**日報作成・更新時は以下の処理を必ず実行すること:**
### ステップ1: 文体の統一(である調・文語体)
- ユーザープロンプトが口語(話し言葉)である場合、必ずビジネス文書として適切な文語体に変換する
- 文体統一ルール:
* 「です」→「である」
* 「ます」→「する」
* 「〜した」→「〜を実施した」または「〜に取り組んだ」
* 「〜があった」→「〜が発生した」または「〜が生じた」
* 「〜してました」→「〜を行った」または「〜を実施した」
* 「〜やります」→「〜を進める」または「〜を予定している」
- 対象フィールド: workContent(作業内容), issues(課題), nextActions(次のアクション)
- 例:
* 「今日は〇〇をやってました」→「本日、〇〇の業務を実施した」
* 「問題があった」→「技術的課題が発生した」
* 「明日もやります」→「明日も同様の作業を進める予定である」
### ステップ2: 天気情報の取得・格納
- weather-tool で天気情報を取得する
- 取得した情報を日本語でデータベースに登録する
- weather, temperature, weatherSource フィールドに格納する
### ステップ3: 元の入力を保存
- 変換前のユーザー元入力を rawInput フィールドに記録する(監査・追跡用)
## レポート表示フロー
### パターンA: 条件が明確な場合(直接実行)
ユーザー: 「2026-03-02の日報を帳票形式で表示して」
↓
エージェント: 直接 reportTool を呼び出す
reportTool({ reportName: 'daily_reports', filters: { reportDate: '2026-03-02' } })
↓
レポート表示
### パターンB: 条件が不明な場合(確認フロー)
ユーザー: 「日報を表示して」
↓
エージェント: **確認質問**
「帳票形式とテキスト形式のどちらで表示しますか?」
↓
ユーザー: 「帳票形式で」
↓
エージェント: reportTool を呼び出す
reportTool({ reportName: 'daily_reports', filters: { reportDate: 'today' } })
↓
レポート表示
### 実装ガイドライン
1. reportName に応じてフィルタ条件を決定
* daily_reports → reportDate, userId を使用(指定あれば)
* test → フィルタ不要
2. ユーザーの指定条件を filters に設定
* 例: reportDate="2026-03-02", userId(指定あれば)
* **指定なし → filters を空オブジェクト()で全件取得**
- reportTool({ reportName: 'daily_reports', filters: {} })
3. reportTool({ reportName: 'daily_reports', filters: { reportDate: '2026-03-02' } }) 形式で実行
4. 報告後は「レポートを表示します。」だけ返す(JSON 出力厳禁)
## reportTool パラメータ
- reportName(必須): "daily_reports" または "test"
* daily_reports: 業務日報(reportDate, userId でフィルタ可)
* test: テスト用レポート
- filters(オプション): DB から取得する条件
* reportDate: "YYYY-MM-DD" 形式で指定(例: "2026-02-06")
* userId: ユーザーID(数値)
* 両方指定 → AND で結合(例: userId=1 かつ reportDate="2026-02-06")
* 指定なし → 全件取得
- reportData: JSON文字列 ← 外部で取得済み のみ(通常は不要)
- parameters: レポートパラメータ(オプション)
## reportTool の動作
1. reportName で対象レポート特定
2. filters がある場合 → DB から該当データ取得
3. レポート定義(RDLX-JSON) + データをセット
4. レポート生成(フロントエンドで描画)
## 重要: reportTool 後の出力ルール
- ツール結果の詳細を説明しない
- JSON を含めない
- コードブロック厳禁
- テキストは確認メッセージのみ
`;これで、すべての実装が完了しましたので、最後に実際にAIエージェントを使ってみます。
業種を問わず日報は、さまざまな形で利用されていますので、今回は以下のサンプルケースを登録してそれぞれ帳票出力を行ってみます。
今回は、「MastraとActiveReportsJSで実現する日報AIエージェント」の最終回として、フロントエンド部分の実装などを解説したほか、実際に日報AIエージェントを利用した日報の登録、帳票表示など一連の動作確認まで行いました。
業務アプリケーション開発では、CRUD操作や帳票機能は必要不可欠であり、これはAIエージェントに業務機能を実装する上でも同様です。フロントエンドだけで実装可能な「ActiveReportsJS」と、「Mastra」「Next.js」を利用することで、今回ご紹介したようなAIエージェントの構築が可能になります。ぜひ本記事を参考に、業務AIエージェントを構築していただけると幸いです。
今回ご紹介したソースコードはGitHubで公開しています。こちらも是非ご確認ください。
製品サイトでは、今回ご紹介したActiveReportsJSの機能を手軽に体験できるデモアプリケーションやトライアル版も公開しておりますので、こちらもご確認ください。
また、ご導入前の製品に関するご相談、ご導入後の各種サービスに関するご質問など、お気軽にお問合せください。
]]>ExcelライクなスプレッドシートをWeb上で実現するJavaScriptライブラリ「SpreadJS(スプレッドJS)」は、このReactをはじめとする主要なJavaScriptフレームワークに対応しています。
本記事では、ReactとViteを使ってSpreadJSを組み込んだアプリケーションを構築する方法についてご紹介します。
Reactのアプリケーションはビルドツール「Vite」を使用することで非常に簡単に作成することが可能です。
※ ReactではこれまでReactアプリケーションの作成に使えるコマンドラインツール「Create React App」を提供していましたが、現在は非推奨になっています。
最初に、ターミナル等で以下のコマンドを実行して、ベースとなるReactアプリケーションを作成します。
npm create vite@latest spreadjs-vite-app実行後するといくつかの選択肢が提示されるので、フレームワークとバリアントに「React」と「TypeScript」を選択し、その他の項目は既定値のままにします。
アプリケーションが作成されたら、cdコマンドを実行してアプリケーションのフォルダに移動します。
cd spreadjs-vite-app移動後に以下のコマンドを実行してアプリケーションを起動します。
npm run devブラウザで「http://localhost:5173/」を参照するとReactアプリケーションが表示されます。

動作を確認したらターミナルウィンドウに戻り、Ctrl+Cキーを押下することで実行を中断できます。
先ほど作成したReactアプリケーションにSpreadJSを組み込んでいきます。最初に行うのはSpreadJS関連モジュールのインストールです。
npm installコマンドを実行してSpreadJSのReactモジュールをインストールします。先ほど作成したプロジェクトのルートフォルダで次のコマンドを実行します。
npm install @mescius/spread-sheets
npm install @mescius/spread-sheets-react
npm install @mescius/spread-sheets-resources-ja上記コマンドでは最新バージョンがインストールされますが、「@18.2.4」のようにバージョンを指定してインストールすることもできます。
npm install @mescius/[email protected]
npm install @mescius/[email protected]
npm install @mescius/[email protected]続いて「src/App.tsx」ファイルを次のように変更します。
※ ライセンスキーを設定しない場合トライアル版を示すメッセージが表示されます。ライセンスキーの入手や設定方法についてはこちらをご覧ください。
import { useState } from 'react';
import { SpreadSheets, Worksheet, Column } from '@mescius/spread-sheets-react';
import * as GC from '@mescius/spread-sheets';
import '@mescius/spread-sheets-resources-ja';
import './App.css'
import '@mescius/spread-sheets/styles/gc.spread.sheets.excel2013white.css';
// ライセンスキーとカルチャの設定
GC.Spread.Sheets.LicenseKey = 'ここにSpreadJSのライセンスキーを設定します';
GC.Spread.Common.CultureManager.culture('ja-jp');
const App = () => {
let spread: GC.Spread.Sheets.Workbook;
const [hostStyle] = useState({
width: '865px',
height: '250px',
border: '1px solid silver'
});
const [data] = useState([
{ 商品名: 'デスクトップPC', カテゴリ: 'PC本体', 単価: 128000, '購入店': 'PCショップA' },
{ 商品名: 'ラップトップPC', カテゴリ: 'PC本体', 単価: 249000, '購入店': 'PCショップA' },
{ 商品名: 'マウス', カテゴリ: '周辺機器', 単価: 1980, '購入店': 'PCショップA' },
{ 商品名: 'キーボード', カテゴリ: '周辺機器', 単価: 5680, '購入店': '家電量販店B' },
{ 商品名: 'プリンタ', カテゴリ: '周辺機器', 単価: 14480, '購入店': '家電量販店B' },
{ 商品名: 'SSD', カテゴリ: 'PCパーツ', 単価: 7980, '購入店': 'ネットショップC' },
{ 商品名: 'デスクトップPC', カテゴリ: 'PC本体', 単価: 168000, '購入店': 'PCショップA' },
{ 商品名: 'ラップトップPC', カテゴリ: 'PC本体', 単価: 249000, '購入店': 'PCショップA' },
{ 商品名: 'マウス', カテゴリ: '周辺機器', 単価: 4980, '購入店': 'PCショップA' },
{ 商品名: 'キーボード', カテゴリ: '周辺機器', 単価: 4680, '購入店': '家電量販店B' },
]);
const [columnWidth] = useState(184);
const initSpread = (spreadObj: GC.Spread.Sheets.Workbook) => {
spread = spreadObj;
//// 実行時にコードでデータソースと列幅を設定する場合
// const sheet = spread.getActiveSheet();
// sheet.setDataSource(data);
// sheet.defaults.colWidth = 184;
}
return (
<div className="sample-spreadsheets">
<SpreadSheets hostStyle={hostStyle} workbookInitialized={spread => initSpread(spread)}>
<Worksheet dataSource={data}>
<Column dataField='商品名' width={columnWidth}></Column>
<Column dataField='カテゴリ' width={columnWidth}></Column>
<Column dataField='購入店' width={columnWidth}></Column>
<Column dataField='単価' width={columnWidth} formatter="¥ #,###"></Column>
</Worksheet>
{/* 実行時にコードでデータソースと列幅を設定する場合 */}
{/* <Worksheet>
<Column dataField='商品名'></Column>
<Column dataField='カテゴリ'></Column>
<Column dataField='購入店'></Column>
<Column dataField='単価' formatter="¥ #,###"></Column>
</Worksheet> */}
</SpreadSheets>
</div>
)
}
export default AppコントロールをReactに組み込む場合、クラスコンポーネントとして組み込む方法もありますが、新規開発での使用は非推奨となっているので、ここでは関数コンポーネントを使用しています。
また、上記ではReact用に用意されたSpreadJSのタグを使ってデータソースの設定と列幅の指定を行っていますが、関数式のinitSpread内で実行時にコード(コメントアウト箇所)で設定することも可能です。
※ React用のSpreadJSのタグについてはヘルプをご参照ください。
以上で、SpreadJSの組み込みが完了しました。
再度アプリケーションを実行して「http://localhost:5173/」に接続すると、データ連結したSpreadJSが表示されます。

今回の記事では、ReactとViteを使ってSpreadJSを組み込んだアプリケーションを構築する方法についてご紹介しました。
製品サイトでは、SpreadJSの機能を手軽に体験できるデモアプリケーションやトライアル版も公開しておりますので、こちらもご確認ください。
また、ご導入前の製品に関するご相談、ご導入後の各種サービスに関するご質問など、お気軽にお問合せください。
]]>サービスパックで変更された内容については、上記のリンク先をご覧ください。
サービスパックは以下のアップデートページよりダウンロード可能です。
]]>
InputManJSは、Webフォーム用の入力コントロールを提供するJavaScriptコントロールセットです。テキストやマスク、日付時刻、数値、コンボといった入力に特化したコントロールを豊富に搭載しています。
今回紹介する「コメントコンポーネント」は、チャットやフォーラム、会話アプリなどで見られるコメント機能のUIを構築できるコントロールです。用途に合わせた表示モードや入力エディタの選択、ユーザー制御や投稿へのアクションなど、コメントのUI構築に必要な機能を豊富に搭載しています。
以下の記事では、InputManJSのコメントコンポーネント機能を用い、双方向通信ライブラリ「Socket.IO」と連携して、リアルタイムに更新されるチャットアプリケーションのUIを構築する方法を解説します。
InputManJSのコメントコンポーネントはオンラインデモアプリケーションで実際にお試しいただくことが可能です。また、InputManJSにはコメントコンポーネント以外にも高機能な入力コントロールが多数搭載されていますので、気になった方はデモアプリケーションやトライアル版をぜひお試しください。
また、35年以上の開発実績・120万ライセンスの販売実績を持つメシウス製品の活用事例を紹介した資料を公開しています。
本資料では、UXの向上や定期的な新機能の追加や機能更新など、ユーザーの満足度を高める取り組みが欠かせないSaaS開発におけるよくある課題をどのように解決できるのか、事例付きで解説します。
資料は以下より無料でダウンロードできます。ぜひご覧ください。
CodeZineで公開しているメシウス製品のTIPSやイベントレポートは以下よりご確認いただけます。
]]>本連載ではWebサイトで公開しているサンプルアプリケーションを例に、さまざまな業務アプリケーションのUIの実装のポイントや、コントロールの選定ポイントを解説していきます。第3回となる本記事ではJavaScript UIライブラリ「Wijmo(ウィジモ)」の各種コントロールを使用した「WBS(プロジェクト工数管理表)」デモの解説です。
本デモはデータグリッドコントロールの「FlexGrid」と「MultiRow」をベースにしたものと、スプレッドシートコントロールの「FlexSheet」をベースにしたものの2種存在します。
※ FlexGrid/MultiRow版はPureJS、FlexSheet版はAngularとReactのサンプルとして提供しています。
それぞれ同様の機能を実現していますが、使用するコントロールにより実装の方法や手間が異なっているので、その点も詳しく解説していきます。

WBSデモではWijmoの以下のコントロールを使用しています。
| コントロール名 | 利用目的 | 対象デモ | その他 |
|---|---|---|---|
| InputNumber | 1日の規定労働時間を入力する | FlexGrid/MultiRow | |
| InputDate | プロジェクトの開発期間を入力する | 共通 | |
| FlexGrid、MultiRow | プロジェクトのWBSを表示する | FlexGrid/MultiRow | ![]() ![]() |
| FlexSheet | FlexSheet | ![]() | |
| Popup | 1日の労働時間の不正入力時のアラート表示 | FlexGrid/MultiRow | ![]() |
| FlexGridFilter | WBSをフィルタする | FlexGrid/MultiRow (FlexGrid使用の画面のみ) | ![]() |
| UndoStack | Undo/Redoを機能を追加 | FlexGrid/MultiRow | ‐ |
ここからは各コントロールのソースコード部分を抜粋し、ポイントとなる部分を解説します。
FlexGrid(在庫管理システム開発タブ)、MultiRow(勤怠管理システム開発タブ)、FlexSheetそれぞれでセル結合して1レコード複数行を実現しています。MultiRowはlayoutDefinitionを使用してレコード内のセルを縦横いずれの方向にも結合することが可能です。一方、FlexGridとFlexSheetの場合、wijmo.grid.MergeManagerクラスを拡張して、そのオブジェクトを定義してグリッドのmergeManagerプロパティに割り当てることで実現しました。以下のFlexGridのカスタムセル結合のデモもご参考ください
export class RestrictedMergeManager extends MergeManager {
getMergedRange(p, r, c, clip = true) {
if (grayTextCellNames.includes(p.columns[c].binding)) {
// create basic cell range
var rng = null;
// start with single cell
rng = new CellRange(r, c);
// expand up
while (rng.row > 0 && rng.row % 2 !== 0) {
rng.row--;
}
// expand down
while (rng.row2 < p.rows.length - 1 && (rng.row2 + 1) % 2 !== 0) {
rng.row2++;
}
// don't bother with single-cell ranges
if (rng.isSingleCell) {
rng = null;
}
// done
return rng;
}
}
}工程、タスク、担当者の列を2行単位でセル結合をしています。

// ベースヘッダーの行のセル結合
export const baseHeaderMerge = (
headerStartRow: number,
headerStartCol: number,
flexSheet: FlexSheet,
) => {
// カスタムMergeManagerを設定
flexSheet.mergeManager = new MergeManager();
flexSheet.mergeManager.getMergedRange = function(p, r, c, clip) {
if (flexSheet.sheets.selectedIndex !== 0) {
// 先頭シート以外は結合表示しない
return null;
}
let rng = null;
if (Object.values(BASE_HEADERS).length + headerStartCol > c
&& headerStartRow < r) {
// start with single cell
rng = new CellRange(r, c);
// expand up
while (rng.row > 0 && rng.row % 2 === 0) {
rng.row--;
}
// expand down
while (rng.row2 < p.rows.length - 1 && (rng.row2 + 1) % 2 === 0) {
rng.row2++;
}
// don't bother with single-cell ranges
if (rng.isSingleCell) {
rng = null;
}
// done
}
return rng;
}
}FlexSheetでは工程から工数消化率の列までを2行単位でセル結合をしています。

それぞれのデモにおいて、入力されたデータに応じて実績や出来高、工数消化などが自動的に計算されます。
FlexGrid(MultiRow)は計算に使用するセルの値が変更する度に、再計算してセルの値を更新する処理を実装する必要があります。例えば、セルに値が張り付けられた際や、セルの入力が完了した場合、行や列が追加・削除された場合などです。
export function getUpdatedView(s, e) {
// FlexGridが描画された後に実行する
recalculateActualTime(s); // 実績(d)の計算
// 全体実績の計算
calcAllCell(s.columns.getColumn(ActualTime).index, s.columns.getColumn(ActualTime).index, s);
// 全体予定の計算
calcAllCell(s.columns.getColumn(ScheduledTime).index, s.columns.getColumn(ScheduledTime).index, s);
// 各々のその他の進捗率を計算
PersonnelList.forEach((personnel) => {
calcCommonProgressRate(0, s.columns.getColumn(Personnel).index, personnel, s);
});
calcProgressRate(0, s.columns.getColumn(Personnel).index, s); // 全体進捗率を計算
// イベントハンドラーを解除
s.updatedView.removeHandler(getUpdatedView);
}
export function getCalculatedFields() {
return {
yield: ($) => {
if ($.isCopy)
return "";
else if ($.scheduledTime === undefined || $.progressRate === undefined)
return 0;
else
return $.scheduledTime * $.progressRate;
},
manHourDigestionRate: ($) => {
if ($.isCopy)
return "";
else if ($.scheduledTime === 0 ||
$.actualTime === undefined ||
$.scheduledTime === undefined ||
$.actualTime === "" ||
$.scheduledTime === "")
return 0;
else
return $.actualTime / $.scheduledTime;
},
};
}
export function getPastedCell(s, e) {
getCellEditEnded(s, e);
}
export function getCellEditEnded(s, e) {
const col = s.columns[e.col];
// コピーセルの工程・タスク担当者を一致させる処理
// multrowの場合はスキップ
if (isFlexGrid() && e.row % 2 === 0) {
let grayTextIndex = grayTextCellNames.indexOf(col.binding);
if (grayTextIndex !== -1) {
s.setCellData(e.row + 1, s.columns.findIndex((c) => c.binding === grayTextCellNames[grayTextIndex]), s.getCellData(e.row, col.index, false));
}
}
// 実績(d)の計算
if (e.row % 2 !== 0 && col.binding.startsWith(dateCellBinding)) {
calcActualTime(s, e.row);
calcAllCell(s.columns.getColumn(ActualTime).index, s.columns.getColumn(ActualTime).index, s); // 全体実績の計算
}
// 全体予定の計算
if (e.row % 2 === 0 && col.binding === ScheduledTime) {
calcAllCell(e.col, e.col, s);
}
// 編集されたセルの担当を取得
const editedPersonnel = s.getCellData(e.row, s.columns.getColumn(Personnel).index, true);
setTimeout(() => {
// その他行の進捗率の計算(出来高/予定時間) 出来高=予定時間x進捗率, 予定時間
// 編集後の担当者の進捗率を更新
calcCommonProgressRate(e.row, e.col, editedPersonnel, s);
// 編集前の担当者の進捗率を更新(削除された担当者は除く)
if (e.col === s.columns.getColumn(Personnel).index &&
editedPersonnel !== e.data &&
PersonnelList.includes(e.data) &&
!(e.data === undefined || e.data === null || e.data === "")) {
calcCommonProgressRate(e.row, e.col, e.data, s);
}
calcProgressRate(e.row, e.col, s); // 全体の進捗率を計算
}, 0);
if (e.col === s.columns.getColumn(Personnel).index) {
// 新しい担当者がリストに存在しない場合、「その他」セルの行を追加
// 既存の担当者リストを取得
if (!PersonnelList.includes(editedPersonnel) && !isEmpty(editedPersonnel)) {
let newRow = {
process: CommonText,
personnel: editedPersonnel,
scheduledTime: 3,
// progressRate: 0,
};
// 追加するIndexを取得
let insertIndex = s.collectionView.sourceCollection.findIndex((item) => ![CommonText, AllText].includes(item.process) &&
(item.isSample === undefined || !item.isSample));
s.collectionView.sourceCollection.splice(insertIndex, 0, newRow);
if (isFlexGrid()) {
// コピー行
let newCopyRow = {
process: CommonText,
personnel: editedPersonnel,
isCopy: true,
scheduledTime: "",
};
s.collectionView.sourceCollection.splice(insertIndex + 1, 0, newCopyRow);
}
PersonnelList.push(editedPersonnel);
}
// 担当者が消えた場合
if (e.col === s.columns.getColumn(Personnel).index &&
editedPersonnel !== e.data) {
let personnelList = [
...new Set(s.collectionView.sourceCollection
.filter((item) => item.process !== CommonText)
.map((item) => item.personnel)),
];
if (!personnelList.includes(e.data)) {
let removeIndex = s.collectionView.sourceCollection.findIndex((item) => item.process === CommonText && item.personnel === e.data);
// FlexGridの場合はコピー行が格納されているが、MultRowにはコピー行がない点を考慮して要素を削除
let removeCount = isFlexGrid() ? 2 : 1;
s.collectionView.sourceCollection.splice(removeIndex, removeCount);
PersonnelList = PersonnelList.filter((item) => item !== e.data);
}
}
s.collectionView.refresh();
}
// Number型のセルでないときに数値のフォーマットを適用させる
let cellData = s.getCellData(e.row, col.index, false);
if (!(col.dataType === DataType.Number) &&
!isNaN(cellData) &&
cellData !== "") {
s.setCellData(e.row, e.col, Globalize.formatNumber(Number(cellData), "n2"));
}
// 新規行を追加
if (e.row >= s.rows.length - 2) {
addRow(s);
}
}FlexSheetで計算処理を実装する場合、セルの値に他セルを参照するような数式を設定できます。また数式を使用すれば、参照先のセルの値が(ユーザの入力やUndo/Redoなどの操作で)変更されてセルの値が変わっても再計算する必要はありません。
export const setFormula = (
headerStartRow: number,
headerStartCol: number,
flexSheet: FlexSheet,
) => {
const actualTimeIdx =
Object.keys(BASE_HEADERS).indexOf(BASE_HEADERS_TITLE.ActualTime) + headerStartCol
const yieldIdx = Object.keys(BASE_HEADERS).indexOf(BASE_HEADERS_TITLE.Yield) + headerStartCol
const scheduledTimeIdx =
Object.keys(BASE_HEADERS).indexOf(BASE_HEADERS_TITLE.ScheduledTime) + headerStartCol
const progressRateTimeIdx =
Object.keys(BASE_HEADERS).indexOf(BASE_HEADERS_TITLE.ProgressRate) + headerStartCol
const manHourDigestionRateIdx =
Object.keys(BASE_HEADERS).indexOf(BASE_HEADERS_TITLE.ManHourDigestionRate) + headerStartCol
// 設定のシートをがあれば設定値を参照する式を使用するようにする
const settingSheetIdx = flexSheet.sheets.findIndex((sheet) => sheet.name === SETIING_SHEET)
if (settingSheetIdx === -1) {
console.warn(`${SETIING_SHEET}シートが見つかりません`)
}
const workHours = settingSheetIdx !== -1 ? '設定!A2' : WORKING_HOURS
for (let row = 1 + headerStartRow; row < flexSheet.rows.length; row++) {
const formulaActualTime = `=ROUND(SUM(${getColumnLetter(
Object.values(BASE_HEADERS).length + headerStartCol + 1,
)}${row + 2}:XFD${row + 2})/${workHours}, ${2})` // 数式の行数は1から始まるので+1, FlexsheetのIndexは0から始まる
const formulaYield = `=${getColumnLetter(scheduledTimeIdx + 1)}${
row + 1
}*${getColumnLetter(progressRateTimeIdx + 1)}${row + 1}` // 数式の行数は1から始まるので+1, FlexsheetのIndexは0から始まる
const condition = `${getColumnLetter(actualTimeIdx + 1)}${
row + 1
}/${getColumnLetter(scheduledTimeIdx + 1)}${row + 1}`
const formulaManHourDigestionRate = `=IF(ISERROR(${condition}),"",${condition})` // 数式の行数は1から始まるので+1, FlexsheetのIndexは0から始まる
flexSheet.setCellData(row, actualTimeIdx, formulaActualTime)
flexSheet.setCellData(row, yieldIdx, formulaYield)
flexSheet.setCellData(row, manHourDigestionRateIdx, formulaManHourDigestionRate)
const readOnlyCols = [actualTimeIdx, yieldIdx, manHourDigestionRateIdx] // 計算式の列は編集不可セル
readOnlyCols.forEach((colIndex) => {
flexSheet.columns[colIndex].isReadOnly = true
})
}
}FlexSheetは標準でUndo/Redo機能が搭載されていますが、FlexGrid(MultiRow)には搭載されていないので、UndoStackを使用して機能を追加します。
export function createUndoStack(parentClassName, view) {
// parentClassNameと子要素の間にformElementを追加する
const parentElement = document.querySelector(`.${parentClassName}`);
// 新しい要素を作成
const formElement = document.createElement("form");
formElement.id = `${parentClassName}-undoable-form`;
// parentElementの子要素をnewElementに移動
while (parentElement.firstChild) {
formElement.appendChild(parentElement.firstChild);
}
// 新しい要素を追加
parentElement.appendChild(formElement);
let undoStack = new UndoStack(`#${formElement.id}`, {
undoneAction: (s, e) => {
getCellEditEnded(view, e.action);
},
redoneAction: (s, e) => {
getCellEditEnded(view, e.action);
},
});
}以下のように各プロジェクトで呼び出します。
・・・(中略)・・・
DataCommonService.addRow(grid);
DataCommonService.createWorkingHoursInputNumber("pj1-theInputNoSrc", grid);
DataCommonService.createUndoStack("project_flexgrid1", grid);
DataCommonService.setFilter(grid);
grid.autoSizeColumns();
return grid;
}それぞれのデモにおいて、各コンポーネントのsaveAsyncメソッド(FlexGrid/FlexSheet)を使用してExcelエクスポート機能を実装しています。
document.querySelector("#saveXlsx").addEventListener("click", () => {
if (!showGrid)
return;
let projectName = tabPanel.selectedTab.header.textContent;
FlexGridXlsxConverter.saveAsync(showGrid, {
includeColumnHeaders: true,
includeStyles: false,
formatItem: null,
}, `${projectName}_${dateFns.format(new Date(), "yyyy-MM-dd-HH-mm-ss")}.xlsx`);
}); save() {
const fileName = 'FlexSheet.xlsx';
// 非同期でExcelファイルにエクスポート
if (!this.flex) return;
applyMergesToModel(this.flex)
this.flex.saveAsync(fileName);
}その際、FlexSheetで作成したWBSにおいては、制限はあるものの、セルに設定した数式など、アプリ上で使用していた機能をそのまま維持してExcelエクスポートできます。
今回はWijmoの各種コントロールを使用した「WBS(プロジェクト工数管理表)」デモの機能の実装のポイントを解説しました。
今回は同様の画面をデータグリッド系のコンポーネントと、スプレッドシート系のコンポーネントそれぞれで作成していますが、この例では各種の計算に数式や関数を使用できる点や、Excelとの互換性の点で、スプレッドシートコントロールのFlexSheetを使用する方が優位な部分が多かったです。
また、今回のサンプルではExcelのように別のシートを追加して、一日の稼働時間やプルダウンメニューに表示する項目などを定義していました。別途設定値を入力したり保持したりする仕組みを作らなくてよいので、この点もFlexSheetならではの強みといえます。
製品サイトでは今回ご紹介したデモアプリケーションをブラウザ上で手軽にお試し可能で、加えてソースコード付きでダウンロードも可能なので、是非こちらもチェックしてみてください。
そのほか、製品サイトでは、Wijmoのトライアル版も公開しておりますので、こちらもご確認ください。
また、ご導入前の製品に関するご相談、ご導入後の各種サービスに関するご質問など、お気軽にお問合せください。
]]>今回の記事では、日報の登録に必要となるデータベースの作成、日報データの登録処理、帳票レイアウトファイルの取得処理などをエージェントに実装する方法についてご紹介します。
あわせて、本連載で利用しているエージェントフレームワーク「Mastra」が先日「Mastra 1.0」として正式リリースされました。本記事では、旧バージョンからの移行ポイントについてもご紹介します。
Mastra 1.0 がリリース。これまでのMastraは0.x系として提供され、エージェントやワークフローのPoCや検証用途を中心に多くのユーザーに利用されてきましたが、今回リリースされた1.0ではAPIの安定化に加え、既存のサーバーに組み込めるServer Adapters、ドメインごとに最適化できるComposite Storage、最新のAI SDK v6サポートなど、本番利用を意識した強化が行われています。

前回作成したプロジェクトはMastra0.x系をベースとしているため、本記事ではMastra1.0への移行を行ったうえで、各実装を行っていきます。
まず最初に以下のコマンドで、プロジェクトのすべてのMastraパッケージを最新にします。
npm install mastra@latest @mastra/core@latest @mastra/loggers@latest @mastra/memory@latest @mastra/libsql@latestMastra 1.0を利用するにはNode.js 22.13.0以降が必要です。あらかじめインストールされているNode.jsのバージョンを確認し、必要に応じてアップデートを行ってください。
続いて、移行支援ツールとして用意されている「codemod」というCLIツールを、プロジェクトの直下で実行します。以下のコマンドを実行してください。
npx @mastra/codemod@latest v1codemodを実行すると、次のようにFIXME(mastra):と書かれているコメント箇所を検索して、手動で修正するように指示されます。

検索すると、該当箇所が3箇所ありますので、コメントの指示に従い修正します。

codemodのコメントに従って修正しても、一部のケースではエラーが発生したり、期待通りに動作しない場合があります。
Mastra 1.0では設定構成や責務の整理が行われており、codemodでは対応できない変更点があります。
主なポイントは以下のとおりです。
Azure OpenAIを利用している場合、@ai-sdk/azureをエージェント側で直接使用するのではなく、AzureOpenAIGatewayを用いてindex.tsでGatewayとして定義します。
Observabilityの設定は引き続きindex.tsに記述しますが、v1では@mastra/observabilityを利用する形に変更されています。そのため、Observabilityを利用する場合は、次のコマンドでパッケージをインストールしてください。
npm install @mastra/observability@latestcodemodで修正できない箇所については、以下の公式移行ガイドに記載されている変更点を確認したうえで、必要な移行作業を行ってください。
今回修正が必要となる、コードは以下の通りとなります。強調表示した箇所が変更箇所です。
import { Mastra } from '@mastra/core/mastra';
import { AzureOpenAIGateway } from '@mastra/core/llm';
import { PinoLogger } from '@mastra/loggers';
import { LibSQLStore } from '@mastra/libsql';
import { Observability, DefaultExporter, CloudExporter, SensitiveDataFilter } from '@mastra/observability';
import { weatherWorkflow } from './workflows/weather-workflow';
import { weatherAgent } from './agents/weather-agent';
export const mastra = new Mastra({
gateways: {
azureOpenAI: new AzureOpenAIGateway({
resourceName: process.env.AZURE_OPENAI_RESOURCE_NAME!, //環境変数の追加が必要です。
apiVersion: process.env.AZURE_OPENAI_API_VERSION!,
apiKey: process.env.AZURE_OPENAI_KEY!,
deployments: ["gpt-4.1"],
}),
},
workflows: { weatherWorkflow },
agents: { weatherAgent },
/* FIXME(mastra): Add a unique `id` parameter. See: https://mastra.ai/guides/migrations/upgrade-to-v1/mastra#required-id-parameter-for-all-mastra-primitives */
storage: new LibSQLStore({
// stores observability, scores, ... into memory storage, if it needs to persist, change to file:../mastra.db
id: 'mastra-storage',
url: ":memory:",
}),
logger: new PinoLogger({
name: 'Mastra',
level: 'info',
}),
observability: new Observability({
configs: {
default: {
serviceName: 'mastra',
exporters: [
new DefaultExporter(), // Persists traces to storage for Mastra Studio
new CloudExporter(), // Sends traces to Mastra Cloud (if MASTRA_CLOUD_ACCESS_TOKEN is set)
],
spanOutputProcessors: [
new SensitiveDataFilter(), // Redacts sensitive data like passwords, tokens, keys
],
},
},
}),
// telemetry: {
// // Telemetry is deprecated and will be removed in the Nov 4th release
// enabled: false,
// },
// observability: {
// // Enables DefaultExporter and CloudExporter for AI tracing
// default: { enabled: true },
// },
});
import { Agent } from '@mastra/core/agent';
import { Memory } from '@mastra/memory';
import { LibSQLStore } from '@mastra/libsql';
import { weatherTool } from '../tools/weather-tool';
//import { createAzure } from '@ai-sdk/azure';
// const azure = createAzure({
// useDeploymentBasedUrls: true,
// baseURL: process.env.AZURE_OPENAI_ENDPOINT!,
// apiVersion: process.env.AZURE_OPENAI_API_VERSION!,
// apiKey: process.env.AZURE_OPENAI_KEY!,
// });
/* FIXME(mastra): Add a unique `id` parameter. See: https://mastra.ai/guides/migrations/upgrade-to-v1/mastra#required-id-parameter-for-all-mastra-primitives */ /*日本語訳:一意の id パラメータを追加してください。詳細は次を参照してください*/
export const weatherAgent = new Agent({
id: 'weather-agent', //追加
name: 'Weather Agent',
instructions: `
You are a helpful weather assistant that provides accurate weather information and can help planning activities based on the weather.
Your primary function is to help users get weather details for specific locations. When responding:
- Always ask for a location if none is provided
- If the location name isn't in English, please translate it
- If giving a location with multiple parts (e.g. "New York, NY"), use the most relevant part (e.g. "New York")
- Include relevant details like humidity, wind conditions, and precipitation
- Keep responses concise but informative
- If the user asks for activities and provides the weather forecast, suggest activities based on the weather forecast.
- If the user asks for activities, respond in the format they request.
Use the weatherTool to fetch current weather data.
`,
//model: azure('gpt-4.1'), //← 変更 model: 'openai/gpt-4o-mini',
model: "azure-openai/gpt-4.1",
tools: { weatherTool },
memory: new Memory({
/* FIXME(mastra): Add a unique `id` parameter. See: https://mastra.ai/guides/migrations/upgrade-to-v1/mastra#required-id-parameter-for-all-mastra-primitives *//*日本語訳:一意の id パラメータを追加してください。詳細は次を参照してください*/
storage: new LibSQLStore({
id: 'weather-agent-memory', //追加
url: 'file:../mastra.db', // path is relative to the .mastra/output directory
}),
}),
});
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';
interface GeocodingResponse {
results: {
latitude: number;
longitude: number;
name: string;
}[];
}
interface WeatherResponse {
current: {
time: string;
temperature_2m: number;
apparent_temperature: number;
relative_humidity_2m: number;
wind_speed_10m: number;
wind_gusts_10m: number;
weather_code: number;
};
}
export const weatherTool = createTool({
id: 'get-weather',
description: 'Get current weather for a location',
inputSchema: z.object({
location: z.string().describe('City name'),
}),
outputSchema: z.object({
temperature: z.number(),
feelsLike: z.number(),
humidity: z.number(),
windSpeed: z.number(),
windGust: z.number(),
conditions: z.string(),
location: z.string(),
}),
execute: async ({ location }) => {
return await getWeather(location);
},
});
const getWeather = async (location: string) => {
const geocodingUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(location)}&count=1`;
const geocodingResponse = await fetch(geocodingUrl);
const geocodingData = (await geocodingResponse.json()) as GeocodingResponse;
if (!geocodingData.results?.[0]) {
throw new Error(`Location '${location}' not found`);
}
const { latitude, longitude, name } = geocodingData.results[0];
const weatherUrl = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,wind_gusts_10m,weather_code`;
const response = await fetch(weatherUrl);
const data = (await response.json()) as WeatherResponse;
return {
temperature: data.current.temperature_2m,
feelsLike: data.current.apparent_temperature,
humidity: data.current.relative_humidity_2m,
windSpeed: data.current.wind_speed_10m,
windGust: data.current.wind_gusts_10m,
conditions: getWeatherCondition(data.current.weather_code),
location: name,
};
};
function getWeatherCondition(code: number): string {
const conditions: Record<number, string> = {
0: 'Clear sky',
1: 'Mainly clear',
2: 'Partly cloudy',
3: 'Overcast',
45: 'Foggy',
48: 'Depositing rime fog',
51: 'Light drizzle',
53: 'Moderate drizzle',
55: 'Dense drizzle',
56: 'Light freezing drizzle',
57: 'Dense freezing drizzle',
61: 'Slight rain',
63: 'Moderate rain',
65: 'Heavy rain',
66: 'Light freezing rain',
67: 'Heavy freezing rain',
71: 'Slight snow fall',
73: 'Moderate snow fall',
75: 'Heavy snow fall',
77: 'Snow grains',
80: 'Slight rain showers',
81: 'Moderate rain showers',
82: 'Violent rain showers',
85: 'Slight snow showers',
86: 'Heavy snow showers',
95: 'Thunderstorm',
96: 'Thunderstorm with slight hail',
99: 'Thunderstorm with heavy hail',
};
return conditions[code] || 'Unknown';
}
上記のコードの修正に加え、環境変数に以下のようにAzureのリソース名を追加しています。
AZURE_OPENAI_ENDPOINT=https://your-end-point/openai
AZURE_OPENAI_API_VERSION=2025-01-01-preview
AZURE_OPENAI_KEY=your-api-key
AZURE_OPENAI_RESOURCE_NAME=your-resource-nameリソース名は、次のようにAzureポータル上のリソースマネージャ上より取得できます。こちらから取得して、環境変数に追加してください。

最後に、移行が正しく行われたか確認するため、前回と同様に以下のコマンドでMastraを起動し、Mastra Studioで動作を確認します。
mastra dev --dir ./mastra次のように、地域の天気が取得できれば、移行は完了です。

Mastra 1.0への移行が完了しましたので、ここからは、日報AIエージェントを作成にむけた実装を行っていきます。
まずはじめに、日報データを格納するためのデータベースを構築します。前回の記事でご紹介した通り、今回はデータベースを「SQLite」で構築します。また、データベース操作とマイグレーション作業には「Drizzle ORM/Kit」も利用するため、これらのパッケージをインストールします。
以下のコマンドで、「SQLite」、「Drizzle ORM/Kit」をインストールしてください。
npm install better-sqlite3 drizzle-orm drizzle-kit
npm install -D @types/better-sqlite3続いて、データベースのファイルや、スキーマ定義などを格納するためのフォルダをプロジェクトフォルダの直下に作成します。
今回は、「database」というフォルダ名としています。

続いて、プロジェクト直下に「drizzle.config.ts」ファイルを作成し、Drizzle ORMの設定を行います。設定には、データベースファイル(workreports.db)、スキーマ定義ファイル(schema.ts)、およびマイグレーションファイルの出力先を指定します。設定内容は以下のとおりです。
import type { Config } from 'drizzle-kit';
export default {
schema: './database/schema.ts',
out: './database/migrations',
dialect: 'sqlite',
dbCredentials: {
url: './database/workreports.db',
},
} satisfies Config;続いて、日報データを格納するためのテーブルをスキーマ定義として、既に作成済みの「database」フォルダ配下に「schema.ts」ファイルを作成します。
今回はユーザー情報と、日報データを格納する以下の2つのテーブルを定義します。
| 列名 | データ型 | 説明 |
|---|---|---|
| id | integer | ユーザーID(主キー、自動採番) |
| name | text | ユーザー名 |
| text | メールアドレス(ユニーク) | |
| createdAt | text | 作成日時 |
| 列名 | データ型 | 説明 |
|---|---|---|
| id | integer | 日報ID(主キー、自動採番) |
| userId | integer | ユーザーID(外部キー、usersテーブルを参照) |
| reportDate | text | 日報の日付 |
| workContent | text | 作業内容 |
| issues | text | 問題・課題 |
| nextActions | text | 次のアクション |
| weather | text | 天気 |
| temperature | integer | 気温 |
| weatherSource | text | 天気情報の取得元 |
| rawInput | text | 元のテキスト入力 |
| createdAt | text | 作成日時 |
| updatedAt | text | 更新日時 |
スキーマ定義は以下の通りです
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
email: text('email').notNull().unique(),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const dailyReports = sqliteTable('daily_reports', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
reportDate: text('report_date').notNull(),
workContent: text('work_content').notNull(),
issues: text('issues'),
nextActions: text('next_actions'),
weather: text('weather'),
temperature: integer('temperature'),
weatherSource: text('weather_source'),
rawInput: text('raw_input'),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});ここまでで、データベースとテーブルを作成するための準備が整いました。
次に、Drizzle Kitのデータベースマイグレーション機能を利用して、データベースとテーブルを実際に作成していきます。
以下のコマンドを実行して、スキーマ定義に基づいたマイグレーションファイルを生成します。
npx drizzle-kit generate コマンドを実行すると次のように「database/migrations」配下にマイグレーションファイルが生成されます。
生成されたマイグレーションファイルには、schema.tsで定義したTypeScriptのテーブル定義がSQLに変換されて格納されています。このファイルを実行することで、データベースとテーブルが作成されます。

続いて、以下のコマンドでマイグレーションを実行して、実際にデータベースとテーブルを作成していきます。
npx drizzle-kit migrateこのコマンドを実行すると、SQLiteデータベースファイル(workreports.db)が自動作成され、マイグレーションファイルのSQLが実行されて、usersテーブルとdailyReportsテーブルが作成されます。

続いて、データベースファイル内にテーブルが正しく作成されたかを、Drizzle Studioで確認します。
以下のコマンドを実行して、Drizzle Studioを起動します。
npx drizzle-kit studioこのコマンドを実行するとローカルサーバーが起動され、「https://local.drizzle.studio」にアクセスして、データベースの内容を確認できます。

データベースを確認すると、スキーマ定義に基づいて「users」と「dailyReports」の2つのテーブルが正常に作成されていることが確認できました。

日報データを格納するためのデータベースの作成と、テーブルの準備が整いましたので、AIエージェントを作成していきます。
AIエージェントの実装を行うにあたっては、最初にエージェントが利用する「Tools」の実装から始めます。
まず初めに、作成した2つのテーブル「users」と「dailyReports」に対して、CRUD(Create、Read、Update、Delete)操作を行うためのツール実装を行います。
CRUDツールがデータベースを操作するには、「Drizzle ORM」と「better-sqlite3」を使用して、「Drizzle ORM」インスタンスを作成・設定し、他のモジュールから操作できるようにする必要があります。まず初めに、この処理をdatabase配下にindex.tsとして実装していきます。
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import path from 'path';
import fs from 'fs';
import * as schema from './schema';
/**
* プロジェクトルートディレクトリを取得
*
* 問題:同じ database モジュールが開発時と本番時で異なる場所から実行される
* - 開発時: ソースコード(TypeScript)から直接実行
* - 本番時: コンパイル済みコード(.mastra/output/)から実行
*
* 前提条件:
* - ローカル開発時(npm run dev):
* __filename は .mastra を含まないパス
* → process.cwd() でプロジェクトルートを取得
*
* - Mastraビルド・本番時(npx mastra dev または npm run build → start):
* __filename は .mastra/output 配下のパス
* 例)project-root/.mastra/output/xxx/database/index.js
* → 3階層上に遡ってプロジェクトルートに到達
*
* 処理ロジック:
* - .mastra を含むパスの場合: 3階層上に遡る (../../../../) でプロジェクトルートに到達
* - そうでない場合: process.cwd() をプロジェクトルートとする
*/
const getProjectRoot = (): string => {
// Mastraでビルドされたファイルの場合、__filename から推定
if (typeof __filename !== 'undefined' && __filename.includes('.mastra')) {
return path.resolve(__filename, '../../../..');
}
// ローカル開発時はプロセスの作業ディレクトリをプロジェクトルートとする
return process.cwd();
};
const projectRoot = getProjectRoot();
const dataDir = path.join(projectRoot, 'database');
// フォルダが存在しなければ作成
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const dbPath = path.join(dataDir, 'workreports.db');
// SQLite データベース接続
const sqlite = new Database(dbPath);
// テーブル確認
try {
sqlite.prepare("SELECT name FROM sqlite_master WHERE type='table'").all();
} catch {
// ignore
}
// Drizzle ORM インスタンス
export const db = drizzle(sqlite, { schema });
// データベース接続テスト
export function testConnection() {
try {
sqlite.prepare('SELECT 1').get();
return true;
} catch {
return false;
}
}
続いて、CRUDツールの実装に入ります。CRUDツールはmastraフォルダ内のtoolsフォルダに実装します。今回はファイル名を「crud-tool.ts」とします。
この「crud-tool.ts」では、先ほど実装した「database/index.ts」を利用してデータベース接続を行うほか、「database/schema.ts」を参照してテーブルの定義情報も取得しています。
これらをもとに「Drizzle ORM」を利用して、次のようにCRUD処理を実現します。
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';
import { db } from '@/database';
import * as schema from '@/database/schema';
import { eq } from 'drizzle-orm';
/**
* ========== 概要 ==========
*
* このファイルは Mastra エージェント用の汎用 CRUD ツールを提供します。
* AI エージェントがデータベースに対して CREATE / READ / UPDATE / DELETE 操作
* を実行できるようにします。
*/
/**
* database/schema.ts に定義されているテーブル名の型
*
* 使用例:
* - 'daily_reports': 日報テーブル
* - 'users': ユーザーテーブル
* - など schema.ts で定義されたテーブルすべて
*/
type TableName = keyof typeof schema;
/**
* ========== 汎用 CRUD ツール ==========
*
* 設計思想:
* - 入力値の妥当性チェックは Drizzle ORM のスキーマ制約に委譲
* (NOT NULL、UNIQUE、FOREIGN KEY などの制約)
* - ツール側では try/catch で例外をハンドル
* - エラーメッセージはユーザーフレンドリーに変換
*
* 対応操作:
* 1. CREATE: 新規記録を作成
* 2. READ: 記録を取得(WHERE条件対応)
* 3. UPDATE: 記録を更新(id指定必須)
* 4. DELETE: 記録を削除(id指定必須)
*/
export const crudTool = createTool({
id: 'crud-tool',
description: 'Generic CRUD tool based on Drizzle schema constraints',
/**
* ========== 入力スキーマ ==========
*
* @param operation - 実行する操作
* - 'create': レコード新規作成、data パラメータを使用
* - 'read': レコード検索、where パラメータで条件指定可能
* - 'update': レコード更新、where.id で対象を指定、data で新値を指定
* - 'delete': レコード削除、where.id で対象を指定
*
* @param table - テーブル名(schema.ts で定義されている必要があります)
*
* @param data - INSERT/UPDATE する値
* 例){ name: 'John', email: '[email protected]' }
*
* @param where - WHERE 条件(通常は id を指定)
* 例){ id: 1 }, { id: 5 }
*/
inputSchema: z.object({
operation: z.enum(['create', 'read', 'update', 'delete']),
table: z.string(),
data: z.record(z.string(), z.unknown()).optional(),
where: z.record(z.string(), z.unknown()).optional(),
}),
/**
* ========== 出力スキーマ ==========
*
* @param success - 操作が成功したかどうか
* @param data - 操作結果のデータ(CREATE/READ/UPDATE で返却)
* @param message - エラーメッセージ(成功時は省略)
*/
outputSchema: z.object({
success: z.boolean(),
data: z.unknown().optional(),
message: z.string().optional(),
}),
/**
* ========== メイン処理 ==========
*/
execute: async ({ operation, table, data, where }) => {
// ========== テーブルの存在確認 ==========
const targetTable = schema[table as TableName];
if (!targetTable) {
return { success: false, message: `Unknown table: ${table}` };
}
// ========== ID カラムの検出 ==========
// UPDATE/DELETE 操作で WHERE 條件として id を使用するため
// 対象テーブルの id カラムを動的に取得
type TargetTableType = typeof targetTable;
const idColumn = ('id' in targetTable ? (targetTable as { id: unknown }).id : null) as (TargetTableType extends { id: infer T } ? T : null);
try {
switch (operation) {
/**
* ========== CREATE 操作 ==========
* 新規レコードをテーブルに挿入します
*
* 処理フロー:
* 1. INSERT 文を生成
* 2. data パラメータの値を使用
* 3. 新規作成されたレコードを RETURNING で返す
* 4. スキーマの制約違反がある場合は error catch へ
*
* エラー例:
* - NOT NULL 制約違反
* - UNIQUE 制約違反(重複した値)
* - 型の不一致
*/
case 'create': {
const result = await db
.insert(targetTable)
.values(data ?? {})
.returning();
return { success: true, data: result };
}
/**
* ========== READ 操作 ==========
* テーブルからレコードを検索します
*
* 処理フロー:
* 1. where.id が指定されている場合:
* - そのID のレコードのみを取得
* 2. where.id が指定されていない場合:
* - テーブル全体を取得
*
* 使用例:
* - { table: 'daily_reports', operation: 'read', where: { id: 1 } }
* → ID=1 の日報を1件取得
*
* - { table: 'daily_reports', operation: 'read' }
* → 全日報を取得
*/
case 'read': {
let result;
if (idColumn && where?.id !== undefined) {
result = await db.select().from(targetTable).where(eq(idColumn, Number(where.id)));
} else {
result = await db.select().from(targetTable);
}
return { success: true, data: result };
}
/**
* ========== UPDATE 操作 ==========
* 既存レコードを更新します
*
* 必須条件:
* - where.id が必ず指定される必要があります
* (複数レコードの同時更新を防ぐため)
*
* 処理フロー:
* 1. where.id から対象レコードを特定
* 2. data パラメータの値で上書き
* 3. 更新後のレコードを RETURNING で返す
* 4. id の不在または型の不一致は error へ
*
* エラー例:
* - id が指定されていない
* - UNIQUE 制約違反
* - データ型の不一致
*/
case 'update': {
if (!idColumn || where?.id === undefined) {
throw new Error('id is required for update');
}
const result = await db
.update(targetTable)
.set(data ?? {})
.where(eq(idColumn, Number(where.id)))
.returning();
return { success: true, data: result };
}
/**
* ========== DELETE 操作 ==========
* 指定したレコードを削除します
*
* 必須条件:
* - where.id が必ず指定される必要があります
* (複数レコードの誤削除を防ぐため)
*
* 処理フロー:
* 1. where.id から対象レコードを特定
* 2. そのレコードを削除
* 3. 削除対象が見つからない場合も成功として返す
* 4. id の不在は error へ
*
* 注意:
* - 削除後のデータ復旧はできません
* - FOREIGN KEY 制約の対象になっている場合は削除失敗
*/
case 'delete': {
if (!idColumn || where?.id === undefined) {
throw new Error('id is required for delete');
}
await db
.delete(targetTable)
.where(eq(idColumn, Number(where.id)));
return { success: true };
}
}
} catch (error) {
return {
success: false,
message: formatDbError(error),
};
}
},
});
/**
* ========== エラーメッセージの変換 ==========
*
* Drizzle ORM / SQLite が返すエラーメッセージを解釈し、
* ユーザーフレンドリーな日本語メッセージに変換します
*
* 対応するエラータイプ:
*
* 1. NOT NULL 制約違反
* - 必須項目が空白で送信された
* - 例:{ name: null }
*
* 2. UNIQUE 制約違反
* - 重複する値をINSERT/UPDATEしようとした
* - 例:メールアドレスが既に登録されている
*
* 3. FOREIGN KEY 制約違反
* - 関連する親レコードが存在しない
* - 例:存在しないユーザーID を参照している
*
* 4. CHECK 制約違反
* - カスタム検証ルールに違反している
* - 例:年齢が負の数
*
* @param error - Drizzle ORM から投げられたエラーオブジェクト
* @returns ユーザーフレンドリーなエラーメッセージ
*/
function formatDbError(error: unknown): string {
if (!(error instanceof Error)) {
return 'Unknown database error';
}
const msg = error.message;
if (msg.includes('NOT NULL')) {
return '必須項目が不足しています';
}
if (msg.includes('UNIQUE')) {
return '一意制約に違反しています';
}
if (msg.includes('FOREIGN KEY')) {
return '関連データが存在しません';
}
if (msg.includes('CHECK')) {
return '入力値が制約条件を満たしていません';
}
return msg;
}
続いて、ActiveReportsJSの帳票レイアウトファイルを提供するためのツールを実装していきます。
帳票レイアウトの保存先として、プロジェクトの直下にreportsフォルダを作成します。その中にテスト用に次のような簡単なレイアウト※を追加しておきます。
※ Visual Studio Code拡張機能版のActiveReportsJSレポートデザイナを利用して表示しています。

続いて、CRUDツールと同様に、帳票レイアウトを取得し提供するレポートツールをmastraフォルダ内のtoolsフォルダに実装します。ファイル名を「reports-tool.ts」とします。
プロジェクトのreportsフォルダからそのファイルを探して読み込み、エージェントに返すといった非常にシンプルなツールですが、アプリの起動方法によってファイルパスが変わるため、複数の場所から自動的にファイルを検索する工夫を施しています。
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';
import { promises as fs } from 'fs';
import { join, resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
// レポートデータの型定義(任意のキーと値のペアを持つオブジェクト)
type ReportData = Record<string, unknown>;
// 現在のファイルのディレクトリを取得(ES モジュール環境用)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// レポート読み込み結果の型定義
type ReportResult = {
report: ReportData; // パースされたレポート定義
fileName: string; // ロードされた帳票レイアウトファイルの名前
parameters?: Record<string, unknown>; // レポートに渡されるパラメータ
};
/**
* ========================================
* レポート生成ツール
* ========================================
*
* 機能:RDLX-JSON形式のレポート定義ファイルを読み込み、
* ActiveReports.jsビューアで表示可能なレポート定義を返す
*
* 用途:複数のレポートテンプレートから、指定されたレポートを
* 動的にロードして エージェントに提供する
*/
export const reportTool = createTool({
id: 'reports-tool',
description: 'RDLX-JSON形式のレポート定義ファイルをreportsフォルダからロードして、ActiveReports.jsビューアで表示可能なレポート定義を返します。',
// ========== 入力スキーマ(ユーザーやエージェントからの入力) ==========
inputSchema: z.object({
reportName: z.string().describe('帳票レイアウトファイルの名前(拡張子なし)。例: "weather-report"、"sales-report"、"user-list"'),
parameters: z.record(z.string(), z.any()).optional().describe('レポートに渡すパラメータ(ユーザーデータなど)'),
}),
// ========== 出力スキーマ(このツールの戻り値) ==========
outputSchema: z.object({
// レポートはJSON文字列で返される(テキスト分割を避けるため)
// 内部用途に備えて、パースされたオブジェクトも `reportObject` で提供される
report: z.string().describe('レポート定義のJSON文字列(一塊の形式)'),
reportObject: z.record(z.string(), z.unknown()).optional().describe('パースされたレポートオブジェクト(内部用)'),
fileName: z.string().describe('ロードされた帳票レイアウトファイル名'),
parameters: z.record(z.string(), z.unknown()).optional().describe('ビューアに渡すレポートパラメータ'),
}),
// ========== メイン処理 ==========
execute: async (args: unknown) => {
/**
* Mastra や createTool からツール入力を受け取る際、複数の形式が考えられるため、
* それぞれのケースに対応する処理を行う:
* 1. 直接オブジェクトで渡される場合
* 2. { context: {...} } の形で渡される場合
* 3. JSON文字列で渡される場合
* 4. { argsJson: '...' } の形で渡される場合
*/
let payload: unknown = args;
// ========== パターン1: JSON文字列として渡された場合の処理 ==========
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch {
// パースに失敗しても続行(下流のバリデーションでエラーが出る)
}
}
// ========== パターン2: { argsJson: '...' } の形で渡された場合の処理 ==========
// 一部のランタイムでは、引数がこのフォーマットで包装されることがある
const argsObj = args as Record<string, unknown> | null;
if (!payload && argsObj?.argsJson) {
try {
payload = JSON.parse(argsObj.argsJson as string);
} catch {
// パースに失敗した場合はpayloadのままで続行
}
}
// ========== パターン3: { context: {...} } の形で渡された場合の処理 ==========
// この場合は、context内に実際の引数が含まれているため抽出する
const payloadObj = payload as Record<string, unknown> | null;
if (payloadObj?.context && typeof payloadObj.context === 'object') {
payload = { ...payloadObj.context };
}
const finalPayload = payload as Record<string, unknown> | null;
const reportName = finalPayload?.reportName as string | undefined;
const parameters = finalPayload?.parameters as Record<string, unknown> | undefined;
// ========== 必須パラメータの検証 ==========
// reportName は帳票レイアウトファイルを特定するために必須
if (!reportName) {
throw new Error('reportName is required');
}
// ========== レポート定義のロード ==========
// 指定された帳票レイアウトファイルを各候補ディレクトリから検索して読み込む
const res = await loadReportDefinition(reportName, parameters);
// ========== レスポンスの作成 ==========
// レポートはJSON文字列で返す(クライアント側でテキスト分割を避けるため)
// パースされたオブジェクトも別途 `reportObject` として返す
const singleJson = JSON.stringify(res.report);
return {
report: singleJson, // JSON文字列形式(クライアント用)
reportObject: res.report, // パースされたオブジェクト(内部用)
fileName: res.fileName, // ロードされたファイル名
parameters: res.parameters, // パラメータ
};
},
});
/**
* ========================================
* レポート定義ロード関数
* ========================================
*
* 機能:指定されたレポート名から、RDLX-JSON形式のレポート定義ファイルを
* プロジェクト直下のreportsフォルダから検索してロードする
*
* パラメータ:
* @param reportName - 帳票レイアウトファイルの名前(拡張子なし)
* @param parameters - レポートに渡す動的パラメータ(ユーザーデータなど)
*
* 戻り値:
* @returns レポート定義、ファイル名、パラメータを含むオブジェクト
*/
async function loadReportDefinition(reportName: string, parameters?: Record<string, unknown>): Promise<ReportResult> {
// ========== 帳票レイアウトファイルのパス ==========
// プロジェクト直下の reports フォルダを参照
// 複数のレポートフォルダの候補を試す:
// 1. process.cwd() をベースにしたパス(通常はプロジェクトルート)
// 2. __dirname から相対的に上がったパス
const candidates = [
join(process.cwd(), 'reports'),
resolve(__dirname, '../../../reports'), // コンパイル出力からプロジェクトルートへのパス
];
let reportPath: string | null = null;
// ========== 複数の候補パスから検索 ==========
for (const candidate of candidates) {
const path = join(candidate, `${reportName}.rdlx-json`);
try {
await fs.access(path);
reportPath = path;
break;
} catch {
// このパスは見つからない、次を試す
}
}
if (!reportPath) {
throw new Error(
`レポート "${reportName}" をロードできません。次の場所を検索しました: ${candidates
.map((c) => join(c, `${reportName}.rdlx-json`))
.join(', ')}`
);
}
// ========== ファイルを読み込んでパース ==========
try {
const fileContent = await fs.readFile(reportPath, 'utf8');
const report = JSON.parse(fileContent) as ReportData;
const result: ReportResult = {
report,
fileName: `${reportName}.rdlx-json`,
};
// ========== パラメータを結果に含める ==========
// パラメータが存在する場合のみ結果に含める
if (parameters && Object.keys(parameters).length > 0) {
result.parameters = parameters;
}
return result;
} catch (error) {
// ========== ファイル読み込み/パースエラーの処理 ==========
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`レポート "${reportName}" をロードできません(パス: ${reportPath}): ${errorMessage}`);
}
}
それでは、利用するツールの実装が完了したので、最後にエージェントを実装していきます。
まずは、サンプルコードである「weather-agent.ts」と同様に、mastraフォルダ配下のagentsフォルダに日報用エージェント「workreport-agent.ts」として追加します。
エージェントの作り方はweather-agent.tsを参考にしますが、instructionsプロパティでLLMへのプロンプトを指定する部分に関しては、複雑なエージェント構成やツールの利用方法などを記載するとプロンプトの分量が多くなり、可読性が低下するため、mastraフォルダ配下に新たにinstructionsフォルダを作成し、「workreport-instructions.ts」としてプロンプトを分離し、作成したtsファイルをインポートして使用します。

workreport-agent.tsの実装は、作成したツールを利用する形で以下のようになります。今回作成したツールの他、サンプルで用意されている「weather-tool」は日報の登録時の天気情報を取得するため引き続き利用します。
import { Agent } from '@mastra/core/agent'; // Mastraフレームワークのコアコンポーネントをインポート
import { Memory } from '@mastra/memory'; // メモリ機能のインポート(会話履歴を永続化)
import { LibSQLStore } from '@mastra/libsql'; // LibSQL(SQLiteラッパー)のインポート(メモリの永続ストレージ)
import { weatherTool } from '../tools/weather-tool'; // 天気情報取得ツールのインポート
import { crudTool } from '../tools/crud-tool'; // 日報のCRUD操作ツールのインポート
import { reportTool } from '../tools/reports-tool'; // レポート生成・管理ツールのインポート
import { workreportInstructions } from '../instructions/workreport-instructions'; // 日報エージェントの指示文をインポート
// 日報を管理・支援するAIエージェント
// CRUDツールとレポートツールで仕事情報を管理し、メモリ機能で会話履歴を保持
export const workreportAgent = new Agent({
// エージェントの名前
id: 'workreport-agent',
name: 'Workreport Agent',
// エージェントの行動指針(日報の専門家ロール)
instructions: workreportInstructions,
// 使用するLLMモデル(Azure OpenAI GPT-4.1)
model: "azure-openai/gpt-4.1",
// エージェントが使用できるツール(CRUD操作とレポート管理)
tools: { weatherTool, crudTool, reportTool },
// 会話履歴を永続化するメモリシステム
memory: new Memory({
// SQLiteデータベースでメモリを保存
storage: new LibSQLStore({
id: 'workreport-agent-storage',
url: 'file:../mastra.db',
}),
}),
});
プロンプト本体となる、「workreport-instructions.ts」は以下の通りです。
export const workreportInstructions: string = `
## ロール
あなたは日報データベースを操作するエージェントであり、帳票表示機能も担当します。
## 目的
ユーザーからの入力を受け取り、必要に応じてユーザー登録・日報の作成・更新・削除を行います。
## 一般ルール
- 出力は明確かつ簡潔に。ユーザーに確認が必要な場合は、必要な情報を一つずつ質問してください。
- 最終的なデータ操作(create/read/update/delete)は必ず "crud-tool" を用いて行ってください。
- 外部データ(天気など)は "weather-tool" を使って取得し、その出力を "dailyReports" の該当フィールドに格納してください。
- 帳票表示が要求された場合は、"reportTool" を使用して適切なレポートを生成してください。
- 天気の場所が指定されていない場合は、必ず場所を尋ねてください
- 天気の場所名が英語でない場合は、英語に翻訳してください
- 天気の複数の要素を含む場所(例:「東京都新宿区」)の場合は、最も関連性の高い部分(例:「新宿区」)を使用してください
- 天気の湿度、風の状況、降水量などの関連情報も含めてください
- 天気の回答は簡潔かつ情報豊富にしてください
- ユーザーが天気予報を提供し活動を尋ねた場合は、その天気に基づいた活動を提案してください
- ユーザーが活動を尋ねた場合は、リクエストされたフォーマットで回答してください
## データベース構造
- users テーブル: ユーザー情報(id, name, email, created_at)
- dailyReports テーブル: 日報情報(id, user_id, report_date, work_content, issues, next_actions, weather, temperature, weather_source, raw_input, created_at, updated_at)
## 日報のフィールド
- "userId": users テーブルの id
- "reportDate": 報告日(YYYY-MM-DD 推奨)
- "workContent": 作業内容
- "issues": 課題
- "nextActions": 次のアクション
- "weather", "temperature", "weatherSource": 天気情報("weather-tool" を使用)
- "rawInput": 元の入力内容
## API 形式("crud-tool" に渡す JSON 例)
- ユーザー作成例:
{
"operation": "create",
"table": "users",
"data": { "name": "山田太郎", "email": "[email protected]" }
}
- 日報更新例:
{
"operation": "update",
"table": "dailyReports",
"data": { "workContent": "...", "issues": "...", "nextActions": "...", "weather": "晴れ", "temperature": 20, "weatherSource": "weather-tool", "rawInput": "..." },
"where": { "id": 123 }
}
## 帳票・レポート表示時のガイドライン
- ユーザーが「表示して」などと言った場合は、まずユーザーに確認してください(帳票形式で表示するか、テキスト形式でデータベース情報を表示するか)。
- ユーザーが帳票形式を選んだ場合は、reportTool を呼び出してください。
- 帳票表示のリクエストに対しては、説明や補足を一切せず、reportTool から返されたJSONデータのみをそのまま返してください(テキスト形式の説明や案内は禁止)。
- 返却するJSONデータは、JSON.stringifyなどで文字列化せず、純粋なJSONオブジェクトとして返してください。JSONをテキストやコードブロックでラップすることは禁止です。
- フロントエンド(ActiveReports.jsビューワー)がそのままパース・表示できる形式で返してください。
- reportTool を呼び出した場合、返されたJSONデータ以外は一切返さないこと。追加の説明・案内・テキストは不要・禁止。
- 返却形式はJSONのみ。フロントエンドのレポートビューワでそのまま表示できるようにすること。
## weather-tool の扱い
- 地名は "location" パラメータで渡してください。
- 取得した天気情報は "weather", "temperature", "weatherSource" に反映してください。
- データベースに天気情報を登録する際は、必ず日本語で登録してください。
## reportTool
- 用途: "reports" フォルダから RDLX-JSON 形式の帳票定義を読み込み、ActiveReports.js ビューワーで表示可能な JSON を返します。
- パラメータ:
- "reportName" (string, 必須): レポート名(拡張子なし。例: "test")
- 利用可能なレポート:
- "test": テストレポート
- 重要ルール:
1. "reportTool" を呼び出した場合、返却された JSON をそのままクライアントへ返してください。追加の説明文、注釈、要約などを付け加えてはなりません。
2. レスポンスは純粋なデータ(JSON)であることを保証してください。
- 利用例:
- ユーザーが「テストレポートを表示して」と言ったら、直ちに "reportTool('test')" を呼び出してください。
## 不足情報の取り扱い
- 必要な情報(ユーザー名、メール、報告日、地名、reportName など)が不足している場合は、具体的にどの値が必要かだけを短く尋ねてください。
`;
ここまでで、日報エージェントの実装が完了しました。実際にMastra Studioを利用してエージェント動作を確認してみます。
新たに作成した「Workreport Agent」を選択し、ユーザー情報を登録してみます。
正しく、ユーザーが登録できました。続いて、日報も登録してみます。
日報が、登録できました。最後に、レポートツールを起動して、作成済みのレポート情報を取得してみます。
作成したreports-toolから、帳票レイアウトのJSONデータがテキストとして返されることが確認できました。「Observability」の実行履歴からも、LLM、ツールを経て、JSONが返されることが確認できます。
今回は、「MastraとActiveReportsJSで実現する日報AIエージェント」の2回目として、日報の登録に必要となるデータベースの作成、日報データの登録処理、帳票レイアウトファイルの取得処理などをエージェントに実装する方法についてご紹介してきました。
また、正式にリリースされた「Mastra 1.0」への対応として、旧バージョンからの移行ポイントについてもご紹介いたしました。
3回目の記事では、Next.jsを利用してフロントエンドを実装し、チャットUI上にActiveReportsJSビューワを組み込んで、取得した帳票レイアウトファイルを帳票表示する方法についてご紹介しています。こちらの記事もぜひご覧ください。
製品サイトでは、今回ご紹介したActiveReportsJSの機能を手軽に体験できるデモアプリケーションやトライアル版も公開しておりますので、こちらもご確認ください。
また、ご導入前の製品に関するご相談、ご導入後の各種サービスに関するご質問など、お気軽にお問合せください。
]]>
昨年開催いたしましたウェビナー「Wijmo入門講座 – Webアプリの入力を快適に!Inputコントロール活用術」のアーカイブをオンデマンド形式のセミナーとして公開しました。場所や時間を問わず、お好きなタイミングでセミナーを受講することが可能です。
JavaScript開発ライブラリ「Wijmo(ウィジモ)」は、フロントエンド技術を活用した業務アプリケーション開発に適したUIコントロールセットです。40種類以上の豊富なコントロールを備え、業務アプリ開発における多様な要件に柔軟に対応できます。
本ウェビナーでは、業務アプリケーション開発に欠かせない「入力」機能にフォーカスして、「Wijmo(ウィジモ)」の機能をご紹介します。 各種入力コントロールの特長や、お客様からよく寄せられる質問に基づいた活用方法、他のコントロールとの連携術を、デモを交えて分かりやすく解説します。
また、より高度な入力要件に対応するためのJavaScript入力ライブラリ「InputManJS(インプットマンJS)」についてもご紹介。InputManJSとWijmoの違いや製品選定時に知っておきたいポイントなども詳しくお伝えします。
はじめてご覧になる方はもちろん、重要なポイントをもう一度復習したい方も、ぜひこの機会にご視聴ください。
]]>
JavaScript開発ライブラリ「Wijmo(ウィジモ)」の最新バージョン「2025J v2.1」を2026年2月4日(水)にリリースしました。「2025J v2.1」では以下のようなアップデートを実施しています。
FlexGrid、FlexSheetにおいて、取り消し線(打ち消し線)のスタイルを適用したセルをExcelエクスポートできるようになりました。

また、取り消し線のスタイルを適用したセルを含むExcelファイルをFlexSheetにインポートすることも可能です。

wijmo.xlsxモジュールにおいて、Workbook APIを使用して取り消し線のスタイルを適用したセルを含むExcelファイルの生成できるようになりました。

Next.jsの最新バージョン「16」に対応しました。
Wijmoのさまざまなコントロールを使用した実用サンプルを3種追加しました。ブラウザ上で動作を確認できるほか、ソースコードもダウンロードできるので、Wijmoを使用した業務アプリケーションの開発方法を学習するのに最適です。

その他、「2025J v2.1」では主に以下のような機能の改善と追加を行っています。
「2025J v2.1」の新機能についてはWebサイトでも詳しく紹介していますので、ぜひご確認ください。
製品の機能を手軽に体験できるデモアプリケーションやトライアル版も公開しておりますので、こちらもご確認ください。
また、ご導入前の製品に関するご相談、ご導入後の各種サービスに関するご質問など、お気軽にお問合せください。
]]>