MESCIUS.devlog https://devlog.mescius.jp/ メシウス株式会社のDeveloper Solutions〈開発支援ツール〉の情報発信メディア「MESCIUS.devlog」 Thu, 19 Mar 2026 00:52:18 +0000 ja hourly 1 https://devlog.mescius.jp/wp-content/uploads/2023/10/cropped-favicon-32x32.png MESCIUS.devlog https://devlog.mescius.jp/ 32 32 InputManJSの「AI文章作成アシスタント」のアシスタントメニューをカスタマイズする https://devlog.mescius.jp/inputmanjs-ai-gctextassistant-menu-customization/ Thu, 19 Mar 2026 00:52:16 +0000 https://devlog.mescius.jp/?p=31936 便利で快適な入力フォーム開発に特化したJavaScriptライブラリ「InputManJS(インプットマンJS)」の最新バージョン「V6J」では、各種AIプラットフォームのAPIを利用して、リッチテキストエディタ(GcRichTextEditor)と複数行テキスト(GcMultiLineTextBox)に文章の生成、校正、要約、翻訳といったAIによる文章作成支援機能を組み込むことができる「AI文章作成アシスタントコンポーネント(GcTextAssistant)」が新機能として追加されました。

前回はリッチテキストエディタにAI文章作成アシスタントを組み込んで使用する方法をご紹介しました。

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

AI文章作成アシスタントのメニューをカスタマイズする"

前回作成したアプリは以下よりダウンロード可能です。

AI文章作成アシスタントをカスタマイズする

使用できるアシスタントメニューを制御する

AI文章作成アシスタントにはデフォルトの組み込みのアシスタントメニューとして、文章の生成や校正、要約、翻訳などの多数のメニューが用意されています。

デフォルトのアシスタントメニュー
デフォルトのアシスタントメニュー

これらのメニューは要件に合わせてエンドユーザーが使えるメニューを制御することができます。

「client/src/main.js」の中で、AITextAssistantConfigmenuItemsオプションに、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の機能を手軽に体験できるデモアプリケーションやトライアル版も公開しておりますので、こちらもご確認ください。

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

]]>
マイアカウントで障害【2026年3月18日】(復旧) https://devlog.mescius.jp/myaccount-error-20260318/ Wed, 18 Mar 2026 08:18:06 +0000 https://devlog.mescius.jp/?p=32121 追記(2026年3月19日 9:40)

2026/3/18(水)8:30頃に発生いたしました弊社Webサイトのマイアカウントの障害について、復旧いたしました。 
マイアカウントの各種お手続きやお問い合わせフォームの受付メールが送信されない状態となっておりましたが、正常に動作しております。
ご迷惑をお掛けし、誠に申し訳ございませんでした。

弊社システムの障害によりご迷惑をお掛けしまして、誠に申し訳ございません。

2026/3/18(水)8:30頃より、弊社Webサイトのマイアカウントで障害が発生しており、各種お手続きの際のメールがお客様に送信されない状態となっております。

  • アカウントの新規作成(本人確認メール)
  • パスワード再設定
  • 技術サポート(E-mail) 受付自動返信メール
  • ※ ご質問は正常に受け付けられており、順次対応いたします。

ご利用の皆様には多大なご不便をおかけし大変申し訳ございませんが、復旧まで今しばらくお待ちください。

]]>
DioDocs V9Jの新機能 https://devlog.mescius.jp/diodocs-9-new-features/ Thu, 12 Mar 2026 02:00:12 +0000 https://devlog.mescius.jp/?p=32025 DioDocs(ディオドック)」の最新バージョン「V9J」を、2026年3月27日(金)にリリースします。

DioDocs V9Jの新機能

今回リリースする「V9J」では、新たに「DioDocs for Imaging」が加わります。DioDocs for Imagingは、外部の画像編集ツールへ依存することなく画像の生成・編集・保存がC#およびVB.NETのコードから可能な .NETアプリケーション開発用の画像処理ライブラリです。

DioDocs for Imaging(画像処理)

画像のリサイズやクロップ、回転、反転といった変形および色や輝度、コントラストの調整、さらにフィルタやエフェクトの適用、画像の合成といった画像処理を実施できます。そのほかに、画像に対して図形やテキスト、バーコードを描画することも可能です。

