AIチャットで完結。MastraとActiveReportsJSで実現する日報AIエージェント(3)

これまで「MastraとActiveReportsJSで実現する日報AIエージェント」の作成方法として、第1回目では「Next.js プロジェクトへの Mastra の導入と、サンプルコードを用いた基本的なエージェント構築」について紹介しました。

第2回目となる前回は「日報登録に必要となるデータベースの準備、登録処理、そして帳票レイアウトファイル取得の実装」を中心に解説してきました。

シリーズの最後となる今回の記事では、Next.jsを利用してフロントエンドを実装し、チャットUI上にActiveReportsJSビューワを組み込んで、取得した帳票レイアウトファイルを表示する方法を解説いたします。

日報AIエージェント

コンポーネントの作成

それでは、さっそくフロントエンド部分の実装を進めていきます。画面上にはチャットUIを配置し、あわせてチャットUI内に ActiveReportsJSビューワを表示するため、それぞれの機能をコンポーネントとして実装していきます。

ActiveReportsJSのインストール

まず最初に、ビューワを利用するために ActiveReportsJSパッケージをインストールします。以下のコマンドを実行して、インストールを行ってください。

npm install @mescius/activereportsjs@6.0.1 @mescius/activereportsjs-i18n@6.0.1

ビューワコンポーネントの実装

続いて、コンポーネントを実装するため、プロジェクト直下に「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コンポーネントの実装

続いて、チャット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,

MastraAPIと連携するためのカスタムHookの実装

続いて、チャットUIからMastraAPIと連携するためのカスタムHookを実装します。最初に「app」フォルダ配下に「hooks」フォルダを追加し、その中に「use-mastra.ts」を追加します。

カスタムHookの追加

追加するコードは以下の通りです。

'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:4111

レポート解析ライブラリの実装

Mastraエージェントのレスポンスをパースし、チャット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画面の実装

コンポーネントの実装と、カスタムフック、レポート解析ライブラリの実装が完了しましたので、これらを利用してエージェントUI画面の実装を行っていきます。

エージェントUI画面の追加

app」フォルダ配下に「worklog-agent」フォルダを作成し、Next.jsのページコンポーネント「page.tsx」を配置します。

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>
  );
}

layout.tsxの変更

さらに、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それぞれを実行します。

Mastraの実行
npx mastra dev --dir ./mastra
Next.jsの実行
npm run dev

動作確認では以下の内容を確認します。

  • CRUD処理確認:ユーザー追加後、テキストで一覧表示
  • 帳票レイアウトをビューワ上に表示

CRUD処理は、前回Mastra Studioで確認した時と同様に動作していることが確認できます。

帳票ビューワも正常に動作しています。チャットUI上に配置されたビューワに、指定した帳票レイアウト(前回記事で追加済みのテストレポート「test.rdlx-json」)が表示されます。

業務日報レポートの追加

基本的な機能の実装と動作確認が完了しましたので、最後に業務日報用の帳票レイアウトを追加し、データベースのデータを帳票に設定する処理を実装します。

帳票レイアウトの追加

今回は以下の業務日報用の帳票レイアウトを利用します。「reports」フォルダの中にファイルを追加してください。

業務日報用の帳票レイアウト

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

業務日報用の帳票レイアウト-データソース

Toolの修正

続いて、帳票レイアウトの取得を行う「reports-tool」に対して、「crud-tool」を用いてテーブルからデータを取得する処理を追加します。

既存のツール構成を変更せずに、CRUDツールを利用して帳票用データを取得し、レポートビューワに設定する構成も検討しましたが、この場合は一度LLMにデータを渡す必要があります。
その結果、トークン量の増加によるコスト増加やレスポンス低下が懸念されるため、帳票レイアウトの取得およびデータ取得を各ツール内で完結させ、処理効率を向上させる構成へ変更しました。

reports-toolの変更

強調表示した箇所が変更部分ですが、修正箇所が多いため、以下の内容を上書きしてください。

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の変更

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: 'john@example.com' }
   * 
   * @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;
}
~~ 以下省略 ~~

チャットUIコンポーネントの変更

'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エージェントを使ってみる

これで、すべての実装が完了しましたので、最後に実際にAIエージェントを使ってみます。

業種を問わず日報は、さまざまな形で利用されていますので、今回は以下のサンプルケースを登録してそれぞれ帳票出力を行ってみます。

工事日報

授業日誌

看護記録

さいごに

今回は、「MastraとActiveReportsJSで実現する日報AIエージェント」の最終回として、フロントエンド部分の実装などを解説したほか、実際に日報AIエージェントを利用した日報の登録、帳票表示など一連の動作確認まで行いました。

業務アプリケーション開発では、CRUD操作や帳票機能は必要不可欠であり、これはAIエージェントに業務機能を実装する上でも同様です。フロントエンドだけで実装可能な「ActiveReportsJS」と、「Mastra」「Next.js」を利用することで、今回ご紹介したようなAIエージェントの構築が可能になります。ぜひ本記事を参考に、業務AIエージェントを構築していただけると幸いです。

今回ご紹介したソースコードはGitHubで公開しています。こちらも是非ご確認ください。

製品サイトでは、今回ご紹介したActiveReportsJSの機能を手軽に体験できるデモアプリケーションやトライアル版も公開しておりますので、こちらもご確認ください。

また、ご導入前の製品に関するご相談、ご導入後の各種サービスに関するご質問など、お気軽にお問合せください。

\  この記事をシェアする  /