AI (サーバー側の Agent) と会話するチャット UI フィールドです。ユーザーの発言を送り、返事を吹き出しで表示します。返事は HTML として表示するので、表・コードブロック・リンク・見出し・インラインスタイルなど、Agent が「見やすい」と判断した表現が見えます (表示の前にブラウザで消毒します)。
AI の実体と会話の履歴はサーバー側の持ち物で、このフィールドは入力と表示だけを担います。返事を作る Agent はサーバーが名前を付けて登録し、フィールドの Agent プロパティでどれを使うかを選びます。標準で RawDataAccessAgent (DB を SQL で読んで集計・グラフで答える) を用意しています。サンプルアプリの既定はダミー Agent (AI 未接続) で、Azure OpenAI の設定があれば RawDataAccess も使えます。
- 入力: Ctrl+Enter は常に送信、Shift+Enter は常に改行。Enter は
SendOnEnter(既定 true) で送信か改行かを選べる (IME の確定 Enter は送信しない)。入力欄はMinInputRows(既定 3) 行ぶんの高さがあり、内容に合わせて 12 行まで伸びる - 考え中: 送信直後にアシスタント側の吹き出しを出し、点滅ドット・途中経過 (Agent が報告した場合)・経過秒数を表示
- 逐次表示: Agent が「ここまでの返事」を報告すれば、確定前から吹き出しの中身が更新される
- 停止: 待ち中は送信ボタンが停止ボタンになり、サーバーに中断を伝える
- エラーと再送: 失敗した発言はエラー表示になり、同じ内容で再送できる
- 新しい会話: 履歴を消して会話の識別子を振り直す
- コピー: 返事の吹き出しにマウスを乗せるとコピーボタンが出る (テキストとしてコピー)
- 会話は画面を離れても残る: 会話 (発言・返事・会話 ID・待ち中の返事) はアプリのメモリ上の保管庫 (DI スコープごと = WASM ならタブ) にモジュール名 + フィールド名で置かれ、返事の中のリンクで別ページへ行って戻っても消えない。返事待ちのポーリングは画面から離れている間も続くので、戻った瞬間に届いた返事が見える。リロードか「新しい会話」で消える (
KeepConversationを false にすると画面を離れた時点で消える) - 高さ:
IsFillAvailable: trueのグリッドの最終行に置くと残りの高さを使い切り、履歴だけが内部スクロールする。普通の行では履歴の領域は 16rem 固定で、返事が増えても行は伸びない (行が伸びると下の行やページを押し出す)。決まった高さにしたいときは行の Height を使う
| プロパティ | 型 | 必須 | 説明 |
|---|---|---|---|
| Agent | string | - | 返事を作るサーバー側 Agent の名前。サーバー側の対応表にある名前 (例: RawDataAccess) を指定し、同じフィールドで用途の違う Agent を使い分ける |
| DocumentFolder | string | - | この会話に渡す補足文書のフォルダ (デザインプロジェクトの Resources からの相対パス。例: AIChat/Sales)。そのフォルダの .md / .txt が用語の定義や集計の決まりとして Agent に渡る。空なら文書なし。チャットごとに別のフォルダを指定して、用途に合った文書だけを渡す (トークンの節約にもなる) |
| KeepConversation | bool | - | 画面を離れても会話を保持するか。既定 true。会話はアプリのメモリ (WASM ならブラウザのタブ) に置かれ、返事のリンクで別ページへ行って戻っても消えず、待ち中の返事も届く。リロードか「新しい会話」で消える。false なら画面を離れた時点で消える |
| TimeoutSeconds | int | - | 返事を待つ上限 (秒)。既定 600。超えたら問い合わせをやめてエラー表示にする (サーバー側の処理は止めない) |
| MinInputRows | int | - | 入力欄の行数 (最小)。既定 3。内容が増えれば 12 行まで自動で伸び、それ以上は入力欄の中でスクロール |
| SendOnEnter | bool | - | Enter キーで送信するか。既定 true。false なら Enter は改行。Shift+Enter (改行) と Ctrl+Enter (送信) はこの設定に関係なく固定 |
| OnReplyReceived | string (スクリプトイベント) | - | 返事が確定したときに呼ぶスクリプト。void Xxx(string replyHtml) |
| メンバ | 説明 |
|---|---|
Send(string text) |
発言を送る (待ち中・空文字は無視) |
Cancel() |
待ち中の返事を中断する |
Clear() |
履歴を消して新しい会話にする |
IsBusy |
返事を待っている間 true |
LastReply |
最後に確定した返事 (HTML) |
ConversationId |
現在の会話の識別子 |
// 画面の内容を前置きして質問する
void AskButton_OnClick()
{
Chat.Send("次の受注について納期の目安を教えてください。\n顧客: " + Customer.Value + "\n数量: " + Quantity.Value);
}
// OnReplyReceived に "Chat_OnReplyReceived" を設定した場合
void Chat_OnReplyReceived(string replyHtml)
{
Memo.Value = replyHtml;
}クライアントは HTML を表示するだけで内容を解釈しません。Markdown やテキストを HTML にするのはサーバーの仕事です。返事に時間がかかる Agent を想定し、HTTP を張ったまま待たず、受付番号でポーリングします。
既定は共通の API (POST /api/extras) に AIChat.Send / AIChat.Status / AIChat.Cancel を送ります (要求・応答の型は下と同じ)。以下は AIChatField.EndPoint にチャット専用のコントローラを設定したときの形です。
POST {EndPoint} { "conversationId": "…", "message": "…", "moduleName": "…", "fieldName": "…", "agent": "", "documentFolder": "" } // moduleName / fieldName = AIChatField の場所 (サーバーはこのデザインから Agent 名と文書フォルダを取る。agent / documentFolder は参考値)
→ 202 { "requestId": "…" }
GET {EndPoint}/{requestId}
→ { "status": "running" | "done" | "error" | "canceled",
"reply": "<p>…</p>", // running 中は「ここまでの HTML」(任意)、done で確定
"progress": "検索しています…", // 任意
"error": "…" }
DELETE {EndPoint}/{requestId} // 中断
- ポーリングの間隔は最初の 10 秒は 1 秒、以後 2.5 秒
- 会話の履歴は
conversationIdを鍵にサーバー (Agent) が持ちます。クライアントは全履歴を送りません - やり取りする型は
Codeer.LowCode.Blazor.Extras.AIChat名前空間 (AIChatSendRequest/AIChatSendResponse/AIChatStatusResponse)
Codeer.LowCode.Blazor.Extras.Server に部品があります。フォルダと名前空間は役割で分かれています: AI/Chat/ (共有: 契約 IAIChatAgent と窓口 AIChatService)、AI/Chat/ChatClient/ (内部: 会話エンジン ChatClientAgent。公開しない)、AI/Chat/RawDataAccess/ (専用: RawDataAccessAgent とその設定)、AI/Chat/ModuleDataAccess/ (専用: ModuleDataAccessAgent とその設定)。アプリに見える入口は、契約 IAIChatAgent、窓口 AIChatService、標準 Agent 2 つ (RawDataAccessAgent / ModuleDataAccessAgent) だけです。アプリは「Agent 名 → Agent」の対応表 (メールの MailSenderTable と同じ位置づけの静的クラス) で Agent を持ち、AIChatField はデザインの Agent でその名前を指定します。DI は要りません。
| 型 | 役割 |
|---|---|
IAIChatAgent |
返事を作る側のインターフェース。ReplyAsync(request, progress, cancellationToken) で AIChatReply (テキスト / Markdown / HTML / Auto) を返す。途中経過は IAIChatProgress に報告。request.AgentName にデザインの Agent 名が入る |
AIChatService |
AIChat のサーバー側入口 (プロセス内のジョブ置き場)。コンストラクタで対応表 (Func<string, IAIChatAgent?>。Agent が 1 つなら IAIChatAgent を直接) を受け、送信で Agent をバックグラウンド実行し、状態をポーリングに返す。ジョブの保持時間・最長実行時間は FinishedRetention / MaxRunning プロパティ。StartAsync(owner, request, moduleDataIO) で送信リクエストと ModuleDataIO を渡す (リクエストの ModuleName / FieldName の AIChatField が今のユーザーに見えるときだけ受け付け、Agent 名と文書フォルダはそのデザインから取る。見えなければ LowCodeException)。対応表に無い名前は error になる。プロセスに 1 つ (アプリの静的プロパティ) |
RawDataAccessAgent (+ RawDataAccessOptions) |
アプリの設計を読み、DB を直接読んで集計・グラフで答える Agent (会話の基盤は内部の会話エンジン: モデル呼び出し、会話履歴、逐次表示、Markdown → HTML。履歴の鍵は「ログイン ID + 会話 ID」(認証の無いアプリでは会話 ID だけ) で保持期限つき。トークンの膨張は RawDataAccessOptions の KeepToolResultsForTurns / MaxHistoryTurns / MaxHistoryCharacters の 3 段で抑える)。内部のツール: list_modules / describe_module (モジュール定義: フィールドの表示名・型・DB 列、候補値 (コード=名称)、リンク (結合相手とキー)、論理削除、スクリプト)、read_document (補足文書)、画面 URL (一覧 /{フレーム}/{セグメント}・詳細 /{フレーム}/{セグメント}/{Id} をページフレームのリンクから組み、行を挙げる返事に「開く」リンクを付ける)、get_schema (引数なしなら表名の目次だけ、tables を指定した表の列だけを 1 表 1 行で。方言はシステムプロンプトに常時入っていて、設計で表と列が分かるときは呼ばない指示にしている = トークン節約)、execute_sql (指定データソースで読み取り専用 SELECT を 1 文。行数・文字数・時間の上限、実行ログ)、render_chart (棒 / 折れ線 / 円。サーバーで SVG を作るので数字が狂わない)、search_records (SemanticSearchField を置いたモジュールの行を内容の意味で探す。コンストラクタの embeddingProvider に埋め込みプロバイダ (IEmbeddingProvider) を渡したときだけ付く)。依存 (IChatClient と IDbAccessor の作り方、デザイン定義、文書) はコンストラクタ、設定 (RawDataAccessOptions: データソース名の一覧と上限) は別 |
ModuleDataAccessAgent (+ ModuleDataAccessOptions / ModuleDataAccessScope) |
アプリの設計を読み、レコードを実行ユーザーの権限で読んで答える Agent。行の読み出しは本体の ModuleDataIO.GetListAsync を通るので、モジュールの閲覧権限 (UserRead)・行の条件 (DataRead)・項目の読み取り権限 (PermissionField)・論理削除・アプリアクセス条件が画面と同じに効く。SQL は書かせない。内部のツール: list_modules / describe_module / read_document (RawDataAccessAgent と同じ)、find_records (モジュール名・絞り込み (項目名・比較・値。all / any のグループで入れ子・not で否定)・並び・項目・ページを指定して行を返す。選択・リンクは { value, text })、aggregate_records (本体の集計 API ModuleDataIO.AggregateAsync で DB 側に集計させる。count / countDistinct / sum / avg / min / max、項目でグループ化、日付は year / quarter / month / week / day / hour でまとめ、集計後の絞り込み・並び・上限)、cross_tab (行 × 列のクロス表。CrossTabBuilder で合計も別クエリで取る)、get_record (Id で 1 件を子一覧ごと)、search_records (意味検索。semanticSearch を渡したとき。本体の一覧検索の条件として走るので権限が効く)、render_chart。DB の負荷の上限 (タイムアウト・1 返事の合計時間・同時実行数) は RawDataAccessAgent と同じ。読む ModuleDataIO はツール呼び出しごとに ModuleDataAccessScope (ユーザー Id → そのユーザーの DataService) として開いて閉じる。詳細は ModuleDataAccessAgent |
AIChatDocument |
Agent に渡す補足文書 (名前と本文)。出所はホストが決める。標準はデザインプロジェクトの Resources/{DocumentFolder}/*.md (フォルダはフィールドの DocumentFolder) |
| (内部) HTML 化 | 返事は AIChatService の中で HTML に揃えられる。Markdown は Markdig (表・タスクリスト・自動リンク、単独改行は <br>)、テキストはエスケープ、HTML は素通し (リンクに target="_blank" を付けるだけ)。Agent 側で HTML 化のコードを書く必要はない |
Example の DummyAIChatAgent (Example/Extras/Extras/Extras.Server/AI/) は AI を呼ばないダミーで、UI の確認と、自分で IAIChatAgent を書くときの雛形です。発言に html / text / error / slow を含めると振る舞いが変わります。
対応表はアプリの持ち物です。アプリテンプレート (Starter の Server/Extras/AIChatAgentTable.cs。Example は旧配置の AI/AIChatAgentTable.cs) の形:
// Extras/AIChatAgentTable.cs: Agent 名 → Agent。AIChatField のデザインの Agent がこの名前を指す。自分の Agent はここに 1 行足す
public static class AIChatAgentTable
{
static readonly ConcurrentDictionary<string, Lazy<IAIChatAgent?>> _agents = new(StringComparer.OrdinalIgnoreCase);
public static AIChatService Service { get; } = new(Create); // プロセスに 1 つ
public static IAIChatAgent? Create(string name) // 会話履歴を持つので名前ごとに 1 つを使い回す
=> _agents.GetOrAdd(name ?? "", n => new Lazy<IAIChatAgent?>(() => CreateCore(n))).Value;
static IAIChatAgent? CreateCore(string name) => name switch
{
"" => new DummyAIChatAgent(), // 既定 (Agent が空のフィールド)
"RawDataAccess" => new RawDataAccessAgent(
chatClientFactory, // IChatClient の作り方 (下記)
() => new DbAccessor(SystemConfig.Instance.DataSources), // IDbAccessor の作り方 (SQL ごとに作って捨てる)
() => AppDesign.Store.GetDesignData(), // デザイン定義 (ホットリロードで変わるので都度。null 可)
folder => DesignDataFileManager.GetResourceTexts(designFileDirectory, folder, ".md", ".txt") // 補足文書 = デザインの Resources/{DocumentFolder}/*.md|*.txt (本体 1.3.32 以降。null 可)
.Select(e => new AIChatDocument(e.Name, e.Text)).ToList(),
new RawDataAccessOptions { DataSourceNames = { "Analytics" } }), // AI 用の読み取り専用 DB ユーザーで接続するデータソース (複数可)
_ => null, // 知らない名前 → ジョブは error
};
}// Extras/ExtrasApiTable.cs: 共通の API (Controllers/ExtrasController.cs = POST api/extras) に出す機能の対応表に 1 行
new AIChatApi(AIChatAgentTable.Service),AIChatApi (Extras.Server) が送信・返事の状態・中断を受け、AIChatService の StartAsync / GetStatus / Cancel を呼びます。送信はリクエストの AIChatField が今のユーザーに見えるときだけ受け付け (アプリアクセス条件・モジュールの UserRead・フィールド読取権限)、ジョブの状態の取得・中断は送信したユーザーにだけ応じます。
// IChatClient は Extras.Server の AzureOpenAIClients が AISettings (Azure OpenAI の OpenAIEndPoint / OpenAIKey / ChatModel) から作る。設定が欠けていれば null
Func<IChatClient>? chatClientFactory = AzureOpenAIClients.ChatClientFactory(SystemConfig.Instance.AISettings);
// 別のプロバイダ (OpenAI / Ollama …) を使うなら、Microsoft.Extensions.AI の IChatClient をアプリで作って同じ形で渡す
// Func<IChatClient> chatClientFactory = () => new OllamaChatClient(...);クライアント側の設定は不要です (共通の API ExtrasTransport.EndPoint = /api/extras に送ります)。チャット専用のコントローラで受ける場合は、従来どおり AIChatField.EndPoint にその URL (POST {EndPoint} / GET・DELETE {EndPoint}/{requestId}) を設定すればそちらに送ります。
RawDataAccessAgent は名前どおり DB を生で読みます (SELECT を組んで実行し、結果を表とグラフにする)。何が読めるかは DB 側で決めます:
- AI 用に読み取り専用の DB ユーザーを作り、見せてよい表・列だけに SELECT を GRANT する。ログイン情報 (パスワードハッシュ・TOTP シークレット等)、変更履歴、一時ファイルの表は GRANT しない
- そのユーザーで接続するデータソースを appsettings の
DataSources/ConnectionStringsに追加し、RawDataAccessOptions.DataSourceNamesに指定する (複数可。1 つの SQL は 1 つのデータソースにしか届かず、またがる質問は AI がデータソースごとに問い合わせて突き合わせる)。Options は文字列と数値だけなので appsettings のセクションからそのまま束縛できる。アプリ本体と同じ接続を渡すと DB 全体が読める - SQLite はユーザーが無いので接続文字列の
Mode=ReadOnlyで読み取り専用にする (表・列の限定はできない) - ログインユーザーごとの行制限 (モジュールの UserRead / DataRead 条件) は効かない。「誰がこのチャットを使えるか」は、フィールドを置くページやモジュールの UserReadCondition (と PermissionField) で絞る。サーバーは送信のたびに、リクエストの
ModuleName/FieldNameの AIChatField が今のユーザーに見えることを確かめてから受け付け、Agent 名と文書フォルダもそのデザインから取る (サーバー API の権限チェック)
execute_sql 側の SELECT 判定 (1 文だけ・INSERT/UPDATE/DELETE 等の語を含まない) は補助で、書き込み拒否の本体は DB ユーザーの権限です。行数 (MaxRows 既定 200)、文字数 (MaxResultChars 既定 20000)、時間と同時実行数の上限 (DB の負荷を抑える)、実行した SQL のログ (RawDataAccessAgent のコンストラクタの ILoggerFactory を設定したとき。ILogger の Information) はツール側が担います。
監査ログ (AuditLog) との関係: 送信 (AIChat.Send) は DataRead として「誰が・どの画面の AIChatField を・どの Agent で使ったか」が残ります (Targets にモジュールと AIChat:フィールド名、Detail に Agent=。発言は残しません)。RawDataAccessAgent が読んだ行は監査ログの対象外です。SQL は送信とは別のジョブで実行され、集計の SQL は読んだ行を特定する形になっていないので、行の Id では残せません。監査にどう答えるかは 監査基準への対応 を見てください。RawDataAccessAgent は、行単位の統制を要しない分析用途向けです。
権限管理はライブラリではなく DB 側の設定と接続文字列で行ってください。 RawDataAccessAgent は「渡された接続で読めるものは読む」だけで、表や列の許可・不許可を判断する仕組みを持ちません。AI 用の DB ユーザー (またはビュー) を用意し、そのユーザーで接続するデータソースを appsettings に書く、が正式な手順です。
AI が書く SQL は、全件の集計や索引の効かない並べ替えになることがあります。RawDataAccessAgent は重い SQL を事前に見分けることはせず、時間と本数に上限を付け、表の大きさと索引を AI に伝えて軽い SQL へ誘導します。設定は RawDataAccessOptions で、テンプレートでは appsettings の AIChat:RawDataAccess にそのまま束縛されます。0 / false でその対策は無効です。待たせる・断る種類の上限 (合計時間・同時実行数) は既定で無効なので、必要になったら値を入れてください。
| 設定 | 既定 | 内容 |
|---|---|---|
CommandTimeoutSeconds |
30 | SQL 1 文のタイムアウト (秒)。超えると DB 側でもクエリが止まる |
MaxQuerySecondsPerReply |
0 (無効) | 1 回の返事で SQL の実行に使える合計時間 (秒)。使い切ると以後の SQL は DB へ行かずに断り、AI はここまでの結果で答える。例: 45 |
MaxConcurrentQueries |
0 (無効) | 同じデータソースへ同時に実行する SQL の本数 (プロセス全体)。超えた分は空くまで待ち (最大 CommandTimeoutSeconds 秒)、待ち切れたら「混み合っています」を返す。例: 2 |
CancelQueryAtRowLimit |
true | MaxRows を超えたら DB にクエリの中止を送る。送らないとドライバは残りの行を最後まで読み捨てる (DB は全件を送り切る) |
LargeTableRows |
100000 | この行数以上の表を「大きい表」として、索引の先頭列と一緒にシステムプロンプトに挙げ、索引のある列で絞るか集計するよう促す。行数と索引は DB のカタログから読む (COUNT(*) はしない)。0 で読まない |
"AIChat": {
"RawDataAccessDataSources": [ "Analytics" ],
"RawDataAccess": {
"CommandTimeoutSeconds": 30,
"MaxQuerySecondsPerReply": 0,
"MaxConcurrentQueries": 0,
"CancelQueryAtRowLimit": true,
"LargeTableRows": 100000
}
}効く範囲:
- 行数で止まるのは、行をそのまま返すクエリ (行数制限の無い SELECT など) です。集計・索引の無い並べ替え・大きな JOIN は、1 行目が出る前に重い処理が終わっているので行数では止まりません。これらは時間 (1 文・1 返事) と本数の上限で抑えます。軽くはならず、長引かない・重ならない、という上限です
- 行数と索引は DB の統計 (SQL Server
sys.partitions/sys.indexes、PostgreSQLpg_class/pg_index、MySQLinformation_schema、OracleUSER_TABLES/USER_IND_COLUMNS) から読み、スキーマと同じ時間 (SchemaCacheDuration) キャッシュします。統計が古いと行数もずれます。AI 用 DB ユーザーで読めなければ「不明」として扱い、SQL の実行は止めません
DB 側でやること (ライブラリでは代われないもの):
- AI 用のデータソースを読み取りレプリカに向ける。 本番 DB の負荷をなくせるのはこれだけです (接続文字列を変えるだけ)
- AI 用の DB ユーザーに DB 側のタイムアウトと接続数の上限を付ける (例: PostgreSQL
ALTER ROLE ai_reader SET statement_timeout = '15s'/ALTER ROLE ai_reader CONNECTION LIMIT 4) - よく聞かれる重い集計は、集計済みの表やビュー (夜間に作る等) を AI に見せる
割り切り:
- SQLite は時間で止まりません (Microsoft.Data.Sqlite のタイムアウトはロック待ちだけ)。サンプル・小規模向けです
- 同時実行の上限はプロセスごとです。スケールアウトすると実際の上限は「本数 × インスタンス数」になります
- ビューは統計に行数が出ません。大きいビューは補足文書に「この表は大きいので期間で絞る」と書いてください
execute_sql の結果 (行と列の値) は、そのまま AI のモデル (Azure OpenAI 等、アプリが IChatClient として渡したもの) に送られます。設計の情報 (モジュール名・列名・候補値) と補足文書も送られます。したがって、AI 用 DB ユーザーが読める列の中身は AI サービスへ出る、と考えてください。
多くの業務データ (受注、在庫、案件の数字) は、社内で SaaS を使うのと同じ扱いで問題になりません。Azure OpenAI は入力を学習に使わず、デプロイしたリージョン内で処理されます (不正利用監視のためにプロンプトと応答が一定期間保存される仕組みがあり、これは申請でオプトアウトできます)。導入先に説明するときは「学習に使われない」「リージョン (東日本など) 内で処理」「監視ログのオプトアウト」の 3 点を伝えると整理しやすいです。
気にする必要があるのは次の場合です。
- 個人 (消費者) の情報を含む表 (氏名、住所、電話、メール等)
- 顧客との契約で再委託先や処理場所が縛られている場合
- 業法で扱いが決まっている業種 (医療、金融など)
これらに当たる場合の手段も DB 側です。
- 列単位で GRANT を切る。 顧客マスタは ID と取引先名だけ SELECT を許し、住所・電話・メール・担当者名は許可しない (PostgreSQL / SQL Server / MySQL は列単位の GRANT が可能。Oracle はビューで代用)
- ビューをかませる。 AI 用に個人情報の列を落とした (またはマスクした) ビューを作り、AI ユーザーにはビューだけを見せる。運用上いちばん説明しやすい方法です
- 送る先を変える。 どうしても外に出せない場合は、
IChatClientを閉域 (Private Endpoint) の Azure OpenAI やローカルのモデル (Ollama 等) に差し替える。ライブラリはIChatClient抽象しか知らないので Agent 側の変更は不要です
行数や文字数の上限はトークンの節約のためのもので、漏えい防止の手段ではありません。プロンプトで「個人情報を列挙しない」と指示するのも振る舞いを整える程度の効果です。DB から読める時点でデータは送られているので、対策は必ず DB 側で行ってください。実行した SQL は実行ログ (ILogger) に全文残るので、「何が送られたか」は後から追えます (監査ログ (AuditLog) に残るのは「誰がどの Agent を使ったか」までで、SQL と読んだ行は残りません)。
上場企業の内部統制 (J-SOX の IT 全般統制) や ISMS が AI チャットに求める証跡は「誰が・いつ・何にアクセスしたか」です。RawDataAccessAgent はこれを機能ではなく構成と運用で満たします。
考え方は「AI が読める範囲を DB ユーザーで固定し、利用の事実を監査ログに残し、行単位の説明責任があるデータは AI の範囲に入れない」の 3 点です。
| 観点 | 満たし方 |
|---|---|
| 誰が・いつ | 監査ログ (AuditLog) の送信の行 (AIChat.Send、DataRead)。user_id・時刻・画面 (モジュールと AIChat:フィールド名)・Agent=・RequestId・デザインの版が残る |
| 何に (範囲) | AI 用 DB ユーザーに GRANT した表・列・ビューの集合。設定として静的に示せる (BI ツールのアクセスログと同じ粒度: 何を見られる人が、いつ使ったか) |
| 行単位の閲覧記録 | 個人情報 (個人情報保護法の安全管理措置・番号法・医療情報など、誰のデータを見たかまで問われるもの) は AI 用 DB ユーザーの範囲に入れない (ビューで列を除く / マスクする)。AI がそのデータに届かないので、行単位の記録を要する対象が無くなる |
| 事故調査 | RawDataAccessAgent に ILoggerFactory を渡し、実行した SQL (ユーザー・会話 Id つき) をアプリの実行ログに出す。保全期間は監査ログに合わせる。これは調査用で、監査ログではない |
| 証跡の保護 | 監査ログ側の規則 (追記専用の DB ユーザー・Strict・外部転送)。AuditLog の「上場企業の監査基準への対応」 |
設定の手順 (チェックリスト):
- AI 用に読み取り専用の DB ユーザーを作る。見せてよい表・列だけに SELECT を GRANT し、ログイン情報 (パスワードハッシュ・TOTP)・変更履歴・一時ファイル・監査ログの表は GRANT しない
- 個人情報はビューで外す。直接識別子 (氏名・メール・電話・住所・各種番号) を除くかマスクする。組み合わせで個人が特定できる列 (部署 + 年齢 + 入社日など) にも注意する。担当者名のような業務上の名前をどう扱うかは組織の判断で決め、方針として書き残す
- そのユーザーで接続するデータソースを appsettings に追加し、
RawDataAccessOptions.DataSourceNamesに指定する。アプリ本体と同じ接続は渡さない (SQLite はMode=ReadOnly) - 「誰がこのチャットを使えるか」は、AIChatField を置くページ / モジュールの UserReadCondition と PermissionField で絞る
- 監査ログを有効にする (appsettings の
AuditLog)。送信の行が残ることを確認する RawDataAccessAgentにILoggerFactoryを渡し、実行ログの保全期間を決める- 「AI に送られるデータ」の節の説明と合わせて、利用者 (と監査人) に「AI が読める範囲」を文書で示す
残さないもの (と理由):
- 実行した SQL 文の監査ログ化: 読める範囲が DB ユーザーで固定されているので、監査が問う「何に」は設定で答えられる。SQL 文を監査ログに入れると条件の値 (
WHERE name = '…') が証跡に残り、監査ログの「値は残さない」方針と衝突する。調査用途は実行ログで足りる - 読んだ行の Id: AI が書く SQL は集計が中心で、読んだ行を特定する形になっていない。行単位の説明責任があるデータは 2 で AI の範囲から外す方針なので、記録する対象が無い
ユーザーごとに見える行が違うデータ (担当者は自分の案件だけ、など) は DB ユーザーでは表現できないので、AI 用 DB ユーザーの範囲に入れません。
業務の意味は DB スキーマではなくデザインプロジェクトにあります。RawDataAccessAgent はデザイン定義 (DesignData) を受け取り、モデルに「業務語はまずモジュール定義で確かめ、表と列は describe_module で確認してから SQL を書く」よう指示します。モジュール定義に書けないこと (用語の定義、集計の決まり、データの見方) は、デザインプロジェクトの Resources の下のフォルダに Markdown で置き、フィールドの DocumentFolder でそのフォルダを指定します (例: Resources/AIChat/Sales/ に置いて DocumentFolder = AIChat/Sales)。チャットごとに別のフォルダを指せるので、売上分析のチャットには売上の説明書だけ、在庫のチャットには在庫の説明書だけ、と分けられます。App.zip に入ってデプロイで反映されます。文書の合計が小さい (既定 8000 文字以下) うちは全文がシステムプロンプトに入り、大きくなると一覧と冒頭の抜粋だけが入って AI が read_document で必要なものを読みます (App.zip からの読み出しは Codeer.LowCode.Blazor 1.3.32 以降の DesignDataFileManager.GetResourceTexts で行います)。用語の定義 (「売上」は完了分だけ、など) は列名からは分からないので、ここに書くと答えの精度が目に見えて変わります。
RawDataAccessAgent は DB を生で読むため、ログインユーザーごとの行制限と項目の読み取り権限が効きません。ユーザーごとに見える行が違うアプリ (担当者は自分の案件だけ、部門ごとのデータ等) や、
一部の項目を特定の人にしか見せていないアプリでは ModuleDataAccessAgent を使います。レコードは本体の ModuleDataIO.GetListAsync、集計は本体の集計 API ModuleDataIO.AggregateAsync (クロス集計フィールドと同じもの) で読むので、画面と同じ権限 (モジュールの UserReadCondition・行の DataReadCondition・PermissionField の読取条件・論理削除・アプリアクセス条件) がそのまま効きます。
権限が無くて読めなかったときは AI にエラー (accessDenied) が返り、AI はその旨をユーザーに伝えます。
//対応表 (AIChatAgentTable) に 1 行足す。テンプレート (Cookie) には "ModuleDataAccess" の名前で入っている
"ModuleDataAccess" => new ModuleDataAccessAgent(
chatClientFactory, // IChatClient の作り方 (アプリの責務)
userId => // そのユーザーの権限を持つ ModuleDataIO (ツール呼び出しごとに開いて閉じる)
{
var dataService = new DataService(userId); // テンプレートの「ユーザー Id を固定した DataService」
return Task.FromResult(new ModuleDataAccessScope(dataService.ModuleDataIO, dataService, dataService.DbAccess)); // 接続も渡すと SQL のタイムアウトが効く
},
() => AppDesign.Store.GetDesignData(), // デザイン定義 (ホットリロードで変わるので都度)
folder => DesignDataFileManager.GetResourceTexts(config.DesignFileDirectory, folder, ".md", ".txt")
.Select(e => new AIChatDocument(e.Name, e.Text)).ToList(),
config.AIChat.ModuleDataAccess, // ModuleDataAccessOptions (appsettings の AIChat:ModuleDataAccess をそのまま束縛)
semanticSearch: SemanticSearchTable.Service), // 意味検索 (search_records)。SemanticSearchField を置いたモジュールを権限つきで探せる- ユーザー Id は
AIChatAgentRequest.UserName(=AIChatService.StartAsyncの ownerKey。テンプレートはログインユーザーの Id) で、Agent はそれでModuleDataAccessScopeを開きます。バックグラウンド実行なのでリクエストのDataServiceは使えず、意味検索の再索引と同じく「ユーザー Id を固定した DataService」を新しく開きます - AI が使うツールは
find_records(条件・並び・項目・ページを指定して行を返す)、aggregate_records(条件に合う行を DB 側で集計。count / countDistinct / sum / avg / min / max、項目でグループ化、日付は year / quarter / month / week / day / hour でまとめる (年度の開始月はグループごとに指定可)、集計後の絞り込み (having)・並び・上限で「合計の多い得意先 10 件」のような問いに答える)、cross_tab(行 × 列のクロス表。行・列の見出し、セル、行ごとの合計・列ごとの合計・総計を返す。合計は本体のCrossTabBuilderが軸を減らした集計を別に実行した正しい値で、平均・値の種類数でも正しい)。SQL は書きません。項目は describe_module に出るフィールド名で指し、条件の値は項目の型 (数値・真偽・日付・時刻) に合わせて変換されます。条件は項目の比較を並べる (AND。matchAnyで OR) ほか、グループ (all/any) を入れ子にして AND と OR を組み合わせられ、notで否定できます (本体のMultiMatchConditionの木にそのまま落ちる)。選択・リンクは{ value, text }の形で値と表示名の両方が返ります (集計の鍵も同じ。日付の鍵は{ value: 期間の開始日, text: "2026-04" / "FY2026 Q1" }) - 集計は本体の集計 API (
AggregateCondition→ModuleDataIO.AggregateAsync) に渡すだけで、Extras 側で行を読んで足すことはしません。軸・値・条件に使う項目は読める項目だけで、読めない項目 (PermissionField で隠した金額など) を集計しようとすると本体が拒否し、AI にはaccessDeniedが返ります - リンク先の項目 (絞り込み・グループ化・表示名) は、モジュールにリンク越しのフィールド (
Customer.Nameのように名前にドットを含む列。デザイナでリンク先の項目をレイアウトに置くと作られる) があるときだけ使えます。無ければリンクは値 (Id) だけで返ります get_record(モジュール名と Id) は 1 件を子一覧 (ListField / ModuleField) ごと返します (孫は読みません)。子一覧の項目は、その一覧のレイアウトに置いた項目 = 画面に出る項目です。件数上限 (ページング) のある一覧は本体が親と一緒に読まないので、行の代わりに「find_records で読む」という案内を返します。結果がMaxResultCharsを超えると子一覧の行を半分ずつ減らします (truncated=true)。find_records は子一覧を返しません- 上限は
ModuleDataAccessOptions:MaxRows(find_records の 1 回の行数と子一覧の行数。既定 200)、MaxResultChars(ツール結果の文字数。既定 20000)、MaxGroups(aggregate_records が返すグループ数と cross_tab の行数。既定 100。超えた分は並び順の先頭から切りlimited=true)、MaxCrossTabCells(cross_tab のセル数。既定 2000。超えたら表を作らずエラー)、FiscalYearStartMonth(年・四半期でまとめるときの年度の開始月の既定。既定 1)。会話の設定 (プロンプト・履歴・逐次表示) はRawDataAccessOptionsと同じ項目 - DB の負荷の上限も
RawDataAccessAgentと同じ 3 つを持ちます (DB の負荷を抑える と同じ意味。0 で無効、待たせる・断るものは既定で無効):CommandTimeoutSeconds(読み取り 1 回の SQL のタイムアウト。ModuleDataAccessScopeに渡したIDbAccessorのCommandTimeoutSecondsに入れるので、渡さなければ効かない。本体 1.3.41 / DbAccess 0.2.0 以降)、MaxQuerySecondsPerReply(1 返事の読み取りの合計時間。使い切ると以後は DB へ行かずに断る)、MaxConcurrentQueries(同じデータソースへの同時実行数。RawDataAccessAgent と共有)。AI が出せるのは画面と同じ形のクエリだけなので、最悪でも「絞り込みなしのクロス集計」= 利用者が CrossTabField でできることと同じ重さです - 意味検索: コンストラクタの
semanticSearchにSemanticSearchService(索引付けと同じもの) を渡すと、設計に SemanticSearchField があり埋め込みプロバイダが設定されているときsearch_records(モジュール名・探したい内容・件数・filters) が付きます。検索は本体の一覧検索の条件 (SemanticMatchCondition) として走るので、find_recordsと同じく行の条件・論理削除・項目の読み取り権限が効き、結果もfind_recordsと同じ行の形で近い順に返ります。索引の項目 (SemanticSearchField) を読めないユーザーには拒否されます。score は返しません (SemanticSearchField の権限つき検索) - できないこと: データソースをまたぐ結合、設計に無い表、ウィンドウ関数などの自由な SQL (これらは
RawDataAccessAgent) - 監査ログ: 送信 (
AIChat.Send) の行は残りますが、ジョブの中で読んだ行は残りません (リクエストの外で実行され、集計は行を特定しないため)。この Agent の意義は監査ではなく「行・項目の権限が画面と同じに効く」ことです。監査にどう答えるかは 監査基準への対応 と同じで、ユーザーごとに見える行が違うデータを AI に読ませたいときにこちらを使います
標準 Agent で足りないとき (独自のツールを持たせたい、モデルを使わない、独自の対話制御が要る) は IAIChatAgent を直接実装し、対応表に 1 行足します。モデルを呼ぶ部分は Microsoft.Extensions.AI をそのまま使えます (ツール呼び出しの往復は FunctionInvokingChatClient が肩代わりします):
public class MyAgent : IAIChatAgent
{
public async Task<AIChatReply> ReplyAsync(AIChatAgentRequest request, IAIChatProgress progress, CancellationToken token)
{
progress.Report("検索しています…");
var result = await SearchAsync(request.Message, token);
progress.Report("まとめています…");
var markdown = await SummarizeAsync(result, token); // LLM の出力 (Markdown)
return AIChatReply.Markdown(markdown); // HTML にしたければ AIChatReply.Html(...)
}
}- 逐次表示したいときは、LLM のストリームを受けながら
progress.ReportPartial(AIChatReply.Markdown(ここまでの全文))を呼ぶ (差分ではなく全体を渡す) - サーバー側の会話履歴 (既定 2 時間) やジョブ (完了後 30 分) が消えたあとに続きを送ると、クライアントは表示中の会話の写し (テキストのみ・直近 6 往復・4000 文字まで、
AIChatSendRequest.Transcript) を一緒に送り、RawDataAccessAgentは履歴が無いときだけそれで文脈を復元する。SQL の結果などツールの結果は写しに無いので、「その中で」のような直前の結果を指す追問は履歴が消えた後は精度が落ちる AIChatServiceのジョブと Agent の会話履歴は単一インスタンス前提のメモリ保持です。サーバーを複数インスタンスにするときはセッション固定 (Azure App Service の ARR アフィニティは既定でオン) が前提で、それが使えない構成では共有ストア (DB テーブル) への置き換えが必要です- 所有者 (ログイン ID) が一致しないジョブは見えません。匿名同士は共有になるので、認証のあるアプリで使ってください
- このフィールド自体は DB に値を保存しません。返事を残したいときは
OnReplyReceivedで他のフィールドに入れて保存してください - 返事の HTML は表示の前にブラウザで消毒します (DOMPurify)。スクリプトが動くもの (script・イベント属性・javascript: の URL)・フォームの部品・class・位置や重なりを変える style (position・z-index 等) を落とす (グラフの
aichat-chartは残る) - ページを離れると問い合わせは止まりますが、サーバー側の処理は続きます (結果は既定で 30 分保持)
- Agent が未設定の環境 (デザイナのプレビュー等) では入力欄が無効になり、その旨を表示します
- パッケージの組み合わせ: Extras.Server 0.12.1 以降は
Azure.AI.OpenAI2.9.0-beta.1 (OpenAI 2.9 以降向け) に依存し、Microsoft.Extensions.AI.OpenAI10.7 が持ち込む OpenAI 2.11 と揃っています。0.12.0 以前 (Azure.AI.OpenAI2.1.0) ではChatClientを直接CompleteChatAsync等で呼ぶコードがこの組み合わせでMissingMethodExceptionになりました。Azure OpenAI は対応表の例のようにAzureOpenAIClient…GetChatClient(model).AsIChatClient()で IChatClient に包んで使う形を推奨します (Extras.Server の Agent と AITextAnalyzeService はそうしています)