また、Webアプリケーションで画像の表示および編集が可能なImageビューワをJavaScriptライブラリとして提供します。

DioDocs for Imaging(Imageビューワ)

そのほかに、以下のような機能の改善と追加を行っています。

  • DioDocs for Excel
    • AI用カスタム関数の追加
    • 新しいExcel関数の追加(ARRAYTOTEXT、VALUETOTEXT)
    • パフォーマンスの改善
  • DioDocs for PDF
    • 既存の最適化機能の統合

各機能の詳細についてはこちらの新機能ページで確認いただけます。

本件に関するニュースリリースは以下をご覧ください。

]]>
SpreadJS V19Jの新機能 https://devlog.mescius.jp/spreadjs-19-new-feature/ Wed, 11 Mar 2026 02:00:10 +0000 https://devlog.mescius.jp/?p=31881 JavaScriptスプレッドシートライブラリ「SpreadJS(スプレッドJS)」の最新バージョン「V19J」を2026年3月26日(木)にリリースします。

SpreadJS V19Jの新機能

「V19J」では、以下のようなアップデートを実施しています。

AI連携機能

各種AIプラットフォームのAPIを利用したAI連携機能を追加しました。
※ これらの機能の使用には、あらかじめ各種AIプラットフォームのAPIキーを準備・設定する必要があります。

AI関数

表計算関数として使用できるSpreadJS独自のAI関数を3種追加しました。

AI関数
  • SJS.AI.QUERY関数:
    連携したAIモデルにクエリを送信し、データ処理と分析を行うことができます。
  • SJS.AI.TRANSLATE関数:
    対象のコンテンツを指定した言語に翻訳します。
  • SJS.AI.TEXTSENTIMENT関数:
    対象のテキストの感情を分析し、肯定的、否定的、中立的、の結果を返却します。
SJS.AI.QUERY関数を使用したデータ分析例

AIで数式の自動生成と分析

数式エディタにAI機能を追加し、自然言語による指示から数式を自動生成できるようになりました。複数の関数を使用した複雑な数式も簡単に作成が可能です。また、入力した数式の処理内容を分析し、詳細な説明を取得することもできます。複雑な数式の処理内容を把握したい場合などに有用です。

数式の自動生成例

AIでピボットテーブルの作成とデータ分析

ピボットテーブルで使用するピボットパネルにAI機能を追加し、自然言語による指示からピボットテーブルを自動生成できるようになりました。ユーザーはAIに指示するだけで手軽にデータ分析を行うことができます。AIによる提案からのピボットテーブルの生成や、ピボットテーブルの内容をAIが分析し、その結果を取得することもできます。

ピボットテーブルの自動生成例

レポートシートの各種機能を強化(レポートシート)

レポートシートの各種デザイン機能やExcel出力機能を強化し、よりさまざまなユースケースにおいて使いやすくなりました。

テンプレート範囲

テンプレートシート上に「ヘッダー」「詳細」「フッター」といったテンプレート範囲(セルのブロック)を定義し、動的なデータ構造(繰り返しセクション、階層構造、グループ化された集計など)を持つレポートをより簡単にデザインできるようになりました。従来のようにコンテキストを設定しなくてもデータの親子関係を自動的に認識してくれるので、デザインにかかる手間が軽減されます。

テンプレート範囲

数式を維持したままExcelエクスポート

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

数式を維持したままExcelエクスポート

このほかにも、ピボットテーブルやデータチャートなどの既存機能において、約20の新機能の追加や機能強化を実施しています。

製品のWebサイトではこれらのアップデート内容を動画や画像付きで紹介していますので、是非ご確認ください。

また、本件に関するニュースリリースは以下をご覧ください。

]]>
Webサイトメンテナンスのお知らせ https://devlog.mescius.jp/maintenance-20260316/ Wed, 11 Mar 2026 02:00:04 +0000 https://devlog.mescius.jp/?p=32010 誠に勝手ながら、Webサイトのメンテナンスのため一部サービスをご利用いただけない場合があります。ご利用のお客様は、該当の時間帯を避けてご利用くださいますようお願いいたします。

メンテナンス日時

2026年3月16日(月)19:00~19:30
※ 作業状況により、時間が多少前後する場合があります。

皆様には大変ご不便をおかけしますことお詫び申し上げますとともに、あらかじめご了承いただけますようお願い申し上げます。

【お問合せ先】
[email protected]

メシウス株式会社
Developer Solutions事業部

]]>
AIチャットで完結。MastraとActiveReportsJSで実現する日報AIエージェント(3) https://devlog.mescius.jp/mastra-activereportsjs-aiagent-3/ Tue, 10 Mar 2026 05:20:04 +0000 https://devlog.mescius.jp/?p=31868 これまで「MastraとActiveReportsJSで実現する日報AIエージェント」の作成方法として、第1回目では「Next.js プロジェクトへの Mastra の導入と、サンプルコードを用いた基本的なエージェント構築」について紹介しました。

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

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

日報AIエージェント

コンポーネントの作成

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

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コンポーネントの実装

続いて、チャット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: '[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;
}
~~ 以下省略 ~~

チャット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の機能を手軽に体験できるデモアプリケーションやトライアル版も公開しておりますので、こちらもご確認ください。

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

]]>
PDFドキュメントのコンテンツに墨消しを追加する https://devlog.mescius.jp/diodocs-pdf-add-redaction/ Fri, 06 Mar 2026 02:40:06 +0000 https://devlog.mescius.jp/?p=31632 PDFの墨消し機能とは?

PDFにおける墨消し機能とは、PDFドキュメントに含まれる特定の情報を完全に削除して第三者が復元できない状態にする機能です。単に黒い図形で情報を隠すのではなく、テキストや画像といったデータそのものをPDFドキュメントから完全に削除することができ、情報漏洩や誤開示を予防する対策として安全性が高い処理といえます。

例えば、PDFドキュメントに含まれる顧客や従業員の個人情報(氏名、住所、口座番号、電話番号など)、およびその他の機密情報を公開せずに社外に共有したい場合があります。このような場合に墨消し機能が役立ちます。墨消しを適用されたコンテンツは表示および復元したり、コピー&ペーストで抽出することができなくなります。

DioDocs for PDF(ディオドック)」では、PDFドキュメントを読み込んで対象のコンテンツに墨消しを適用することが可能です。

特定の範囲にあるコンテンツに墨消しを適用する

DioDocs for PDFでは、RedactAnnotationクラスを使用して墨消し注釈をPDFドキュメントのページに追加し、墨消しを適用することができます。請求書などのPDFドキュメントにおいて以下のように特定の範囲に顧客情報が含まれている場合に、その情報に対して墨消し処理を実施することが可能です。

特定のコンテンツを削除する

Loadメソッドで読み込んだPDFドキュメントに対して、RedactAnnotationクラスを使用して墨消し注釈を追加します。この際に墨消しを適用する範囲(Rect)と塗りつぶす色(OverlayFillColor)を設定することが可能です。その後Redactメソッドで墨消しを適用します。

using GrapeCity.Documents.Pdf;
using GrapeCity.Documents.Pdf.Annotations;
using System.Drawing;

Console.WriteLine("PDFドキュメントに墨消しを追加・適用する");

var fs = new FileStream("input.pdf", FileMode.Open, FileAccess.Read);

var doc = new GcPdfDocument();
doc.Load(fs);

var page = doc.Pages[0];

// 墨消し注釈を追加
var redaction = new RedactAnnotation()
{
    Rect = new RectangleF(50, 50, 250, 100),
    OverlayFillColor = Color.Gray,
    Page = page
};

// 墨消し注釈を適用
doc.Redact();

doc.Save("result.pdf");

実行すると以下のように墨消しが適用されたPDFファイルが出力されます。

特定のコンテンツを削除する

請求書のレイアウトが同一であれば墨消しを適用する範囲は固定で設定できるので、DioDocs for PDFを使うことにより、逐次対象のPDFドキュメントを開いて墨消し注釈を追加し、墨消しを適用する、といった手間が省けます。ワークフローなどで墨消しを適用する処理を自動化したい、といったような要件に適している機能です。

上記で紹介した動作を確認できるサンプルはこちらです。

マウスで選択した範囲にあるコンテンツに墨消しを適用する

DioDocs for PDFではJavaScriptライブラリとしてPDFビューワを提供しています。こちらを利用して、表示したPDFドキュメント上でマウス操作により選択した範囲に対して墨消し注釈を追加し、墨消しを適用することができます。

事前準備(Webアプリケーションの作成)

まず、Visual Studio Codeを起動してターミナルでプロジェクトのフォルダquickstart-wasmを作成してquickstart-wasmフォルダに移動します。

mkdir pdfviewer-add-redaction
cd pdfviewer-add-redaction

npm installコマンドを使用してPDFビューワのパッケージをインストールします。

npm install @mescius/[email protected]

index.htmlを作成してbody要素にPDFビューワを表示するためのdiv要素を追加します。さらにscript要素を追加してPDFビューワを設定します。

<!DOCTYPE html>
<html lang="ja">

<head>
    <title>PDFビューワ(Wasm)</title>
    <link rel="stylesheet" href="./src/styles.css">
    <script src="/node_modules/@mescius/dspdfviewer/dspdfviewer.js"></script>
    <script src="/node_modules/@mescius/dspdfviewer/DsPdf.js"></script>
    <script src="/node_modules/@mescius/dspdfviewer/wasmSupportApiServer.js"></script>
    <script src="./src/app.js"></script>
</head>

<body>
    <div id="viewer"></div>
</body>

</html>

app.jsを作成してPDFビューワで表示するPDFファイルを設定します。

window.onload = function(){	
	const viewer = new DsPdfViewer("#viewer", { 
		workerSrc: "node_modules/@mescius/dspdfviewer/dspdfviewer.worker.js",		
		supportApi: { implementation: new WasmSupportApi()},
		restoreViewStateOnLoad: false
	});
    viewer.addDefaultPanels();
	viewer.addAnnotationEditorPanel();
	viewer.open("assets/pdf/input.pdf");
}

ローカルで動作確認するための開発用のサーバーとして、Visual Studio Codeの拡張機能「Live Server」をインストールします。その後、index.htmlを右クリックして、[Open with Live Server]を実行します。

事前準備(Webアプリケーションの作成)

実行すると、PDFビューワで読み込んだPDFファイルが表示されます。

事前準備(Webアプリケーションの作成)

墨消し注釈の追加

PDFビューワの上部にあるツールバーで「テキストツール」を選択し、さらに「墨消し領域としてマーク」をクリックします。

墨消し注釈の追加
墨消し注釈の追加

マウス操作により、墨消しを適用したい範囲をドラッグすると墨消し注釈が追加できます。マウス操作により範囲の調整も可能です。

墨消し注釈の追加

PDFビューワの左側にあるサイドバーから「注釈エディタ」を選択します。こちらでは、塗りつぶす色など墨消し注釈のプロパティを設定することができます。

墨消し注釈の追加

注釈エディタパネルの上部にある「墨消しの適用」、またはツールバーの「すべてに墨消しを適用」をクリックすると、PDFドキュメントに墨消しが適用されます。

墨消し注釈の追加

最後にツールバーで「保存」-「PDF形式で保存」を選択すると、墨消しが適用されたPDFファイルがローカルにダウンロードされます。

墨消し注釈の追加
墨消し注釈の追加

上記で紹介した動作を確認できるサンプルはこちらです。

以上のように、DioDocs for PDFでは2種類の方法でPDFドキュメントに墨消しを適用することができ、要件にあわせて使い分けることが可能です。

今回紹介した墨消し機能については以下のデモやヘルプで詳細を確認いただけます。

DioDocs for PDFの最新バージョン「V8J SP2」の新機能については、以下のページで紹介していますのでこちらも是非ご覧ください。

さいごに

弊社Webサイトでは、製品の機能を気軽に試せるデモアプリケーションやトライアル版も公開していますので、こちらもご確認いただければと思います。

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

]]>
JavaScriptフレームワーク「React」でSpreadJSを使う【2026年版】 https://devlog.mescius.jp/spreadjs-react-quickstart/ Thu, 05 Mar 2026 01:00:00 +0000 https://devlog.mescius.jp/?p=8334 React(リアクト)」はMeta(旧Facebook)が開発したJavaScriptフレームワークで、世界のIT技術者1万人以上を対象としたアンケート「State of JavaScript」において、2016年から2025年まで常にトップの利用率を維持しているフロントエンドフレームワークです。

ExcelライクなスプレッドシートをWeb上で実現するJavaScriptライブラリ「SpreadJS(スプレッドJS)」は、このReactをはじめとする主要なJavaScriptフレームワークに対応しています。

本記事では、ReactとViteを使ってSpreadJSを組み込んだアプリケーションを構築する方法についてご紹介します。

開発環境

  • Node.js(Version 24.13.1)
  • Vite(Version 7.3.1)
  • React(Version 19.2.0)
  • SpreadJS(Version 18.2.4)

SpreadJSを組み込んだReactアプリケーションの構築

Viteを使ったReactアプリケーションの作成

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アプリケーションが表示されます。

Reactアプリケーションの実行

動作を確認したらターミナルウィンドウに戻り、Ctrl+Cキーを押下することで実行を中断できます。

SpreadJSのインストールと組み込み

先ほど作成したReactアプリケーションにSpreadJSを組み込んでいきます。最初に行うのはSpreadJS関連モジュールのインストールです。

npmパッケージのインストール

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上に実装されたSpreadJS
React上に実装されたSpreadJS

さいごに

今回の記事では、ReactとViteを使ってSpreadJSを組み込んだアプリケーションを構築する方法についてご紹介しました。

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

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

]]>
SPREAD/InputManPlus/MultiRowPlusの新バージョンをリリース! https://devlog.mescius.jp/spread-inputman-multirow-new-version-release/ Wed, 04 Mar 2026 08:25:09 +0000 https://devlog.mescius.jp/?p=31729 Windows Formsアプリケーション開発向けコンポーネントにおいて、以下3製品の新バージョンを2026年3月4日(水)にリリースしました。

.NET10とVisualStudio2026に対応

各製品においては、共通してマイクロソフトが提供するアプリケーション開発&実行環境「.NET」の最新バージョン「.NET 10」に対応しています。あわせて、最新の統合開発環境「Visual Studio 2026」での利用が可能になっています。

さらに、Windows Formsアプリケーションの表現力向上や利便性向上につながる新機能を多数追加しています。各製品における新機能の詳細については、以下のWebサイトをご確認ください。

30日間無料で機能の制限なく製品を試せるトライアル版はこちらからダウンロード可能です。

各製品の保守サービスを契約中のお客さまは、所有している開発ライセンスを最新バージョンへバージョンアップできる「フリーアップグレード」の利用が可能です。手続き方法については以下をご確認ください。

]]>
AIエージェントを機能拡張する!Claude CodeでAgent Skillsを使う https://devlog.mescius.jp/ai-agent-claude-code-agent-skills/ Tue, 03 Mar 2026 02:42:33 +0000 https://devlog.mescius.jp/?p=31570 普段のお仕事に役立つ普遍的なプログラミングTIPSや、業界で注目度が高い最新情報をお届けする「編集部ピックアップ」。
今回はClaude CodeなどのAIエージェントを拡張して、その振る舞いなどを定義できる「Agent Skills」機能をご紹介します。

はじめに

AIコーディングツールは日々驚くべき進化を続けています。コード補完からコード生成へ、そして「AIエージェント」の登場によって、開発フロー自体の自動化が現実のものとなってきました。
その最前線で開発が進められているAnthropic社の「Claude Code」に、2025年10月、新機能「Agent Skills」が追加されました。Agent Skillsとは、一言で言えば「AIエージェントに決まった処理をさせるための機能」です。

今回の記事では、Agent Skillsについて、その概要から特長、導入方法、そして実際にスキルを作成して使用するまでの手順を詳しく紹介します。

Agent Skillsとは

Agent Skillsは、2025年10月に発表されたClaude Codeの新機能です。これは、AIエージェントが専門知識や定型処理を使えるようにする仕組みで、Claude Codeだけでなく、他のAIツールでも使用できるオープンな標準仕様として公開されています。

Agent Skillsの概要
出典:Claude をスキルで拡張する – Claude Code Docs(2026年2月17日閲覧)

Agent Skillsは、AIエージェントに特定の処理を自動実行させるために、「スキル」という単位で処理を定義する機能です。プログラミング言語におけるライブラリのイメージに近いかもしれません。
SKILL.mdというファイルに処理内容を記述しておくことで、AIエージェントはその「スキル」を呼び出して処理を実行することができるのです。

AIエージェントに依頼を出すと、その都度AIが推論を行って、アクションを決定します。
その結果、これまでは以下のような問題がありました。

  • 同じ指示でも回答が毎回変わってしまう
  • 似たような処理で何度もトークンを消費してしまう
  • 同じプロンプトを繰り返し入力するのが面倒

しかし、Agent Skillsを使うことで、よく使う処理をあらかじめ定義しておき、必要な時にAIエージェントに実行させることができます。これにより、AIエージェントは定義された手順に従って、迷いなく正確にタスクをこなせるようになります。

Agent Skillsは、Anthropicが開発した機能ですが、「Agent Skills Open Standard」として公開されており、Cursor、GitHub Copilotなど他のAIツールへの展開も進んでいます。

そんな注目の機能であるAgent SkillsをClaude Codeで利用する方法を解説します。

Claude Codeの詳細や導入方法については、以下の記事をご参照ください。

Agent Skillsの特長

Agent Skillsには、以下のような特長があります。

再現性の高さ

最大の特長は、処理の再現性の高さです。これまでのAIエージェントは、同じ指示を出しても、毎回回答が変わってしまうことがありました。しかし、Agent Skillsでは、スキル内で手順や実行コマンドを明示できるため、通常のチャット指示よりも結果のブレを抑えやすくなります。

チーム共有可能

スキルはプロジェクト内の.claude/skills/ディレクトリにファイルとして保存されます。これをGitなどで管理することで、チームメンバー間でスキルを共有できます。熟練のエンジニアが作成したデプロイ手順やテスト手順をスキル化しておけば、チーム全体で高品質な作業フローを統一することができます。

段階的読み込み

Agent Skillsは、必要になったときにスキルを読み込み、手順に従って必要な分だけ処理を実行します。そのため処理に必要なすべての情報をプロンプト(コンテキスト)に含める必要がなく、トークン消費を抑えることができるのも特長です。

CLAUDE.mdやカスタムコマンドとの違い

Claude Codeには、プロジェクトの設定を行う「CLAUDE.md」や、定型文を登録する「カスタムコマンド」という機能もあります。それぞれの違いは以下の通りです。

  • CLAUDE.md:プロジェクト全体のコンテキスト(背景、ルール、方針)
    プロジェクトの概要、コーディング規約、アーキテクチャの指針などを記述するファイルです。AIエージェントにプロジェクト全体のコンテキスト(背景知識)を理解させるために使用します。
  • カスタムコマンド:単発の指示(毎回同じ文章を入力する手間を省く)
    よく使う指示を登録しておくことで、毎回同じ文章を入力する手間を省くことができる機能です。例えば、testと入力するだけでnpm run testを実行させるなど、単発のコマンド入力の手間を省くために使用します。
  • Agent Skills:複雑な手順を自動実行(スクリプトや外部ツールも使える)
    複数の手順を組み合わせたり、外部スクリプト(Pythonなど)を実行したりする、より複雑な処理を定義するための機能です。AIエージェントに自律的にツールを使わせる場合に適しています。

有効な利用シーン

Agent Skillsは、安定した結果が求められる処理や複雑な手順の自動実行が得意です。ChangelogやPR、仕様書などのドキュメントの自動生成や、テスト、デプロイ、リファクタリングなどの定型作業の自動化に活用できます。また、将来的にはCI/CDパイプライン全体の構築や、コードレビューからデプロイまでの一連のワークフローの自動化なども期待されています。

自作orパッケージ?便利なスキル集の紹介

Agent Skillsは自作もできますが、世界中の開発者が作成したスキルが集まる「スキル集」が多数公開されており、活発なエコシステムが形成されつつあります。
ここでは主要なスキル集を紹介します。

主要なスキル集

  • Vercel公式/skills.sh
    • 最大規模のスキル集。2026年2月時点で数万件のスキルが登録されており、最も活発なスキル集です。
  • VoltAgent/awesome-agent-skills
    • 主要企業の公式スキルを網羅的に集約。2026年2月時点で500以上のスキルが登録されており、Anthropic、Vercel、Google、Microsoftなどの主要企業の公式スキルを網羅的に集約しています。
  • https://github.com/anthropics/skills
    • Anthropic公式スキル。2026年2月時点で100以上のスキルが登録されており、Anthropic公式が提供するスキル集です。
  • コミュニティキュレーション:
    • ComposioHQ/awesome-claude-skills
      コミュニティによって厳選されたスキル集。Claude Code向けに最適化されたスキルが多数登録されています。
    • travisvn/awesome-claude-skills
      個人開発者がキュレーションしているスキル集。ユニークなスキルが見つかることも。

これらはGitHubのスキルリポジトリからコードをダウンロードし、.claude/skills/ディレクトリに配置することで、スキルを利用できます。また、skills.shでは、npx skills add <owner/repo>でスキルを追加できます

スキル集を利用するメリット

自作しなくても、すぐに使える実用的なスキルが豊富に揃っています。スキル集からスキルを導入し、必要に応じてカスタマイズすることで、「使える」機能をチームや社内で共有し、開発効率を向上させることができます。

一方で、公開されているスキル集には悪意のあるスキルが混ざっている可能性もあります。信用できる提供元が公開しているスキル集を利用するようにしましょう。

スキルを自作するメリット

細かな要件に合わせたスキルを作成できるのは、自作の大きなメリットです。日々の細かな業務や、チームの独自ルールに合わせたスキルを開発することで、生産性を劇的に向上させることができます。

Agent Skillsは、Skill Creatorという機能も用意しており、Skillの自作をサポートしてくれます。

Agent Skillsを使ってみよう

事前準備

必須の準備

  • Claude Codeの導入
    Claude CodeでAgent Skillsを利用するには、まずClaude Codeを導入する必要があります。詳しくはこちらの記事を参考に導入してください。

推奨の準備

  • Git環境
    スキル集を利用するために、Git環境も用意しておくと便利です。アカウントを作成し、スキル集を取得する準備をしましょう。
  • Python環境
    Agent Skillsでは、外部ファイルを実行することができます。今回はPythonのスクリプトを実行する例を紹介しますので、必要に応じてPython環境を整えてください。

スキルの作成

では早速、Agent Skillsの機能でスキルを作成してみましょう。スキルを作成するには、いくつかの決まりがあります。

ディレクトリ構造

スキルは以下のディレクトリ構造を遵守する必要があります。グローバルで利用するスキルは、Claude Codeのルートディレクトリに、.claude/skills/という構造で配置する必要があります。
プロジェクトで利用するスキルは、プロジェクトのルートディレクトリに、同じ階層構造で配置します。

  .
  ├── .claude
  │   └── skills
  │       └── <skill_name>
  │           ├── SKILL.md
  │           └── <script_name>

SKILL.mdの書き方

SKILL.mdは、YAMLフロントマターとMarkdown本文で構成されています。

YAMLフロントマター(メタ情報)

メタ情報として、最低限、name属性、description属性を以下の形式で指定します。他にも設定できる情報は、version属性、author属性などがあります。

  • name:スキル名
  • description:スキルの説明

例)name: Hello World description: A simple hello world skill version: 1.0.0 author: Claude

Markdown本文(指示)

Markdown本文には、実際の処理を記述します。
例)# Hello World This is a simple hello world skill.

サポートファイル

Agent Skillsでは、SKILL.mdだけでなく、実際の処理を行うスクリプトファイルを一緒に配置できます。これを「サポートファイル」と呼びます。
例えば、以下のようなファイルをサポートファイルとして利用できます。

  • Pythonスクリプト(.py)
  • Shellスクリプト(.sh)
  • JavaScriptファイル(.js)
  • その他の実行可能ファイル

SKILL.mdの本文で、これらのファイルを実行するコマンドを記述することで、AIエージェントが自動的にスクリプトを実行してくれます。
今回の例では、SKILL.mdと同階層に配置したhello.pyを呼び出すように記述します。

実践例:簡単な挨拶スキルを作る

それでは、例として簡単な挨拶スキルを作ってみましょう。「挨拶して」と指示すると、名前と時刻を提示し挨拶してくれるスキルを自作していきます。

ステップ1:フォルダ作成

プロジェクトのルートディレクトリ下に、.claude/skills/hello-skillフォルダを作成します。
以下のような構成になります。

project_root/
└── .claude
    └── skills
        └── hello-skill
            ├── hello.py
            └── SKILL.md

ステップ2:SKILL.md作成

.claude/skills/hello-skill/SKILL.mdを作成します。

YAMLフロントマターの記述
---
name: hello
description: A simple skill to greet the user with the current time using a Python script.
---

【日本語訳】Pythonスクリプトで、現在時刻とともにユーザーに挨拶するためのシンプルなスキル。

Markdown本文(指示)の記述例
## Instructions
When the user asks for a greeting or asks about the time, you MUST run the provided Python script `hello.py` to get the accurate time and greeting.
Do not guess the time. Execute the script.
      
## Usage
Run the script using: `python3 hello-skill/hello.py`

【日本語訳】
指示:ユーザーが挨拶を求めたり、時刻について尋ねたりした場合は、必ず提供されているPythonスクリプト「hello.py」を実行して、正確な時刻と挨拶を取得してください。時刻を推測してはいけません。スクリプトを実行してください。
使用方法:スクリプトを実行するには、以下のコマンドを入力してください:python3 hello-skill/hello.py

ステップ3:スクリプト作成

現在時刻を取得するhello.pyを作成します。

import datetime
import os
    
def get_greeting():
    now = datetime.datetime.now()
    user = os.getenv('USER', 'User')
    return f"Hello {user}! The current time is {now.strftime('%Y-%m-%d %H:%M:%S')}."
    
if __name__ == "__main__":
    print(get_greeting())

ステップ4:動作確認

ステップ2と3で作成したファイルをステップ1で作成したディレクトリに配置します。

ディレクトリ構造

claudeコマンドで、Claude Codeを起動します

Claude Code起動画面

「挨拶してください」と指示をすると、スキルを使用するか確認されるので、Yesを選択します。

スキル使用確認

次に、pythonスクリプトを実行するか確認されます。
ここで「Yes, and don’t ask again for python3 commands in <スキル名>」(このスキル内でのpython実行を許可する)を選択すると、次回から実行確認が省略されます。

Pythonスクリプト実行確認

自動的にスクリプトが実行され、現在時刻と共に挨拶が表示されます。

挨拶スキル実行結果

ここまで所要時間は5分程度です。シンプルなスキルであれば、このように手軽に自作できます。ただし、より複雑な処理を実装する場合には、SKILL.mdの記述にも工夫が必要になってきます。

そんなときに便利なのが、AnthropicのSkill Creatorです。この機能を使えば、AIがスキルの作成をサポートしてくれます。次回の記事では、Skill Creatorを使った高度なスキル作成について詳しく解説する予定ですので、ぜひご期待ください。

注意点とトラブルシューティング

実際に動作させてみて、いくつか注意すべきポイントがありましたので紹介します。

  • SKILL.mdの記述
    ファイル先頭のYAMLフロントマター(---で囲まれた部分)にnamedescriptionが正しく記述されているか確認してください。
  • 指示の具体性
    descriptionInstructionsが曖昧だと、AIがスキルを使うべきか判断できない場合があります。具体的なキーワードを含めるようにしましょう。
  • 再起動
    新しいスキルを追加した直後は、Claude Codeの再起動が必要な場合があります。

さいごに

Agent Skillsを活用することで、Claude Codeは単なるコード生成ツールを超え、「開発フロー全体を支えるパートナー」へと進化します。

これまで、AIエージェントは便利だけれど「何をするか分からない不安定さ」がありました。しかしAgent Skillsによって、「AIに任せる部分」と「確実に実行させたい処理」をコントロールできるようになりつつあります。

定型作業の自動化や、チーム固有のワークフローの共有など、活用の幅はアイデア次第で大きく広がります。ぜひ、Agent Skillsを活用して、AIエージェントを使いこなしてみてください。


メシウスではさまざまなWebアプリケーションフレームワークと一緒に使えるJavaScriptライブラリを提供しています。

メシウスのJavaScriptライブラリ

無償のトライアル版や、ブラウザ上で手軽に試せるデモアプリケーションも公開しているので、こちらも是非ご覧ください。

]]>