Skip to content

Latest commit

 

History

History
553 lines (455 loc) · 125 KB

CLAUDE.md

File metadata and controls

553 lines (455 loc) · 125 KB

Codeer.LowCode.Blazor.Extras - Project Guide

プロジェクト概要

Codeer.LowCode.Blazor 向けの拡張フィールドライブラリ。 カレンダー、ガントチャート、カンバンボードなど、ローコードフレームワークに高機能UIコンポーネントを追加する。 NuGetパッケージ Codeer.LowCode.Blazor.Extras (MIT) として配布。

リポジトリ構成

Codeer.LowCode.Blazor.Extras/
├── Source/
│   ├── Codeer.LowCode.Blazor.Extras/   # メインライブラリ (Razor Class Library)
│   │   ├── Components/                   # Blazor UIコンポーネント (.razor + .razor.css)
│   │   ├── Designs/                      # フィールドデザインクラス (JSON定義)
│   │   └── Fields/                       # ランタイムフィールドクラス
│   └── Example/Extras/                   # 動作確認用サンプルアプリ
├── Experiments/                          # 実験的プロトタイプ (統合前の検証用)
│   ├── BlazorMyCalendarApp/              # カレンダーUI実験
│   └── GanttBlazor/                      # ガントチャートUI実験 (SVGベース)
├── bk/                                   # バックアップ
│   └── GanttBlazor/                      # ガントチャート旧版
└── LICENSE, README.md

依存関係

<PackageReference Include="Codeer.LowCode.Blazor" Version="1.2.45" />
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="8.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.8" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />

本体 Codeer.LowCode.Blazor はNuGetパッケージとして参照。

ビルド

dotnet build Source/Codeer.LowCode.Blazor.Extras/Codeer.LowCode.Blazor.Extras.csproj

GeneratePackageOnBuild: True でビルド時にNuGetパッケージを自動生成。

共通の API (ExtrasTransport / ExtrasApi)

Extras の機能 (メール・ジョブ・承認・AI チャット・意味検索の再索引・自分の認証アプリの解除・Excel → PDF・AI 文書解析) がホストと話す口は 1 本。各機能の節の「結線」はここを指す。

  • クライアント (Extras Api/): ExtrasTransport (static)。EndPoint (既定 "/api/extras") へ ExtrasRequest {Feature, Action, Payload (操作ごとの要求の JSON)} を POST し、ExtrasResponse {Payload} を受ける。HTTP を使わないホストは Handler (Func<ExtrasRequest, Task<ExtrasResponse?>>) を設定する。機能名・操作名は各機能の XxxApiNames (Mail: Send / SendBulkSearch / Preview / PreviewBulkSearch、Job: Run / Status、Approval: Execute、AIChat: Send / Status / Cancel、SemanticSearch: Reindex / Status / Cancel、Totp: Status / Reset、Excel: ConvertPdf、AITextAnalyze: AnalyzeFile / AnalyzeText)。ファイルの本文は要求の byte[] (JSON では base64) で運ぶ
  • サーバー (Extras.Server Api/): ExtrasApi の Handlers に IExtrasApiHandler を並べ、ExecuteAsync(ExtrasRequest, ExtrasApiContext) が Feature で振り分ける (並べていない機能は null = ホストは 404)。ExtrasApiContext = ModuleDataIO / DesignData / DbAccess / UserId (必須) / Logger / Audit。ハンドラ = MailApi(MailConfig, Func<string, IMailSender?>) / JobApi(IReadOnlyCollection<string> jobNames) / ApprovalApi(MailConfig, Func<string, IMailSender?>) / AIChatApi(AIChatService) / SemanticSearchApi(SemanticSearchService, Func<string userId, SemanticSearchReindexScope>) / TotpApi(TotpLoginSettings) / ExcelApi() (変換は Excel.Report.PDF。フォントの GlobalFontSettings.FontResolver はホストの Program.cs が置く) / AITextAnalyzeApi(AISettings) (中身は AITextAnalyzeService。権限・デザインの拒否はそのまま、モデル側の失敗は "AI analysis failed. Retrying may succeed.")。要求は ExtrasApi.ReadPayload<T> で読み、知らない操作は ExtrasApi.UnknownAction の例外
  • 監査ログ: 分類はハンドラの GetAuditCategory(action) (旧 Controller の [Audit] 属性に当たる)、操作名は "機能.操作" (例 "Mail.Send")。試行の行は ExtrasApi が実行前に AuditContext.StartAsync で書く (前段 = 属性の分類で書いていなければ)。対象・失敗はハンドラが context.Audit に入れる。Excel.ConvertPdf は Export、AITextAnalyze は Other
  • ホスト (Starter Cookie): Controllers/ExtrasController.cs ([Authorize, AutoValidateAntiforgeryToken]・POST api/extras。ExtrasApiTable.Create() の ExtrasApi に DataService から組んだ ExtrasApiContext を渡す) + Extras/ExtrasApiTable.cs (Handlers に 8 行)。クライアントの ServiceInitializer にこれらの機能の EndPoint 代入は無い。デスクトップ (Wpf / WinForms) は従来のフック (MailTransport.Handler / XxxCoreAsync) のままで、ExtrasTransport.EndPoint = string.Empty にして HTTP に出さない
  • 互換 (旧結線はそのまま動く): MailTransport は Handler か SendMailEndPoint が設定されていれば専用ルート。JobTriggerField は RunCoreAsync → EndPoint → 共通の口。Approval (ApprovalTransport.EndPointBase) / AIChat (AIChatField.EndPoint。デスクトップは SendCoreAsync) / SemanticSearch (SemanticSearchField.EndPoint。デスクトップは ReindexCoreAsync) / Totp (TotpResetClient.StatusEndPoint・ResetEndPoint) は専用の EndPoint が設定されていればそちら、空なら共通の口。Excel は ConvertPdf (差し替え) → ConvertPdfEndPoint → 共通の口。AITextAnalyzerField は FileToModuleDataCoreAsync / TextToModuleDataCoreAsync (差し替え) → FileToModuleDataEndPoint / TextToModuleDataEndPoint → 共通の口
  • 共通の口に乗らないもの: 一括ファイル入出力 (BulkFileReader.ParseFileEndPoint / BulkFileTransferService.ListFileByDataEndPoint / BulkSubmitEndPoint。受け口は本体の ModuleDataController) と本体の ModuleDataController。ServiceInitializer に残る EndPoint 代入はこの 3 行だけ
  • テスト: Test/Api/ExtrasApiTest

既存フィールド

すべて実装済み。各フィールドのユーザー向け詳細は docs/Xxx.md を参照。

表示・可視化系

CalendarField - カレンダー

  • 状態: 実装済み (docs/CalendarField.md)
  • 機能: 月・週・日表示のカレンダー。イベントの表示・追加・編集、モジュールデータとの連携
  • インターフェース: IDisplayName, ISearchResultsViewFieldDesign, IFillHeightFieldDesign
  • ファイル: Designs/CalendarFieldDesign.cs, Fields/CalendarField.cs, Components/CalendarFieldComponent.razor

GanttField - ガントチャート

  • 状態: 実装済み (docs/GanttField.md)
  • 機能: SVGベースのガントチャート。タスクのドラッグ移動・リサイズ、依存関係の管理
  • インターフェース: IDisplayName, ISearchResultsViewFieldDesign, IFillHeightFieldDesign
  • ファイル: Designs/GanttFieldDesign.cs, Fields/GanttField.cs, Components/GanttFieldComponent.razor

TaskBoardField - カンバンボード

  • 状態: 実装済み (docs/TaskBoardField.md)
  • 機能: カンバンボード。ドラッグ&ドロップでステータス変更
  • インターフェース: IDisplayName, ISearchResultsViewFieldDesign, IFillHeightFieldDesign
  • ファイル: Designs/TaskBoardFieldDesign.cs, Fields/TaskBoardField.cs, Components/TaskBoardFieldComponent.razor

CrossTabField - クロス集計

  • 状態: 実装済み (docs/CrossTabField.md)。本体の集計 API (ModuleDataIO.AggregateAsync / IModuleDataService.AggregateAsync / CrossTabBuilder / スクリプトの ModuleAggregator) の上に乗る「集計定義を受け取って行 × 列の表を描くだけ」の部品。集計・権限・SQL・クロス表の形 (CrossTab) は本体の持ち物
  • 機能: 行・列 (複数可・日付は年/四半期/月/週/日)、値 (件数/重複を除いた件数/合計/平均/最小/最大)、行ごと・列ごとの合計と総計、割合表示、値で絞り込み・並べ替え・表示件数の上限。定義は設計 (Setting) か、スクリプトの Show(aggregator[, rowCount])。CanCustomize で利用者が自分用に変えられる (表の右上の「集計のカスタマイズ」ボタン (エラー時も出る) / ShowCustomDialog() → Blazor の CrossTabSettingEditor。保存は localStorage「モジュール名.フィールド名」= ListField のカラムカスタマイズと同じ。読み込み時は ReconcileSetting で今の設計に合わせ、OK 時は ValidateSetting で検査)。チームで共有する集計は設計に書く
  • インターフェース: IDisplayName, ISearchResultsViewFieldDesign, IFillHeightFieldDesign
  • ファイル: Designs/CrossTabFieldDesign.cs, Designs/CrossTabSetting.cs, Fields/CrossTabField.cs, Fields/CrossTabCell.cs, Components/CrossTabFieldComponent.razor。デザイナの設定ダイアログは Extras.Designer Controls/CrossTabSettingPropertyControl
  • リンク越しの項目の解決 (CrossTabFieldDesign.ResolveField) は LinkField / ModuleField / モジュール参照の SelectField をたどる Extras 側の実装 (LinkTargetModuleName。本体の解決は internal のため使わない)

MarkerListField - 画像マーカー

  • 状態: 実装済み (docs/MarkerListField.md)
  • 機能: 画像上にマーカー(ピン)を配置・操作。クリック/ダブルクリックイベント
  • インターフェース: ISearchResultsViewFieldDesign, IDataDependentField, IFillHeightFieldDesign
  • ファイル: Designs/MarkerListFieldDesign.cs, Fields/MarkerListField.cs, Components/MarkerListFieldComponent.razor

QrCodeField - QRコード

  • 状態: 実装済み (docs/QrCodeField.md)
  • 機能: 文字列をQRコード画像として表示する表示専用フィールド。参照フィールド(SourceField)の値、固定文字列(Text)、またはスクリプト(Field.Text)で内容を設定。ECCレベル・前景/背景色を指定可能
  • 依存: QRCoder (MIT, 純C#・System.Drawing非依存)。C#側でPNGを生成し base64 data URI で <img> 表示 (JS不要)
  • インターフェース: IDataDependentField (SourceField 変更に自動追従)
  • ファイル: Designs/QrCodeFieldDesign.cs, Fields/QrCodeField.cs, Fields/QrCodeEccLevel.cs, Components/QrCodeFieldComponent.razor

ProgressField - 進捗バー

  • 状態: 実装済み (docs/ProgressField.md)
  • 機能: 進捗率をガント風の角丸バーで表示する表示専用フィールド。値は別フィールド(ValueField)から、色も別フィールド(ColorField)または固定色(BarColor)から取得。進捗率をバー上に重ねて表示 (ShowValueLabel でON/OFF)、文字色は背景色に対する YIQ コントラスト色を自動選択 (Ganttと同じ)。リストに入れて各行の進捗表示に使える
  • インターフェース: IDataDependentField (ValueField/ColorField の変更に自動追従)
  • ファイル: Designs/ProgressFieldDesign.cs, Fields/ProgressField.cs, Components/ProgressFieldComponent.razor

入力系 (値を持つ / ValueFieldDesignBase)

RichTextField - リッチテキストエディタ

  • 状態: 実装済み (docs/RichTextField.md)
  • 機能: 書式付きテキストエディタ。太字・色・リンクなどのHTMLフォーマットに対応
  • ファイル: Designs/RichTextFieldDesign.cs, Fields/RichTextField.cs, Data/RichTextFieldData.cs, Components/RichTextFieldComponent.razor

MarkdownField - Markdown エディタ / ビューア

  • 状態: 実装済み (docs/MarkdownField.md)
  • 機能: Markdown をプレーンテキストのまま DB 列に保存し、閲覧時 (IsViewOnly) は HTML に描画。編集時はツールバー (見出し/太字/斜体/取り消し線/箇条書き/番号付き/チェックリスト/引用/リンク/コード/コードブロック/表) と PreviewMode (Tab=タブ切替 / Split=左右並び / None。編集中だけの設定で、読取専用時は常にプレビュー表示。表示名は「プレビュー(読取専用時はプレビューで表示)」= 「なし」なのに閲覧で描画されて見える誤読への対策 2026-09-08)。MaxLength / Placeholder / ShowToolbar。必須と最大文字数の検証。高さの設定は持たない (0.9.1 で Rows 撤去。JS attach/autoSize が中身に合わせて伸ばす=最小 6 行。FillAvailable の最終行や行 Height で外から高さが決まっているときは「textarea を 0 にしても .markdown-body が縮まない」で判定して伸ばさず stretch+内部スクロール。RichText と同じ振る舞い)
  • 描画: Markdown/MarkdownRenderer (Markdig 0.37.0、AdvancedExtensions + 単独改行を <br> + DisableHtml で生 HTML 無効化 = 信頼できないテキストを描く前提) + リンクに target=_blank。表示 CSS は .markdown-view ::deep。閲覧・プレビューで共通
  • 編集の同期: textarea はローカル _text を oninput で持ち、blur (onchange) / ツールバー操作 / タブ切替で Field.SetValueAsync (キー入力ごとに OnDataChanged を起こさない)。外部からの値変更は Field.OnDataChangedAsync で _text へ戻す。ツールバーは wwwroot/markdown-interop.js の applyAction が選択範囲を書き換えて全文を返す
  • RichTextField との住み分け: 書き手が記法を知っている人か AI なら Markdown、記法を知らない利用者なら RichText (HTML 保存)
  • 未対応: 検索条件 (SearchLayout)、一覧セル向けの 1 行表示、画像アップロード
  • スクリプト: Value / Html / PlainText / AppendLine(string)
  • ファイル: Designs/MarkdownFieldDesign.cs (enum MarkdownPreviewMode 同居), Data/MarkdownFieldData.cs, Fields/MarkdownField.cs, Components/MarkdownFieldComponent.razor(.css), Markdown/MarkdownRenderer.cs, wwwroot/markdown-interop.js

TagField - タグ入力

  • 状態: 実装済み (docs/TagField.md。仕様は docs を先に書いて合意してから実装: 2026-10-07。形は 2026-10-08 に素のフィールドへ)
  • 形: タグを付けるモジュールごとにタグ付けモジュール (TagLinkContractField、役割 OwnerId = IdField / TagName = TextField、既定のフィールド名 Name)。1 行 = レコードに付いたタグ 1 つ。タグのマスタは無い。TagFieldDesign : FieldDesignBase, IChildRecordsFieldDesign / TagField : FieldBase<TagFieldDesign>, ISearchableField, IOwnedRecordsField (Calendar / Gantt / TaskBoard / MarkerList と同じ形。行は ModuleCollection / OwnedRecordModules、行のモジュールは ModuleLayoutType.None)。設定は TagModuleName (タグ付けモジュール) と Placeholder / IsSimpleSearchParameter / SearchMatchDefaultValue / OnDataChanged (値が変わるフィールドの標準のイベント。Calendar などと同じ)。必須の設定は無い (タグは付けなくてもよい分類。要る画面は OnValidateInput で Tags.Count を見る)。結び付きは GetChildRecordsCondition(modules) (本体 1.3.44) が契約の役割から組み立てる (OwnerId.Value = Id.Value・並び Id・SelectFields = Id / OwnerId / タグ名)。契約が無ければ null (本体の口の約束: 結び付きが決まらない = 子レコードを持たない。本体は null かどうかで一覧のパスかを判定する)。TagField は契約と結び付きをインスタンスで 1 回解決して持つ (デザイナは設計を書き換えながら使うので、デザイン側には持たない)。JSON にもデザイナにも出ない。本体はこれで検索の EXISTS・社員.タグ の解決・一覧の行への同梱・条件のデザインチェックを行う。データは ListFieldData (タグ付け行)。レコードの削除でタグ付け行を消すのは DeleteTogether (既定 true。従属レコードの宣言の OwnedRecordsDesign.DeleteTogether、本体 1.3.55 = 一覧の DeleteTogether と同じ経路で親の削除の前に 1 行ずつ消す。タグ付けモジュールの CanDelete 必須 = 設計チェック TagFieldDesign:2)。false なら本体は消さず DDL の外部キー (ON DELETE CASCADE) に任せる (論理削除の親のタグ行を残したいときも false)。従属レコードの宣言 (IOwnedRecordsFieldDesign.GetOwnedRecords(modules)、本体 1.3.45) も同じ条件を返す (編集履歴の版に入り、復元で戻る)
  • セットアップ: Extras.Designer/Setup/TagSetupService (Tools › タグのセットアップ / CLI tag-setup --target <Module>)。名前は対象から決める (フィールド Tags、モジュール <Module>Tags、テーブルは snake_case 複数形、列 owner_id / name、データソースは対象と同じ)。同名のモジュールに契約が無ければ止める。DDL は TagTableDdl が 3 列の表を DB ごとに組む (本体の DDL の文字列は直さない): name = 長さ TagField.MaxTagLength (200) NOT NULL、SQL Server は COLLATE Latin1_General_CS_AS_KS_WS、MySQL は utf8mb4_bin で完全一致 (PostgreSQL / SQLite / Oracle は既定で区別する)。owner_id = 対象の Id 列の型 (DB の RawDbTypeName。文字列の型は長さが取れないので既定の長さ + 結果に案内。DB に無ければ手入力の Id は文字列、それ以外は整数)。一意 (owner_id, name) と name のインデックス、外部キー (SQLite は列に書く)
  • 動き: チップ入力 (Enter かスペースで確定、打った文字のスペース (半角・全角) で分ける。カンマは区切らずタグの一部。区切りの集合は TagField.Separators の 1 か所で、入力欄も JS のキー判定もそれを使う。外へ移ると打ちかけも確定、IME 中は無視、空欄で Backspace は右端を外す)。足し外しは何も書かない (レコードの保存で一緒に)。同一判定は完全一致 (Ordinal)。タグ名は 200 文字まで (入力欄の maxlength・Name の MaxLength・DDL が同じ定数。超えたら足さずにエラー表示。エラーは呼び出しの始めに消し、同じ呼び出しでほかのタグが足せても残す)。候補 (TagField.GetCandidatesAsync) = 本体の集計 (AggregateCondition + ValueGroup・Count・件数の降順 → 名前の昇順・上位 10・Like 打った文字。何も打っていなければ Like を付けない = よく使われている上位) を 1 回。効くのはタグ付けモジュールの読み取り条件 (親の行の条件は効かない)。TagInputComponent はフォーカスで空欄の候補を出す (1 回だけ問い合わせて _topCandidates に持つ・待たない)。打った文字は 200 ms 待ってから問い合わせ、後から打った文字の結果だけ使う。サーバーの候補をそのまま出す (付いているタグを除くだけ。手元で絞り直さない。文字が変われば前の候補は捨てる)。問い合わせは文字が変わるごとに 1 回 (区切りで確定したときは AddAsync が残りの文字で)。% _ は本体がエスケープするので、呼ぶ側は何もしない (文字のまま一致)
  • 読み込み: 詳細はタグ付け行を 1 回読む (未保存のレコードは読まない)。一覧の行・スクリプトで読んだレコードは、一覧の読み込みに同梱された行を使い、無ければ表示のためには読まない (行ごとの問い合わせは出さない)。書く (AddTag / RemoveTag / SetTags / 復元の LoadOwnedRecordsAsync) 前は、読んでいない保存済みのレコードだけ 1 回読む (知らずに足すと同じタグの行が二重になり、保存で一意インデックスのエラー)。LoadOwnedRecordsAsync は読んであれば今の行を返す (全件を持つ = HoldsAllRecords)。スクリプトの ModuleSearcher は Select(e => e.Tags) で同梱させる。未保存のレコード (コピー) に渡された行はタグ名だけ使って新しい行にする (コピー元の行の Id を持たない)。GetData はタグ付け行を返す (保存時の意味検索の文章がタグ名を読む)
  • 検索: ISearchableField を直接実装。子のパス Tags.Name.Value に、いずれか = In、すべて = ContainsAll (本体 1.3.42、SQL で判定)。値はタグ名なので条件の復元・URL 共有で問い合わせない、後から付いたタグも反映される。親の検索に LinkFieldNames で子のタグを置いたとき (社員.タグ) の ContainsAll は、親の子の行のタグを合わせて判定する (テスト: 会社 → 社員 → タグ。デザインはファイルから読んだときに本体が LinkFieldNames のフィールドを作るので、テストは TagTestDesigns.Reload で読み直す)。検索欄は本体 TextField と同じ input-group + 一致の select (IsSimpleSearchParameter で隠す)
  • 意味検索: TagFieldDesign が ISemanticSearchTextSource (Extras の意味検索の口) を実装してタグ名を「, 」で並べる (意味検索の側は型名で分岐しない)。サーバーの索引付け (保存時・再索引とも) は GetWithOwnedRecordsAsync の内部読みで TagField のタグ付け行を従属レコードとして付ける (本体の一覧読みの同梱は子モジュールの閲覧条件で欠けるので使わない)。タグ付けを直接書くと人の文章が作り直されない → 一括でタグを付ける画面も人のレコードを通して保存する (2026-10-06)
  • 重なり順: 候補の dropdown は z-index 1040、検索欄 (input-group) では Bootstrap が .form-control を position: relative; z-index の層にするので :focus-within で入力欄ごと前に出す。デザイン側の GridColumn.AllowOverflow false (overflow: clip) にも切られる → 置く列は はみ出しを許す
  • IME: wwwroot/tag-interop.js が isComposing / keyCode 229 の間はキーを送らない (変換を確定する Enter でタグを作らない)。確定後の input / compositionend で区切りがあれば .NET が分ける。箱の中の mousedown は preventDefault でフォーカスを入力欄に留める (候補のクリックが blur で消えない)
  • スクリプト: Tags / AddTag / RemoveTag / SetTags / HasTag、検索欄 SearchTags / SearchMatch (TagSearchMatch を ExtrasClientInitializer で登録)。内部用は [ScriptHide]
  • 一覧のセル: 編集できるセルではチップ + 入力欄、閲覧ならチップだけ。本体の td.form-control-style-inline input:focus の内側リングは、.razor.css で同じ :not 列を付けた高い詳細度のセレクタで消す (!important は使わない)
  • ファイル: Designs/TagFieldDesign.cs (enum TagSearchMatch 同居), Designs/TagLinkContractFieldDesign.cs, Fields/TagField.cs, Tag/TagContracts.cs (契約の取得・タグ名の読み方), Components/TagFieldComponent.razor / TagFieldSearchComponent.razor / TagInputComponent.razor(.css), wwwroot/tag-interop.js, Extras.Designer/Setup/TagSetupService.cs (+ TagSetupWindow, SetupCli の tag-setup), Extras.SeleniumDrivers/TagFieldDriver.cs (+ TagFieldSearchDriver), テスト Extras.Test/Tag/ (ハーネス Harness/DbClientServices = クライアント Module を SQLite の ModuleDataIO に直結・集計も、TagTestDesigns) と Extras.Test/Setup/TagSetupServiceTest。Example: TagTest + TagTestTags (tag-setup 生成)、SampleData/tag_sample.sql

ColorPickerField - カラーピッカー

  • 状態: 実装済み (docs/ColorPickerField.md)
  • 機能: HTML5ネイティブカラーピッカー。色をHEX文字列として保存
  • ファイル: Designs/ColorPickerFieldDesign.cs, Fields/ColorPickerField.cs, Data/ColorPickerFieldData.cs, Components/ColorPickerFieldComponent.razor

ユーティリティ系 (UIなし / 補助)

AIChatField - AI チャット UI

  • 状態: 実装済み (docs/AIChatField.md)。サーバー側に標準 Agent (RawDataAccessAgent。会話エンジン ChatClientAgent は internal) あり。Example の既定はダミー (Example/.../Extras.Server/AI/DummyAIChatAgent.cs)、RawDataAccess は AISettings (Azure OpenAI) があるときだけ登録
  • 機能: チャット UI だけを担う。入力 (Ctrl+Enter 常に送信 / Shift+Enter 常に改行 / Enter は SendOnEnter 既定 true / IME 対応 / 自動伸長。Placeholder / WelcomeMessage は撤去 = 必要なら画面にラベルを置く。.aichat-messages は min-height 16rem で初期表示に高さ)、考え中ドット + 途中経過 + 経過秒、逐次表示、エラー + 再送、停止、新しい会話、返事のコピー。返事は HTML をそのまま描画 (SVG グラフ含む)
  • Agent の選択: デザインの Agent (文字列、空 = 既定) を AIChatSendRequest.Agent で送り、AIChatService がコンストラクタで受けた対応表 Func<string, IAIChatAgent?> で解決 (アプリ側の静的クラス。Example は AI/AIChatAgentTable.cs = MailSenderTable と同型、名前ごとに Lazy で 1 つ、Service 静的プロパティに AIChatService)。無い名前は error。DI 登録なし。AIChatService.Start(owner, conv, message, agentName) (internal)
  • 通信: 既定は共通の API (AIChat.Send / Status / Cancel)。AIChatField.EndPoint を設定した専用ルートは POST {EndPoint} → 202 {requestId} → GET {EndPoint}/{requestId} をポーリング (最初の 10 秒は 1 秒、以後 2.5 秒) → {status, reply, progress, error}。DELETE で中断。やり取りする型は AIChat/ (AIChatSendRequest / AIChatSendResponse / AIChatStatusResponse / AIChatJobStatus)
  • サーバー (Extras.Server AI/Chat/。フォルダ = 役割: 直下 = 共有の契約と窓口 (IAIChatAgent / AIChatService / ChatReplyHtml)、ChatClient/ = 内部の会話エンジン (ChatClientAgent とツールセット機構、Chart ツール。すべて internal)、RawDataAccess/ = 専用 (Agent / Options / ToolSet / DbSchemaReader)、DesignKnowledge/ = 設計参照ツール (DesignDescriber = DesignData の要約、DesignKnowledgeToolSet = list_modules / describe_module / read_document)。名前空間もフォルダどおり): IAIChatAgent / AIChatService (旧 AIChatJobStore。プロセス内ジョブ。プロセスに 1 つ。対応表 Func か単一 Agent を受ける。FinishedRetention / MaxRunning は init プロパティ = Options 型は無い) / ChatReplyHtml (Markdig で Markdown→HTML、テキストはエスケープ、HTML 素通し + target=_blank) / ChatClientAgent (Microsoft.Extensions.AI IChatClient。システムプロンプト + internal な IAIChatToolSet 群、会話履歴 ConversationHistory (鍵 = 所有者+会話ID (所有者空なら会話IDのみ)。切り詰め 3 段: 古いターンのツール呼出/結果を落とす KeepToolResultsForTurns=2 → ターン数 MaxHistoryTurns=20 → 文字数 MaxHistoryCharacters=40000。いずれもターン境界で)、FunctionInvokingChatClient で往復上限、ストリーミング → ReportPartial、Markdown→HTML→ToolSet.PostProcessHtml) / RawDataAccess/RawDataAccessToolSet (get_schema = DbSchemaReader で DB 種別ごとのカタログ問い合わせ + モジュール定義の業務名、execute_sql = 1 文 SELECT 判定 Validate + DbCommand 直叩き (タイムアウト・MaxRows・MaxResultChars) + ログ) / ChatClient/ChartToolSet + SvgChart (render_chart → [[chart:N]] プレースホルダ → 後処理で <div class="aichat-chart"><svg>) / RawDataAccessAgent (ChatClientAgent を包含して委譲 + 上 2 ツールセット。継承ではない。ChatClientAgent は sealed)
  • 公開面: public は契約 (IAIChatAgent / AIChatAgentRequest / AIChatReply / AIChatReplyFormat / IAIChatProgress)、窓口 (AIChatService。★2026-09-24 AIChatJobStore + Options を 1 型に畳んだ破壊的変更)、標準 Agent (RawDataAccessAgent + RawDataAccessOptions。意味検索は semanticSearch: SemanticSearchService? で受ける。会話の設定 (プロンプト・履歴上限・逐次表示) も RawDataAccessOptions に、ILoggerFactory はコンストラクタ) だけ。ChatClientAgent / ChatClientAgentOptions / IAIChatToolSet / AIChatToolContext / RawDataAccessToolSet / ChartToolSet / SvgChart / ChatReplyHtml / ConversationHistory は internal (テストは IVT)。独自ツールの Agent はアプリが IAIChatAgent を直接実装する (ToolSet を公開しない = 実装の詳細)
  • 設計と文書: 業務の意味はデザインにある。RawDataAccessAgent(clientFactory, dbAccessorFactory, Func<DesignData?>? design, Func<IReadOnlyList<AIChatDocument>>? documents, options)。design があれば DesignKnowledgeToolSet が付き、モデルは list_modules/describe_module で業務語→表・列・候補値・リンク・スクリプトを確かめる (QueryField の SQL は .sql ファイル=実行エンジン専用のバッファなので出さない)。文書 (AIChatDocument) の出所はホスト = デザインの Resources/{DocumentFolder}/*.md (フォルダは AIChatFieldDesign.DocumentFolder → AIChatSendRequest.DocumentFolder → AIChatAgentRequest.DocumentFolder。空 = 文書なし。読み出しは本体 1.3.32 の DesignDataFileManager.GetResourceTexts(dir, folder, ".md", ".txt") (App.zip の Resources/{folder} 直下) を Example / Starter の対応表が呼ぶ)。IAIChatToolSet.GetInstructions(context) (依頼ごとに文書が変わるためプロパティから引数付きに)。Example デザインに Resources/AIChat/データの見方.md。文書は合計 8000 字以下なら全文をプロンプトに入れる (実 AI 検証: 一覧だけだと read_document を呼ばず「売上」を全件合計した→全文注入で定義どおり完了分のみになった)、超えたら抜粋 200 字 + read_document
  • 画面 URL: DesignDescriber.PageUrls がページフレームのサイドバーリンク (Module 一致 → PageFrame/ModuleUrlSegment) から /{frame}/{segment} と /{frame}/{segment}/{Id} を組む (リンク無しはルートのフレーム、フレーム無しは省略)。list_modules と describe_module に出し、プロンプトで「行を挙げるときは詳細リンクを付ける」。ChatReplyHtml.AddLinkTargets は外部 (http(s)://、//) だけ target=_blank、アプリ内相対リンクは同じタブ
  • トークン節約 (2026-09-09): get_schema は 2 段 (引数なし = 表名+列数+モジュールの目次 / tables 指定 = その表だけ table(col TYPE [説明], …) の 1 行)。方言は RawDataAccessToolSet.GetInstructions に常時 (Dialects() = DataSource 種別のみ・接続なし)。プロンプトで順序を明示: 文書 → describe_module (表と列が出る) → 足りないときだけ get_schema。列情報は全データソース分を 10 分キャッシュ (LoadSchemaAsync)。ツール結果は履歴で直近 2 ターン後に落ちる
  • 複数データソース: RawDataAccessOptions.DataSourceNames (IList)。get_schema はデータソースごとに ## データソース X + 方言、execute_sql は dataSource 引数 (1 つなら省略可)。設計の業務名は「モジュールの DataSourceName が一致するもの優先、無ければ表名で全モジュール」。意味検索 (search_records) の接続も同じ見方 (SemanticSearchToolSet.ResolveDataSource: 一覧にモジュールの DataSourceName があればそれ、無ければ別名 (どのモジュールも名指ししていない名前) のうち同じ DB 種別のもの。2026-09-30 別名構成で対象モジュール無しになる不具合の修正)
  • 方針 (ユーザー決定 2026-09-09): 権限管理はライブラリで持たず DB 側の設定 / 接続文字列 (AI 用読み取り専用ユーザー・列 GRANT・ビュー) で。基本的にデータは AI に渡してよい (Azure OpenAI は学習不使用・リージョン内)。気にする顧客 (個人情報・契約・業法) には列 GRANT / ビュー / 閉域・ローカルモデルの 3 手段。docs/AIChatField.md「AI に送られるデータ」に記載。除外列オプション等は要望が出てから ★2026-10-02 監査方針確定 (ユーザー): RawDataAccess は「AI 用 DB ユーザー (個人情報を除いた / マスクしたビュー) で範囲を固定 + 監査ログの送信の行 (AIChat.Send) で利用の事実」で監査基準に対応。行単位の閲覧記録が問われるのは個人情報系だけなので範囲から外せば対象が無くなる。SQL 文の監査ログ化・読んだ行の Id は作らない (値が証跡に残る・集計 SQL では行を特定できない)。ILoggerFactory の実行ログは事故調査用。docs/AIChatField.md「監査基準への対応」+ Extras.Designer SpecDocs/AuditLog.md「聞かれたときの答え方」(CCFD 向け)
  • DB の負荷の上限 (2026-10-06): RawDataAccessOptions の CommandTimeoutSeconds (既定 30) / MaxQuerySecondsPerReply (既定 0 = 無効。予算は QueryTimeBudget を AIChatToolContext.Items に) / MaxConcurrentQueries (既定 0 = 無効。QueryGate = データソース名+上限ごとの static SemaphoreSlim) / CancelQueryAtRowLimit (既定 true。MaxRows 超で command.Cancel()、後始末の例外は打ち切り時だけ握る) / LargeTableRows (既定 100000。DbSchemaReader.ReadTableStatsAsync = カタログの行数と索引の先頭列。読めなければ空。プロンプトに大きい表を最大 30 件・目次と表指定に行数)。0 / false で無効。待たせる・断る種類 (予算・同時実行) は既定で無効 (バージョンを上げただけのアプリの動きを変えない)。SQL の書き換え (LIMIT 被せ) と EXPLAIN はしない。ホストは appsettings AIChat:RawDataAccess を束縛して渡す。詳細は docs/AIChatField.md「DB の負荷を抑える」
  • ModuleDataAccessAgent (0.18.0・2026-10-02): 権限の効く Agent。AI/Chat/ModuleDataAccess/ = ModuleDataAccessAgent(clientFactory, Func<string userId, Task<ModuleDataAccessScope>> openScope, Func<DesignData?> design, documents, ModuleDataAccessOptions, ILoggerFactory?) + internal ModuleDataAccessToolSet (find_records / aggregate_records / cross_tab。filters は項目の比較か all / any のグループ (入れ子)・not = MultiMatchCondition の木) + ModuleDataConverter (AI の条件 → SearchCondition / AggregateCondition、ModuleData → 行 JSON、集計の鍵 → { value, text })。行は実行ユーザーの ModuleDataIO.GetListAsync で読む (UserRead / DataRead / PermissionField / 論理削除が画面と同じに効く)。ユーザー Id = AIChatAgentRequest.UserName (ownerKey)。スコープはツール呼び出しごとに開閉 (ホストは new DataService(userId) を包む)。集計は本体の集計 API (2026-10-06 差し替え): aggregate_records = AggregateCondition (ValueGroup / DateGroup (year〜hour・FiscalYearStartMonth)・AggregateMeasure (count / countDistinct / sum / avg / min / max)・Having・Sort・LimitCount = MaxGroups) → ModuleDataIO.AggregateAsync、cross_tab = CrossTabBuilder.BuildAsync(condition, rows.Length, ModuleDataIO.AggregateAsync, withTotals, MaxCrossTabCells)。読めない項目の集計は本体が拒否 (accessDenied)。並び未指定はグループの昇順 (cross_tab は行の項目だけ)。メモリ集計 (MaxAggregateRows) は廃止。負荷の上限 (同日): 読み取り 4 ツール (find_records / get_record / aggregate_records / cross_tab) は ReadAsync の外枠を通る = QueryTimeBudget (MaxQuerySecondsPerReply) → QueryGate (MaxConcurrentQueries・鍵はモジュールの DataSourceName・RawDataAccess と共有) → ModuleDataAccessScope.DbAccessor.CommandTimeoutSeconds (CommandTimeoutSeconds。core 1.3.41 の IDbAccessor.CommandTimeoutSeconds (既定実装 0/無視) + DbAccess 0.2.0 の DbAccessor 実装。ホストは Scope に ds.DbAccess を渡す) → 時間切れ/拒否を AI 向けエラーに。既定は全部 0 (無効)。get_record = Id 1 件 + 子一覧 (ListField / ModuleField。子の項目はその一覧レイアウトの項目 = 本体の SetLayoutFilter。孫は読まない)。★罠: リンクの表示名は本体が LinkFieldData.DisplayText を埋めない = リンク越しフィールド ("Customer.Name" を名前に持つ列) を一緒に読んで表示名にする (DisplayTextPath)。無い設計では値だけ。SQLite の TEXT 日付列は DateOnly→ToString() (カルチャ依存) で文字列比較になる (core の仕様。テストは DATE 型)。読んだ行の監査ログは未対応 (SuppressFlow のためジョブに AuditContext は無い)。テスト Test/AI/ModuleDataAccessToolSetDbTest (SQLite 18 本 + 実 AI 1 本 Explicit)
  • 意味検索を権限つきで (2026-10-06): core 1.3.41 の FieldSqlMatchCondition (抽象・FieldName) + ISqlMatchConditionFieldDesign (CreateWhere / CreateOrderBy) + SqlConditionContext (DataSourceType / Column / AddParameter) = フィールド型が自分への条件の SQL を作る口。本体は一覧検索の SQL に断片を入れ、モジュールの閲覧権限・DataRead・論理削除を外側で掛け、対象フィールドの読み取り権限 (PermissionField) を見る。メモリ評価 (IsMatch) は例外・設計の条件に置くと設計チェック MatchConditionsExtensions:2。Extras 0.18.0 の SemanticMatchCondition (Vector / MaxDistance) を SemanticSearchFieldDesign が実装 (pgvector <=> / SQL Server 2025 VECTOR_DISTANCE。並びは SortConditions に "Search.Value")。ベクトルは呼び出し側が作るか、Text だけ入れてサーバーの前処理に作らせる (下記)。ベクトルは SQL にパラメータで渡す (CAST(@p AS VECTOR(n)) / @p::vector。リテラルだと検索ごとに SQL 文が変わりプランを毎回作り直す)。ModuleDataAccessAgent(..., semanticSearch:) で search_records (find_records と同じ行の形・近い順・score なし)。画面の検索欄 (同日): 検索レイアウトに置くと SemanticSearchSearchComponent (文章の入力欄)。SemanticSearchField : ISearchableField が FieldMatchCondition{ SemanticMatchCondition{ Text, MaxDistance = SearchMaxDistance } } を作り、サーバーの SemanticSearchService.Interceptor (0.22 で ConditionInterceptor から改名・旧名は Obsolete の別名。core 1.3.41 の IModuleDataIOInterceptor.PrepareConditionAsync。ホストの CustomizedModuleDataIO で AddInterceptor) が Text → Vector (権限の判定の後に呼ばれる = 拒否される要求では埋め込み API を呼ばない)。並びは一覧の SortConditions に "Search.Value" を入れる (条件が無いと本体が飛ばす)。サーバーで並びを差し込むことはしない。SearchMaxDistance が空で、その検索レイアウトを使う一覧 (ページの一覧・SearchField の結果・LinkField の検索ダイアログ) の並びに無いと文章が効かないので設計チェック SemanticSearchFieldDesign:3。実 pgvector (Azure PG の Selenium 用 DB) と Azure SQL (VECTOR 型) で生 SQL 版と同じ行・同じ順、距離の上限つきの一覧と集計が同じ行を数えることを確認 (SemanticSearchVectorDbTest)
  • 権限の考え方: RawDataAccess は DB を生で読む。見える範囲 = RawDataAccessOptions.DataSourceNames の接続 (AI 用の読み取り専用 DB ユーザーを GRANT で絞る) で決める。ログインユーザーごとの行制限は効かないので、置くページの UserReadCondition で使える人を絞る。SELECT 判定は補助
  • IChatClient の作り方: Azure OpenAI は Extras.Server AI/AzureOpenAIClients.cs (ChatClientFactory(AISettings)。埋め込みはここではなく IEmbeddingProvider へ (2026-09-24)。設定が欠けていれば null。AzureOpenAIClient は 1 つを使い回し・ApiKeyCredential) が提供 (2026-09-23。それまでは Example/Starter の AIChatAgentTable.CreateAzureOpenAI がホスト側で作っていた)。Agent / AITextAnalyzeService / SemanticSearchService 自体は Microsoft.Extensions.AI の抽象しか受け取らないので、別プロバイダはホストが同じ型を作って渡す
  • 結線: 共通の API の AIChatApi(AIChatService) (テンプレは ExtrasApiTable に AIChatAgentTable.Service)。デスクトップは AIChatField.SendCoreAsync フック。専用ルートは AIChatField.EndPoint (互換)。Example の appsettings AIChat:RawDataAccessDataSource (既定 SampleSQLite、SystemConfig.AIChat)。テスト環境: gitignore 済 appsettings.AIChatTest.json (キー入り) + プロファイル「Extras.Server (AIChatTest)」https://localhost:7469
  • 会話の保持 (0.11.0): 会話は AIChatConversation (発言・会話 ID・ポーリング) として AIChatConversationStore (DI スコープの Services に ConditionalWeakTable で紐づく = ホスト登録なしでスコープと同じ寿命。鍵 {Module}:{Field}。静的なのは弱参照の索引だけ。★これが正式な形。Extras クライアントはホストに DI 登録を求めない = 登録必須化は既存ホストを壊す破壊的変更なのでやらない) に置き、AIChatField は表示だけの薄い層 (Changed/ReplyReceived の委譲、コンポーネント Dispose で DetachAsync)。ポーリングは会話側で続くので離脱中に返事が届く。sessionStorage は使わない (リロードで消えるのは意図)。KeepConversation=false なら保管庫に置かず離脱で中断。送信時の写し (AIChatSendRequest.Transcript: テキストのみ・直近 6 往復・4000 字) はサーバー履歴が消えたときの文脈復元用で、ChatClientAgent は履歴が無いときだけ ConversationHistory.Seed
  • 複数インスタンス: ジョブと会話履歴はプロセス内 → セッション固定 (ARR アフィニティ) 前提。必要になれば DB テーブルの共有ストアに差し替える (本体の一時ファイル表方式)
  • ローカライズ: 画面に出る文言 (進捗・未登録エラー・グラフの「データなし」) は Extras.Server の Properties/Resources(.ja-JP).resx。AIChatService / SemanticSearchService が Start 時のカルチャをバックグラウンドへ引き継ぐ。モデル向けのプロンプトとツール説明は日本語の定数 (ローカライズ対象外)。リクエストのカルチャはホストの AddLowCodeLocalization() (Extras.Server Web/LocalizationExtensions。Accept-Language で ja-JP / 中立) + UseRequestLocalization() で決まる
  • テスト: Test/AI/ (AIChatServiceTest = ジョブ + 対応表の振り分け / ChatClientAgent は台本つき FakeChatClient / RawDataAccessToolSet は SQLite 実 DB / SvgChart)
  • インターフェース: IFillHeightFieldDesign (Height=0 で親の高さに合わせる)
  • ファイル: Designs/AIChatFieldDesign.cs, Fields/AIChatField.cs, Components/AIChatFieldComponent.razor(.css), wwwroot/aichat-interop.js, AIChat/*.cs
  • 意味検索 (0.14.0): コンストラクタ末尾の Func<IEmbeddingProvider>? embeddingProvider を渡し、design に SemanticSearchField (3 列あり・PostgreSQL / SQL Server) のモジュールがあれば SemanticSearch/SemanticSearchToolSet (internal) が付き search_records(moduleName, query, top) が使える (下の SemanticSearchField 参照)

SemanticSearchField - 意味検索 (AI / RAG)

  • 状態: 実装済み (docs/SemanticSearchField.md, Extras.Designer FieldDocs/SemanticSearchFieldDesign.md)。Example にはホスト側の結線だけ (AI/SemanticSearchIndex.cs = SemanticSearchService の静的な持ち物 (Service。プロバイダは Lazy で EmbeddingProviderTable から) + AI/EmbeddingProviderTable.cs / CustomizedModuleDataIO / AIChatAgentTable)。再索引 API は 共通の API の SemanticSearchApi。Example のデータは SQLite なので意味検索できるモジュールは置いていない (Inquiry は通常モジュール)
  • 形: 書き込み専用列 2 本 (DbColumnText 文章 / DbColumnVector = [0.1,-0.2,…] JSON 配列テキスト) + DB のベクトル型列 DbColumnVectorSearch (PG は search_vector::vector の生成列、SQL Server 2025 は VECTOR 型列にテキストから暗黙変換で書けるので DbColumnVector と同名)。3 列すべて必須 (HasColumns)。UI なし (PasswordHashField と同型。FieldDesignBase 直下)。本体 (core) は触らない
  • ★検索は DB 側だけ (2026-09-23 決定): サーバーでの全行メモリ比較 (コサイン類似度) は廃止。SemanticSearchIndexReader.SearchAsync (BuildSearchSql: PG <=>+limit / SQL Server VECTOR_DISTANCE('cosine')+top、semantic_score = 1-距離、論理削除は cast(col as integer)=0) のみ。SemanticSearchVector.SupportsDbSearch(type) = PostgreSQL / SQLServer。それ以外の DB のモジュールは SemanticSearchToolSet.SearchableModules (データソース定義の種別で判定・接続しない) から外れ、AI への説明にも出ない。DB 検索の失敗は AI にエラーとして返す (フォールバック無し)。SemanticSearchVector (Extras 本体・internal) が保存形式 (Encode) と DB の書き方 (SupportsDbSearch / FromText / Literal / CosineDistance) を 1 か所で持つ (生 SQL の search_records・{embed:} と、SemanticSearchFieldDesign の権限つき検索の両方が使う。2026-10-06 に方言の重複を解消)
  • execute_sql の {embed:…}: RawDataAccessToolSet.SqlPreprocessor (Func<ds, sql, ct, Task>) を RawDataAccessAgent が SemanticSearchToolSet.ExpandEmbeddingsAsync に結線。SELECT 判定より前に置換 (数値リテラルだけになる)。非対応 DS は例外→AI にエラー。ログは展開前の SQL。AI への説明は GetInstructions が意味検索できるモジュールごとに表・ベクトル列・方言ヒント (DialectHint) を列挙
  • 文章: SemanticSearch/SemanticSearchText.Build (Extras。作るのはサーバーだけ) が「表示名: 値」を LF 区切りで 1 行ずつ。SourceFields 空 = DefaultSourceFields (DB 列ある入力フィールド全部。Id / 論理削除 / 楽観ロック / Password / 作成更新記録 / 自身は除く)。候補値は表示名、リンクは DisplayText、日付 ISO、RichText はタグ除去。MaxTextLength 既定 8000
  • 添付の中身の索引 (0.22.0): SourceFields に名前を書いた FileField だけ (既定の対象には入らない)。文章を作るのは Server の SemanticSearchService.BuildTextAsync 1 か所 (Interceptor と Reindex が呼ぶ。添付を読んで SemanticSearchText.Build(..., attachmentTexts) に渡す)。ストレージの添付は FileStorages (Func・既定 null = ファイル名だけ) があるときだけ、DB 列格納モードは行の Content。取り出しは FileManagement/FileTextExtractors (拡張子 → IFileTextExtractor。IEmbeddingProvider / IMailSender / IFileStorage と同じインターフェイス + 対応表の形。Add は実装の拡張子を全部登録し後勝ち・Remove は拡張子 1 つ・Find で引く。既定の実装は FileManagement/TextExtractors/ に 1 クラスずつ: Pdf=PdfPig / Word・PowerPoint=Open XML SDK / Excel=ClosedXML キャッシュ値 / PlainText (BOM → UTF-8 厳密 → Shift_JIS) / Html タグ除去)。拡張子で先に判定して対応表に無ければ開かない。例外は警告ログで保存は止めない。1 件 MaxFileTextLength (既定 100000・0 = 無制限) で切る。テスト: AI/FileTextExtractorsTest / SemanticSearchAttachmentDbTest
  • ★索引はサーバーが保存した行から作る (2026-10-12 ユーザー決定「仕様をシンプルに」・2026-10-13 13 回目で「変更したレコード」規則に置換): SemanticSearchService.Interceptor (SemanticSearchInterceptor。旧 ConditionInterceptor は Obsolete の別名) の SubmitAsync。★作り直す行の決め方は送信の形を読まず、本体 core 1.3.50 の SubmitContext.ChangedRecords (インターセプタの SubmitAsync の引数 context。この保存で変更したレコード = 本体の書き込み合流点で記録。追加は採番後の Id・内部書き込みも含む・DB 採番の一括 INSERT は含まない) で決める。規則 2 つ: (1) Add / Undelete の行は全部、Update の行は SourceFields の項目を書いたフィールドだけ。IsBulkInsert の行 (一括 INSERT の手入力 Id) は除く = 取込は Id の付け方によらず索引を付けない (14 回目。2026-10-15 に文章も付けないに変更) (2) SourceFields の従属宣言 (IOwnedRecordsFieldDesign.GetOwnedRecords の子モジュール = タグ付け行) のモジュールの行を Add/Update/Delete/Undelete したら、その親の行 (17 回目: IsBulkInsert の行は (1) と同じく除く = タグ付けモジュールの一括取込の親は Reindex) (★14 回目: 親は宣言の束縛 (条件の FieldVariableMatchCondition: 子の項目 = 親の項目) から子の行の値で引く。親の項目が Id ならそのまま・違えば親を検索。消す子 (送信の Delete / SearchDelete) と更新する子 (送信の Update。16 回目: 付け替え前の親も作り直す) は next() の前に読んで親を控える。送信の主レコードで決めていた 13 回目の形は一覧の中の行 (入れ子) のタグだけ変えた保存を取りこぼしていた)。経緯: 6〜12 回目は送信の形 (Add/Update に索引データが無い行・対象フィールドを送った更新・仮 Id の付与と TemporaryIdMap・@replace: の解析・ExtendedData…) を読み解いていて、形が増えるたびに分岐が増え収束しなかった (13 回目で編集履歴の復活 = Add/Update 空の送信、索引データだけの更新 を取りこぼし) → 事実 (何が書かれたか) で決める形に置換。保存の前に送信の全行からこのフィールドのデータを捨てる (索引の列はサーバーだけが書く)。next() の後に io.CreateSystemAccess() の GetWithOwnedRecordsAsync で読み直し (従属レコードは SourceFields にある宣言だけ・論理削除した行は読めない = 索引しない)、フィールドごとにまとめて埋め込み (EmbedForSaveAsync。失敗は警告ログ + 文章だけ)、同じ口の WriteWithoutSideEffectsAsync (書き込み専用列だけの UPDATE = 更新日時・版・ExecuteSql・ホストの override を通らない) で書く。索引列の書き込み失敗は保存の失敗。★一括 INSERT の行には索引を付けない (2026-10-15 ユーザー決定。文章も付けない = ホストの BulkAddAsync の結線は不要。取込の後は ReindexMissing() = 行から文章を作り (リンク先の表示名も入る) 埋め込む。説明は「保存すればその行の索引ができる。一括取込と穴埋めは ReindexMissing()、全部作り直すのは Reindex()」の 2 文)。★インターセプタで next() の前に文章を入れる案は却下 = フィールドの書き込み権限チェックの前に索引データが入り、このフィールドに PermissionField の書き込み条件 (再索引を絞る推奨構成) を付けた人の追加が拒否される。クライアント (Fields/SemanticSearchField) は IsModified = false・GetSubmitData 空 = 何も送らない (Text プロパティ・IDataDependentField・設計チェック :2 は削除)。ホスト結線は AddInterceptor(Service.Interceptor) (★他のインターセプタより後 = 最後に登録 = 一番内側。理由は「索引列の書き込み失敗 (= 保存の失敗) が外側の監査ログ・編集履歴の next() を通って上がる」こと。17 回目で訂正: ChangedRecords は SubmitAsync の入口で消して保存中ずっと積むので、順序を入れ替えても復活の行は拾える (外側に置けば拾える範囲はむしろ広い)。内側なので外側が next() の後に書いた行には付かない。★18 回目: 索引付けの失敗は例外で投げず結果の ExceptionMessage で返す (SubmitAsync の自分の処理 (保存前の親の控え・保存後の索引付け) を try で包み Fail へ。next() の中は包まない。権限拒否だけ型のまま上げる)。例外のままだと本体が最外で ExceptionMessage にして HTTP 200 で返すので、外側の監査ログ (AuditContext.RecordSubmitAsync。権限拒否しか catch していなかった) には保存が巻き戻ったのに Success で残っていた。併せて RecordSubmitAsync も権限拒否以外の例外を Fail にして投げ直す (今後の内側インターセプタにも効く)。保存の前の読みは送信の Id のまま (Id 手打ち変更の行は読めずに飛ぶ = 子の Id 手打ち変更と親の付け替えを同じ保存でやると旧親は作り直さない割り切り。Extras で @replace: を解析しない)。親の束縛は宣言の FieldVariable 条件すべて (AND)) だけ (Obsolete の ApplyAsync(data, bool) は何もしない。Interceptor 未登録なら警告ログを 1 回出すだけ。docs に「0.21 以前からの移行」節。14 回目)。再索引は ModuleDataIO.CreateSystemAccess() の GetListAsync (Id だけ) + 1 行ずつ ReadForIndexAsync + WriteWithoutSideEffectsAsync。文章の列は全文・MaxTextLength は埋め込みに渡す長さだけ (13 回目。キーワード検索は全文に当たる・添付の中身の構想と同じ線)
  • サーバー (Extras.Server AI/SemanticSearch/): ★2026-09-24 ホスト向け公開型は SemanticSearchService 1 つ (旧 SemanticSearchIndexer + SemanticSearchReindexJobStore + Options を畳んだ。ctor (Func<IEmbeddingProvider?> embeddingProvider, Func<DesignData> design, ILoggerFactory?)、init プロパティ FinishedRetention / MaxRunning / ReindexPageSize / SaveEmbeddingTimeout。SemanticSearchReindexScope だけ別型で残る。ユーザー指示「AIChat も破壊的変更でいい・両方やって」)。一括 INSERT 用の ApplyAsync(datas) は 2026-10-15 に削除 (未公開。一括の行は索引の列が空のまま・取込後に ReindexMissing。bool 付き旧形式は Obsolete で何もしない)。再索引の入口は FieldApiAuthorization.CheckWriteAsync (core 1.3.50 の ModuleDataIO.AuthorizationChecker.CheckFieldUserWriteAsync = UserWriteCondition + フィールドの書き込み権限。10 回目: 読める人全員が全行の埋め込みを起こせていた。12 回目: UserWriteCondition も PermissionField も無い既定ではアプリに入れる全員が起こせるので docs は「絞るならこのフィールドに PermissionField の書き込み条件」と案内)。索引列の書き込み失敗は保存の失敗 (11 回目: best-effort にしたら実 PG で保存が黙って消えた = PG はトランザクション内の 1 文の失敗で以後が全部失敗し COMMIT が ROLLBACK になる。握れるのは埋め込みの失敗だけ)。送信に載ったこのフィールドのデータは保存の前に捨てる (索引の列はサーバーだけが書く)。再索引の合流は同じ種類のときだけ (欠損だけが走る間の全行は SemanticSearch_ReindexMissingRunning)。LoggerFactory プロパティ (ホストは Program.cs で app.Services から設定。静的に作るので ctor では渡せない)。IndexReader の表名はスキーマ付きを . で分けて引用。保存時の埋め込みは SaveEmbeddingTimeout (既定 15 秒) で打ち切り、タイムアウト・プロバイダの中断例外も「文章だけ保存 + 警告」(8 回目レビュー: 以前は中断例外を投げ直して保存が失敗していた)。保存時の索引付けは Interceptor (上記)。埋め込み失敗 / 未設定は Vector null + 警告ログ。索引付けは DB を選ばない。★16 回目: ReadForIndexAsync はリンク・モジュール連携 SelectField の表示名を FillLinkDisplayTextsAsync で埋める (内部読み GetWithOwnedRecordsAsync は DisplayText を埋めない = 0.20 のクライアント生成では入っていた得意先名が 0.22 の保存時索引・再索引で抜けていた。画面の LinkField と同じくリンク先を ValueVariable = 値で引く。権限は通さない)。internal ReindexAsync(moduleDataIO, db, moduleName, missingOnly, progress, pageSize, ct) = moduleDataIO.CreateSystemAccess() (メソッドの先頭で 1 回作る) の GetListAsync (権限を通さない内部読み = 誰が起こしても同じ文章。8 回目レビュー) でページ読み→ページ分の文章を 1 回でまとめて埋め込み→同じ口の WriteWithoutSideEffectsAsync (書き込み専用列だけ。false = 消えた行は飛ばす。論理削除行は出ない。埋め込み失敗は例外)。missingOnly は SemanticSearchIndexReader.ReadIndexedIdsAsync (ベクトル列 not null の Id を生 SELECT) で索引済みを飛ばす。★2026-10-15: missingOnly はベクトルの無い行の文章をそのまま埋め込む (行を読み直さない・リンクを引き直さない = 保存時の埋め込み失敗の穴埋めが速い)。文章の無い行 (一括取込) だけ行から作る。ReadUnembeddedIdsAsync (先にベクトル無し・文章ありの Id を DB の値の型のまま控える) + ReadUnembeddedTextsAsync (ページごとに書く直前にその Id で文章を IN で読む = 全行の文章をメモリに持たない・再索引中に保存されてベクトルが付いた行は行から作る)。ベクトルのあるフィールドは触らない。プロバイダ無しなら文章のある行には何もしない。全行の Reindex は今までどおり行から作り直す。SemanticSearchIndexReader = 書き込み専用列を IDbAccessor の生 SELECT で読む (LoginAccountStore と同じ割り切り)
  • ★再索引はフィールドのスクリプト (2026-09-24 ユーザー決定「ボタンのモードよりフィールドのスクリプト。権限制御もできる」): Fields/SemanticSearchField に Reindex() / ReindexMissing() / CancelReindex() と IsReindexing / ReindexProcessed / ReindexTotal / ReindexError、デザインに OnReindexCompleted (ScriptEvent・引数なし・CheckFieldFunctionExistence)。クライアントは AIChatField と同じ形: 共通の API の Reindex / Status / Cancel (専用ルートは静的 EndPoint = 互換) + ホストフック ReindexCoreAsync、開始→requestId→Status ポーリング (1 秒→2.5 秒・最大 2 時間)→Module.ExecuteScriptAsync(OnReindexCompleted)。DTO は SemanticSearch/SemanticSearchReindex{Request,Response,StatusResponse} (Status は AIChatJobStatus の値を共用)。サーバーは SemanticSearchService の StartReindexAsync(owner, request, authorizeWith, openScope) / GetReindexStatus / CancelReindex (AIChatService と同形。= FieldApiAuthorization で入口検査→Task.Run。同じモジュールの走行中ジョブには合流 (Watchers)。SemanticSearchReindexScope(ModuleDataIO, IDbAccessor, owner) = バックグラウンド用に host が開く実行ユーザーの ModuleDataIO+DB。Example は DataService(string userId) で HttpContext 無しでもユーザー固定)。ホスト結線 = ExtrasApiTable の SemanticSearchApi(SemanticSearchTable.Service, userId => new SemanticSearchReindexScope(...)) (Example は SemanticSearchIndex.Service)。テスト = Test/AI/SemanticSearchReindexTest (SQLite・まとめ埋め込みの回数 / missingOnly / 進捗 / 合流と中断 / 入口検査)
  • ★埋め込みは IEmbeddingProvider (2026-09-24 ユーザー決定「メールみたいに切り替えたい・固定ライブラリに引っ張られるな」): Extras.Server AI/Embedding/ に IEmbeddingProvider { ModelId; Dimensions; EmbedAsync(texts) } と、プロバイダ別の設定+実装 AzureOpenAIEmbeddingSettings/Provider (Azure SDK) / EmbeddingGeneratorProvider (M.E.AI IEmbeddingGenerator のアダプタ)。★OpenAI / Ollama の実装は書いたが実 API 未検証のためユーザー指示で削除 (2026-09-24「一旦 AzureOpenAI だけでいい」)。必要になれば OpenAIEmbeddingCalls を共有して OpenAI、HttpClient で /api/embed を叩いて Ollama を足す。OpenAI 系は OpenAIEmbeddingCalls で 2048 件ずつ・Index で対応づけ・Dimensions>0 なら縮小指定。SemanticSearchService は Func<IEmbeddingProvider?> を取り (RawDataAccessAgent はプロバイダを直接受けず SemanticSearchService を受けて GetProvider を SemanticSearchToolSet に渡す。null なら search_records は出ない)、Dimensions 申告と返ったベクトル長の不一致は例外 (CheckDimensions)。ホストは対応表 (Starter は SemanticSearchTable.CreateEmbeddingProvider、Example は EmbeddingProviderTable。メールの MailSenderTable と同形) を appsettings の SemanticSearch.EmbeddingProvider の呼び名で引く (Example: SystemConfig.SemanticSearch / AzureOpenAIEmbedding セクション。AISettings と同じく appsettings.Development.json 側)。AISettings.EmbeddingModel と AzureOpenAIClients.EmbeddingGeneratorFactory は削除。テスト = Test/AI/EmbeddingProviderTest (設定必須・アダプタ) + AzureOpenAIEmbeddingProviderRealTest ([Explicit]・次元縮小 256 も確認)。FakeEmbeddingGenerator → FakeEmbeddingProvider
  • キーワード検索 (0.21.0・2026-10-11 ユーザー決定「全文検索フィールドは作らない。原文を持っているのだからオプションで LIKE」): SemanticMatchCondition.Keywords (空白区切り) = 文章の列 (DbColumnText) への語ごとの like @p escape @e の AND (SemanticSearchFieldDesign.CreateWhere。ベクトルと両方あれば AND・キーワードだけなら CreateOrderBy は null)。語の切り方は SemanticSearch/SemanticSearchKeywords (internal)、語ごとの LIKE は本体の SqlConditionContext.Like (core 1.3.49。パターンとエスケープ文字をパラメータで渡す・SQL Server は [ も文字扱い = 本体の Like と同じ規則)。デザイン KeywordSearch (bool。★2026-10-12 一度 enum SearchBoxes (KeywordOnly = 文章の列だけの構成) にしたが「ややこしすぎる」で bool に戻し、列は常に 3 つ必須) = 検索レイアウトにキーワード欄も出す (SemanticSearchSearchComponent。キーワード欄は data-role="semantic-search-keyword")。SemanticSearchField.KeywordText / SetKeywordTextAsync。GetMatchCondition は文章とキーワードを 1 つの SemanticMatchCondition に (文章が無ければ MaxDistance も付けない = 埋め込みを呼ばせない。Interceptor の前処理は Text が空なら触らない)。AI: ModuleDataAccessToolSet.ToCondition が SemanticSearchFieldDesign への Like を Keywords にする (他の比較はエラー)。search_records の条件の順は問わない (core 1.3.49 は同じ項目の条件のうち並べる式を返した最初のもので並べる)。★権限の仕様は 2 文だけ (2026-10-12 ユーザー決定「権限の仕様がややこしすぎる。スパッと説明して納得あって十分に使える仕様」): 検索できるかはこのフィールド自身の読み取り権限 (他のフィールドと同じ)。文章の列には SourceFields の中身がそのまま入るので、読ませたくない項目は SourceFields に入れない (入れるならこのフィールドにも同じ読み取り条件)。設計チェックは SemanticSearchFieldDesign:4 = 対象フィールドに PermissionField の読み取り条件があるのにこのフィールドに無い (文言「{0} を SourceFields から外すか、{1} にも読み取り条件を付けてください (外したら Reindex())」) と :5 (10 回目で追加) = 対象フィールドの読み取り条件が行の値で決まる (このフィールドの条件では代わりにならない → 外して Reindex())。条件の厳しさは見ない = 設計者の責任。「条件あり」は葉のあるものだけ (本体の IsEmpty と同じ)。Targets の除外モードは本体と同じく Id / 楽観ロックを対象外。やめたもの: リンク先・タグ付けモジュールの UserReadCondition の指摘 (:5。LinkField を読める人には表示名が見えているので LinkField 自体の読み取り条件で足りる = ISemanticSearchTextSource.GetSemanticTextModuleNames / SemanticSearchText.ReferencedModuleNames も削除)、「同じかそれより厳しい条件」「制限の違う項目が複数なら外せ」の説明。本体 PermissionFieldDesign:4 (行データを参照する読み取り条件を付けると検索は拒否される。ExecuteSql 向け :3 と同じ型だが向きは逆 = :3 は素通し・検索は拒否。6 回目レビューで「適用されない」の文言を直した) は本体の一般則として残す。権限の 2 文目は「同じ読み取り条件 (CurrentUser で決まるもの)。行の値で決まる条件の項目は SourceFields から外す」。ランタイムの権限は意味検索のときから変えていない (検索条件の権限判定は静的 AuthorizationControl.GetFieldPermissionsStatic)。GetInstructions / DesignDescriber に案内。RawDataAccess (SemanticSearchToolSet.GetInstructions) には「語そのものは文章の列に LIKE」を列名つきで案内 (2026-10-11 ユーザー「足しといて」。IndexedModules = 3 列ありで接続が引けるモジュール全部に出す = プロバイダ無し・対応しない DB でも。search_records / {embed:} は SearchableModules = その部分集合のときだけ)。正規化 (NFKC 等) はしない = 一致は列の照合順序次第。テスト: SemanticSearchKeywordsTest / SemanticSearchKeywordDbTest (SQLite 実検索) / ConditionTest / ModuleDataAccessToolSetDbTest
  • デザインチェック: SemanticSearchFieldDesign:1 = 3 列必須。:2 は欠番 (対象の一部だけ読み込む詳細レイアウトの指摘。サーバーが保存した行から作るようになり不要)。:3 = 検索欄が一覧に効かない構成 (KeywordSearch が true なら出さない = キーワードだけで使う構成に直す手が無い。12 回目)。:4 / :5 = 上記の権限 (:5 は同じ規則がこのフィールドも縛るなら対象外 = 本体 :4 が拒否で守る)。列存在 / SourceFields 存在もチェック。DB 種別 (対応 DB か) は DesignCheckContext に無いのでチェックしない (実行時に対象外になるだけ)
  • テスト: Test/AI/ (FakeEmbeddingProvider = 2-gram ハッシュ 128 次元 / SemanticSearchTextTest / SemanticSearchIndexingDbTest (SQLite・保存後の行からの索引付け (読めない人の更新も含む)・一括 INSERT は空のまま・旧 ApplyAsync は何もしない・デザインチェック) / SemanticSearchToolSetTest (接続せず判定と説明) / SemanticSearchDbSearchTest (SQL 文字列) / SemanticSearchVectorDbTest は [Explicit]・実 DB (FakeEmbedding で 保存→再索引→search_records→{embed:} SQL 実行。環境変数 SEMANTIC_SEARCH_PG_CONNECTION = pgvector 済み PG / SEMANTIC_SEARCH_MSSQL_CONNECTION = SQL Server 2025。2026-09-23 に Docker の pgvector/pgvector:pg16 と mcr.microsoft.com/mssql/server:2025-latest で両方緑 = 生成列 ::vector / VECTOR_DISTANCE / cast(is_deleted as integer) の構文確認済) / SemanticSearchRealAITest は [Explicit]・実 Azure OpenAI + pgvector PG (環境変数 AZURE_OPENAI_* と SEMANTIC_SEARCH_PG_CONNECTION))
  • ファイル: Designs/SemanticSearchFieldDesign.cs, Data/SemanticSearchFieldData.cs, Fields/SemanticSearchField.cs, Components/SemanticSearchFieldComponent.razor (空), SemanticSearch/SemanticSearchText.cs, Server AI/SemanticSearch/SemanticSearchService.cs / SemanticSearchInterceptor.cs

EditHistoryField / EditHistoryContractField / EditHistoryUndeleteButtonField - 編集履歴

  • 状態: 実装済み (docs/EditHistory.md, Extras.Designer FieldDocs/EditHistoryFieldDesign.md + EditHistoryContractFieldDesign.md + EditHistoryUndeleteButtonFieldDesign.md)。Example とテンプレート (Starter 4 ホスト) の CustomizedModuleDataIO に結線済み。仕様の正本は docs/EditHistory.md (この節は実装の地図と、決めた理由)
  • 位置づけ: 利用者向けの変更履歴で、監査ログではない (監査ログ = 失敗や拒否・閲覧・ログインも含む記録は別機能として作る。未着手)。受け取った送信をそのまま残す役割 (Command) は一度入れて削除した (差分で足りる・スナップショットと二重)
  • 構図: 承認フローと同じ契約フィールド方式。対象モジュール側 = EditHistoryField (「履歴を取る」マーカー + 版一覧 UI。HistoryModuleName / LayoutName / PageSize / ExcludedOwnedRecords / IndividuallyRecordedOwnedRecords)、履歴モジュール側 = EditHistoryContractField (役割 ModuleName / DataId / ChangeType / Snapshot 必須、UserId / DateTime 任意) と EditHistoryUndeleteButtonField (削除の版からの復活)。EditHistoryChangeType は [DesignEnum] (Add / Update / Delete / Restore。メンバー名 = DB 保存値なので変えない)。履歴行から対象レコードを開くのは本体の AnchorTagField (ModuleVariable / IdVariable) = 専用フィールドは作らない
  • 本体 (core 1.3.37) との分担: 本体は部品、何をどう記録・表示するかは Extras。Extras はフィールドの型を見ない (一覧・埋め込み・Gantt を型で分岐しない。従属レコードの仕組みは本体の interface)
    • 従属レコードの宣言: IOwnedRecordsFieldDesign.GetOwnedRecords(IModuleDesigns modules) (core 1.3.45 から全デザインを受ける。結び付きを相手のモジュールの契約から組み立てるフィールドのため) → OwnedRecordsDesign{Name, Condition, HoldsAllRecords, References, IsReferencedByOwner, DeleteTogether}。DeleteTogether (core 1.3.55) = 親の削除で本体が一緒に消す宣言 (一覧の DeleteTogether と同じ経路。TagField が使う)。References = 行の項目のうち他の宣言の行を Id で指すもの (Gantt の依存関係 → タスク)。IsReferencedByOwner = 親が行を参照する宣言 (埋め込み。宣言が明示する。条件の形から推測しない)。本体の ListFieldDesignBase (DeleteTogether か CanCreate / CanUpdate / CanDelete のどれか = 親の保存に乗る一覧) と ModuleFieldDesign (DbColumn と ModuleName があるとき) が実装。Extras の Gantt (タスク + フィールド名:Dependencies) / Calendar / TaskBoard / MarkerList も実装
    • 従属レコードのデータ: IOwnedRecordsData (GetOwnedRows / SetOwnedRows / RemoveOwnedRows。ListFieldData と ModuleFieldData が実装) + ModuleData.GetOwnedRows(name)。埋め込みモジュールの版の形は ModuleFieldData (Id + Data に子の内容)
    • 従属レコードのランタイム: IOwnedRecordsField = 行の出し入れと表示だけの 6 メンバー (AllowLoad / LoadOwnedRecordsAsync (全部の行を返す) / AddOwnedRecordAsync(name, id) / RemoveOwnedRecordAsync / RefreshOwnedRecordsAsync / ShowOwnedRecordsAsync(name, IReadOnlyList<OwnedRecordRow>))。足した行の内容は連動なし (SetDataWithoutInteractionAsync。行ごとのリンク先の問い合わせを起こさない = 本体 InsertRowsAsync と同じ考え)、今ある行は連動あり。本体の ListField と ModuleField、Extras の 4 フィールドが明示的実装で持つ (フィールドの公開メンバー・スクリプトには出ない)。★差し替えの手順 (行 Id の突き合わせ・内容の反映・論理削除の行の Id を保った復活・余った行の削除・孫への再帰・References の付け替え・指される宣言を先に処理) は本体 Module.ApplyRecordAsync の 1 か所 (2026-09-29 に各フィールドの実装から移した。理由 = 同じ手順が 3 か所にあり、Gantt の依存関係はタスクの差し替えが先に済んでいる前提という隠れた順序があった)。表示専用のモジュールは本体 FieldBase.CreateModuleForShowAsync(data, layoutName, toRows) (AllowLoad を止める → 内容を入れる → 従属を見せる、を 1 回で)
    • IO の拡張点: IModuleDataIOInterceptor (SubmitAsync(io, context, transactionData, next) = 保存を包む / GetListAsync = 読み出しの後処理。第 1 引数は ModuleDataIO io) と ModuleDataIOSystemAccess (アプリ自身 (システム) の権限で読む・書く操作 = ユーザーの認可を通さない: GetWithOwnedRecordsAsync / GetListAsync / AddAsync / UpdateAsync / WriteWithoutSideEffectsAsync)。システムの操作は io.CreateSystemAccess() (サーバーの機能は ModuleDataIO.CreateSystemAccess()) で作る。保存の状態 (TransactionId / ChangedRecords / KeepFilesOfDeletedRecords) は SubmitAsync の引数の SubmitContext (保存の外には無い)。読む人の権限に落とす RestrictToReadableAsync・取り消しの権限 CanUndeleteAsync・現在のユーザー・アプリアクセス・フィールドの入口検査 (GetCurrentUserAsync / CheckAppAccessAsync / CheckFieldUserRead(Write)Async) は io.AuthorizationChecker、ユーザーの権限で行う取り消し (UndeleteAsync / UndeleteTogetherAsync) は io の public (core 1.3.50)
    • 判定の部品: ModuleDesign.UsesLogicalDelete() / HasUserReadPermission(services) / CanUndeleteByUser(services)、IdFieldData.IsTemporaryId、OwnedRecordsExtensions.IsSystemOrLinkedField
  • 結線: ホストは AddInterceptor(new EditHistoryRecorder(designData)) の 1 行 (logError は任意)。保存 (記録・復活) と読み出し (Snapshot を読む人の権限に落とす) を 1 つの実装が受けるので、片方だけ結線した状態が存在しない。ホストに追加のメソッドは要らない
  • 記録 (Extras.Server EditHistory/EditHistoryRecorder): 流れは「履歴モジュールへの書き込みの拒否 → 削除の取り消し (EditHistoryUndeleter) → 計画 (Plan) を立てる → next() = 本体の保存 → 保存後の内容を内部読みして履歴モジュールへ内部 add」。ルート 1 件 = 履歴 1 行
    • 削除は next() の前に削除前の内容を読む。作成・更新は next() の後に結果の DestinationId で読む
    • 内部読み = 操作ユーザーの権限に関係なく全列・全従属 (書いた人の権限で版が欠けると、権限の弱い人の版が列を欠いて記録され、復元でその列を空にしてしまう)。EditHistorySnapshot.FillNulls が NULL の列を null 値で埋める (本体の読み出しは NULL の列を入れないので、無いと「その版では空だった」が残らない)
    • 履歴対象モジュールの投入は NoTemporaryIdResolution = false にして本体の一括 INSERT 経路を使わせない (採番 Id が返らないため)。Id 空の新規行 (一括取込) は AssignTemporaryIdToRootAdd で仮 Id を付けて採番 Id を得る。作成 / 更新の判定は Add 一覧にルートがあるか (IsRootAdd)
    • 版にしない送信: 何も保存しない送信 (IsEmpty) / 含めない従属レコードの行だけの送信 (OnlyNotIncludedRows) / ExecuteSqlField の Standalone で前後のスナップショットが同じ (IsStandalone + Plan.Before)
    • 保存後の Id が取れない・保存後のレコードが読めないときは版を作らず保存は成功させ、logError に出すだけ (仕様として割り切り。テンプレートは logError を渡していないので無音)
    • 履歴の書き込みの失敗・設計不備 (履歴モジュール / 契約なし) は結果の ExceptionMessage = ロールバック
    • Id を変えた更新は EditHistoryUndeleter.RelinkVersionsAsync で旧 Id の版を新 Id に付け替え
    • 変更日時は履歴モジュールの DateTime 役割フィールドの SaveAsUtc に従う (本体の CreatedAt と同じ。サーバーローカル固定にしない)。ミリ秒まで
    • 版番号は保存しない (記録側が採番すると、履歴を読めないユーザーの保存で狂う)
    • 添付: next() の前に context.KeepFilesOfDeletedRecords(submitData) (履歴を持つモジュールのレコードに触る送信)。本体の FileFieldDataIO が削除・差し替えで外れる添付を一時領域へ送らない
  • 読み出し (EditHistoryRecorder.GetListAsync): 履歴モジュールの行の Snapshot を io.AuthorizationChecker.RestrictToReadableAsync で読む人の権限に落とす (落とせない・壊れている・対象を読めないなら空)。Snapshot を検索条件・並びに使った読み出しは例外で拒否 (生の内容で絞ると伏せた値を推測できる)。行条件は「レコード自身の列は版の内容・リンク越しは今のリンク先」(通常の読み出しと判定基準を揃える。リンク値を版に凍結する案は採らなかった)。判定できない条件 (版に無い一覧の 1:N) は読めない扱い
  • 復活 (Extras.Server EditHistory/EditHistoryUndeleter・internal): クライアントは EditHistoryUndeleteData{HistoryModuleName, HistoryRowId, RestoreWholeRecord} を ModuleSubmitData.ExtendedData に載せるだけで、サーバーが版のスナップショットから戻す (版は送信の対象レコードのものに限る)
    • 復活ボタン (RestoreWholeRecord = true): 最新の版が Delete で、今そのレコードが無いときだけ。論理削除 = UndeleteAsync (ユーザー権限) + 子は UndeleteTogetherAsync (子の CanDelete だけ = DeleteTogether の逆)。物理削除 = RecreateAsync (手入力 Id / 複合 Id は元の Id、自動採番は新 Id + RelinkVersionsAsync。行の References は作り直した行の Id に付け替え = ResolveReferences・指される宣言を先に作る OrderByReferences)。束縛の向きは GetBinding (子側が Id なら親が子を参照 = 埋め込み: 子を先に用意して親の参照を付ける)。版は常に Restore
    • この版に置き換える (RestoreWholeRecord = false): 版を AuthorizationChecker.RestrictToReadableAsync で見える内容に落としてから、「今は削除中」の論理削除の行だけ UndeleteAsync。戻せなければ例外 = 保存失敗
    • 元 Id の再利用 (自動採番) は DB 依存 (IDENTITY_INSERT 権限・identity ALWAYS) なので採らない。物理削除の復活を機能ごと外す案は「端の仕様で利便性を削らない・条件を絞って制限を付ける」で不採用
  • 従属レコードの扱い (EditHistory/EditHistoryPolicy): 含める (既定) / 除外 / 行ごと。パス = 宣言の Name をドット区切り ("Items.Details")。IsIncluded / IsIndividual / Walk / NotIncludedModules / Strip。判断は Extras だけで行い、本体は GetWithOwnedRecordsAsync(module, id, includeOwnedRecords) のパス選別を提供するだけ (DeleteTogether を履歴のスイッチに借りない)
    • 行ごと: 親の送信の Add / Update / Delete のうち行モジュールの分を Plan{RowTempId | RowId} にして行モジュールの契約へ 1 行 1 版。親削除で消える行の Delete 版は IndividuallyRecordedDeletePlansAsync
    • 親に EditHistoryField が無いときは、自分の EditHistoryField を持つ従属モジュールを自動で行ごと (IndividualTargets(module, field: null))
    • Walk は同じモジュールに複数の経路で届くとき、その先に除外・行ごとの指定が無ければ中を 1 回だけ列挙する (全経路を列挙すると、明細で互いに繋がった設計で経路の数だけ膨らむ)。Recorder は設計に編集履歴が 1 つも無ければ素通し (_usesHistory)、行ごとの記録先はモジュールごとに 1 回だけ求める
    • Walk の循環停止はモジュール単位 (設計の列挙)。本体の読み出しの循環停止はレコード単位 (自己参照の従属を全深さ読む)
  • 閲覧 (Fields/EditHistoryField): 初期化は件数だけ (LoadCountAsync = Id だけの GetList)、ExpandAsync で版を読む (展開状態はフィールドに残る)。版を引く条件は EditHistoryContracts.VersionsCondition に一本化 (Field / UndeleteButton / サーバーが同じ並びを使う。食い違うと「最新の版か」の判定が割れる)。ページの次の 1 行も同時に読み、ページ末尾の版の差分の元にする。版番号の基準は最初のページの総数で固定 (_numberingTotal)。読み込み中の読み直しは _reloadPending。自動保存は再初期化せず AcceptChanges だけ呼ぶので、そこで読み直す (ReloadAfterSubmit)。履歴モジュールを読めない人には要求しない (HasUserReadPermission)
  • 差分 (EditHistory/EditHistoryDiff・EditHistoryValues): 値は文字列にできる型だけ「旧 → 新」(IsTextSupported)、他は JSON 比較で有無だけ。比較は全文・表示だけ 300 文字に切る。従属レコードは行 Id で突き合わせ、行は行番号で示す (行を代表する項目を推測しない)。親へのバインド条件のフィールドは行の値に出さない。項目の閲覧権限は CanRead (従属の行は _ownedModules = 空のモジュールを作って引く)
  • この版を表示: 本体 CreateModuleForShowAsync(snapshot, LayoutName, (declared, rows) => OwnedRecordsDisplay.Build(rows, change)) (追加 / 変更 + セル / 削除行の差し込み。孫も) → SubmitButtonField と EditHistoryField を非表示 → ShowDialogAsync。強調 CSS はダイアログがコンポーネントの外に描画されるので wwwroot/edit-history.css を edit-history-interop.js (ensureStyles) でページに入れる (scoped CSS では届かない)
  • この版に置き換える (EditHistory/EditHistoryRestorer): 添付ファイルと IEditHistoryExcludedFieldDesign の項目を外して本体 Module.ApplyRecordAsync。論理削除の行を Id 付きで戻したら onRevive → EditHistoryField.AddPendingUndelete → GetSubmitData の ExtendedData に同梱。反映数 0 ならメッセージだけ
  • 対象外の宣言: Designs/IEditHistoryExcludedFieldDesign (目印)。ApprovalFlowFieldDesign が実装 (承認 FK はサーバーが保存の後に書くので、差分に出すと幽霊差分になり、戻すと画面の承認状態だけ過去に戻る)。Diff と Restorer は型名でなく目印で判定
  • Extras の 4 フィールドの部品: Fields/OwnedRecordModules (AddAsync = 行を 1 つ足す (新しい行 / Id のある行。どちらも親への束縛の値を入れる) / CreateForShowAsync = 表示専用の行モジュール / ShownClassName() = 表示専用の行の項目に出すクラス)。履歴に依存しない。★項目 (バー・予定・カード・マーカー) に行モジュールの ClassName を出さない (レイアウトのデザインのクラスが入っていて、通常の表示の見た目が変わる)。Gantt の AllowLoad = false での絞り直しは表示専用のときだけ (_isShowingOwnedRecords)。Gantt / Calendar / TaskBoard / MarkerList は LoadOwnedRecordsAsync で全件を読み直す (表示範囲しか持っていないため)。Fields/UnsavedRecordGuard = 未保存の親 (Module.IsNewData) では子を読みに行かない (本体の仮 Id ガードとは別。手入力 Id の新規は Id が空で、本体のガードでは止まらない)
  • 設計チェック: EditHistoryFieldDesign:1〜:11 / EditHistoryContractFieldDesign:1〜:3 / EditHistoryUndeleteButtonFieldDesign:1 (一覧は docs/EditHistory.md「設計チェック」)。番号は固定 (追加は末尾・欠番は再利用しない)
  • セットアップ (Extras.Designer Setup/EditHistorySetupService / EditHistorySetupWindow / CLI edit-history-setup): テンプレート = Example の EditHistory.mod.json を埋め込み、名前・テーブル名・データソース・ユーザーモジュールを差し替える。enum EditHistoryTargetModule は空で生成
  • SeleniumDrivers: EditHistoryFieldDriver / EditHistoryVersionDriver / EditHistoryVersionDialogDriver / EditHistoryUndeleteButtonFieldDriver (1 ファイル EditHistoryFieldDriver.cs)。Versions / FindVersion / LoadMore は未展開なら自動で開く。版の中に details を足すときは summary の指定を直下限定 (:scope > summary) に保つ
  • テスト: Test/EditHistory/* (Diff / FieldHarness / RecorderDb / PolicyDb / ModuleField{Db,Diff,Restore,Show} / Gantt{Restore,Dependencies} / OwnedFields{Restore,Show} / OwnedDisplay / ShowVersionFlow / ApprovalField / AutoSubmitReload)、Test/DesignCheck/EditHistory{,Policy}DesignCheckTest、Test/Approval/ApprovalEditHistoryDbTest、Test/Setup/EditHistorySetupServiceTest、Test/Fields/UnsavedRecordGuardTest。e2e は tfs の SeleniumTest Scenario/Normal/Extras/EditHistory*Test (デザイン = SeleniumTestCookie の Modules/EditHistory)
    • SQLite のテスト用テーブルは日時列を DATETIME / DATE で宣言する (TEXT だと書式がカルチャで混ざり、日時順が崩れる)
    • ExecuteSqlField を持つモジュールの送信は CurrentEditingData が要る (無いと内部エラー)
  • Example のテスト用モジュール: Order + OrderItem (明細・論理削除)、Project + ProjectTask (Gantt)、Plan + PlanEvent / PlanCard / PlanMarker (Calendar / TaskBoard / MarkerList)、履歴モジュール EditHistory + enum EditHistoryTargetModule。テーブルはローカル SQLite。承認系のモジュールは他の確認にも使うので履歴の検証には使わない
  • 未対応: 「この版に置き換える」での添付ファイルの復元 (実体は残っているので、Restorer が FileFieldData を外さなければ戻せる。要望が出たら) / 洗い替え (ReplaceMode) の一覧は行 Id が毎回変わるので差分が「削除 + 追加」に見える (記録・復元は正しい) / 自動保存の版をまとめる機能 / 保持期間の自動削除

JobTriggerField (ジョブトリガー) - ボタンでサーバーのジョブを実行

  • 状態: 実装済み (docs/JobTriggerField.md)。Extras 0.23.0 (2026-10-21。本体 1.3.52 から移動。本体は 1.3.53 で削除。1.3.53 で残した印 IExecutionFieldDesign は 1.3.54 で削除 = 本体はジョブを一切知らない)
  • 構図: Designs/JobTriggerFieldDesign (JobName / ShowButton / ButtonText / UseSubmitData / IsAsynchronous (起動したらすぐ戻り「開始しました」を通知。Run() は起動の成否。結果は待たない) / AllowsConcurrentRuns (既定 false。同じ JobName が実行中なら起動しない。判定はサーバーがジョブ名で)。設計チェック :1 ジョブ名必須 / :2 モジュールの DataRead/WriteCondition は効かない / :3 このフィールドを対象にした PermissionField の行条件は効かない。行条件の判定と対象判定は Designs/MatchConditionDesignExtensions (SemanticSearchFieldDesign と共用)) / Fields/JobTriggerField (Run() = 入力チェック → {"data","submit"} の JSON → 送信 → 通知。送信は RunCoreAsync の差し替え → 静的 EndPoint (専用ルート。互換) への POST → 共通の API の Job.Run の順。どれも無ければ「このアプリではジョブを実行できません」。非同期は応答の JobId を JobIds (終わりを確かめていないもの・起動順。スクリプトで扱えるよう string[] の写し。JobId はその最後 = LastOrDefault、無ければ空) に持つだけで、フィールドは自動で問い合わせない。スクリプトが GetJobStatus(jobId) (C# は GetJobStatusAsync(string jobId = "")。空なら JobIds の先頭 = FirstOrDefault、それも無ければ問い合わせずに null) で 1 回ずつ問い合わせる = Job.Status (専用ルートなら GET {EndPoint}/{jobId}。その 1 回だけトーストを抑える) か差し替え GetJobStatusCoreAsync (RunCoreAsync があって無ければ null)。終わっていれば JobIds から外す。分からない (404・通信失敗・問い合わせるジョブが無い・デザインモード) は null (404 = サーバーが忘れた、と差し替えの null は JobIds からも外す。通信失敗は残す)。戻り値の JobStatusResponse は ExtrasClientInitializer.Initialize(app) で AddType (スクリプトのメソッドは戻り値の型が登録されていないと見えない)) / Jobs/JobRunRequest・JobRunResponse (Started + JobId)・JobStatusResponse (Status = enum Jobs/JobStatus Running/Done/Error (JSON は core の string enum 変換で名前)・Message・Error。IsRunning/Succeeded の bool は置かない (enum で足りる)。AIChatJobStatus には依存しない。enum は AddType でスクリプトに公開) (DTO。Extras.Server の JobApi 側も同じ型。状態の問い合わせは Jobs/JobStatusRequest) / Components/JobTriggerFieldComponent (ButtonField と同じ ViewOnly 制御)
  • テスト: Test/Fields/JobTriggerFieldTest (RunCoreAsync / GetJobStatusCoreAsync の偽ホスト + 記録する IUIService) / Test/DesignCheck/JobTriggerFieldDesignCheckTest

ジョブ (Jobs) - サーバー機能 (フィールド無し)

  • 状態: 実装済み (docs/Jobs.md)。Extras.Server 0.23.0 (2026-10-21)
  • 構図 (Extras.Server Jobs/): ホストのアプリ自身を別プロセスで job <ジョブ名> --stdin で起動する親 JobProcess (static。RunAsync = 待つ (記録しない) / StartAsync = 起動してジョブ Id (GUID "N") を返す・失敗は LoggerFactory にも Error。どちらも allowConcurrent (既定 true) が false で同じジョブ名 (大文字小文字無視) が実行中なら起動せず null (実行中の数は lock した Dictionary で数える。同期・非同期とも・起動の失敗でも減らす。プロセス内だけ)。GetStatusAsync(jobId, userId) = 非同期のジョブの状態 (知らない Id・別ユーザー・Cache 未設定は null)。状態は Cache (IDistributedCache。ホストが起動時に設定) にキー "Job:" + jobId・JSON (ユーザー Id・Status・Message・Error) で置く: 起動 (TryEnter → Launch) を同期的に済ませてから running を期限なしで書き、裏の WatchAsync が終了で done / error に書き換えて FinishedRetention (既定 30 分) の AbsoluteExpiration。書き込みの失敗は LogFailure に流して黙る。テンプレはメモリキャッシュ = 再起動で消え、複数インスタンスでは共有キャッシュ (LoginLockout と同じ)。途中で落ちたジョブは running のまま残る。HostExecutable = EntryDll の隣の apphost → single-file なら ProcessPath → 無ければ DotnetPath + EntryDll) と、子側 JobMode.RunAsync(args, jobNames, runJob) (UTF-8・--stdin を標準入力の内容に置換・終了コード 0/1/2・最後の行 = 成功の文言・標準エラー 1 行目 = 例外のメッセージ)、結果 JobResult (Succeeded / Message / ErrorMessage / ExitCode / Output / Error)。親子の約束はこの 3 つに閉じる。ジョブの型 (IJob)・DataService の組み立てはホストテンプレ (Starter Cookie Server/Jobs/JobRunner.cs) = 利用者が触る側
  • 使う側: Extras の JobTriggerField (画面のボタン → 共通の API の Job.Run → Extras.Server の JobApi (ホストは ExtrasApiTable に new JobApi(JobRunner.Names)) → JobProcess。非同期の状態は Job.Status → JobProcess.GetStatusAsync。null なら応答なし) と、コマンドライン / タスクスケジューラ / Azure WebJobs (Program.cs の job 分岐 → JobMode)
  • テスト: Test/Jobs/ (JobMode 同一プロセス・JobResult の行の取り出し・JobProcessTest = テスト用の最小ホスト Codeer.LowCode.Blazor.Extras.Test.JobHost を dotnet と apphost の両方で起動する往復。状態の置き場は時計 (ISystemClock) を差し替えた MemoryDistributedCache。Test.csproj の ProjectReference でテストの bin に出力が写る)

監査ログ (AuditLog) - サーバー機能 (フィールド無し)

  • 状態: 実装済み (docs/AuditLog.md)。Extras.Server 0.17.0
  • 位置づけ: 「いつ・誰が・どこから・何に・何をして・どうなったか」を WebAPI ごとに記録する (状態を変える操作は試行+結果の 2 行)。トランザクションは業務と別 (CLB は複数 DB なので合わせられない。失敗の記録がロールバックに巻き込まれない利点)。J-SOX / ISMS の監査証跡向け。編集履歴とは別 (編集履歴 = 業務データの変更、監査ログ = 失敗・拒否・参照・ログインも含むシステム操作の記録)。値は記録しない。ハッシュチェーンは持たない (権限分離 + 外部転送で足りる)
  • 構図 (Extras.Server AuditLog/): 保存する部品だけを持ち、何を記録するかは呼び出し側 (WebAPI の入口) が決める。AuditEvent (レコード) / AuditLogger (シングルトン。記録の規則は固定 = 分類あり (Other 以外) は全部・Other は失敗と拒否だけ・試行は HasAttempt の分類 (Authentication/DataWrite/Export/Admin) だけ、全 IAuditSink へ書く、Strict なら AuditLogException・BestEffort は Critical ログ) / DatabaseAuditSink (固定列・書き込みごとに専用の IDbAccessor・CreateTableSql) と FileAuditSink (JSON Lines audit-{host}-{yyyyMMdd}.jsonl) / AuditLogMiddleware (UseRouting 直後・認可の前に置く = 認可ミドルウェアの 401/403 とアンチフォージェリの 400 が見える。MVC フィルタではこれが見えない。認証は自分で解決 = 既定スキームで AuthenticateAsync。分類は [Audit(AuditCategory)]、無ければ Other。二段記録 (2026-10-01 ユーザー決定「両方同期」): 操作の前に Result=Attempt の行 (AttemptCategories。既定 Authentication/DataWrite/Export/Admin。DataRead は付けない) → 書けなければ Strict で操作を実行しない、操作の後に結果の行。RequestId で結ぶ。後段の書き込みは Response.OnStarting = レスポンス先頭の前。Kestrel/IIS は未開始扱いなので Strict の失敗は例外ハンドラの 500 になる。TestServer は開始済み扱いで例外がそのまま届く) / AuditContext (スコープ。コントローラが対象・業務上の失敗・ログイン時のユーザーを足す) / AuditLogHostedService (起動 = 有効設定を Detail に・停止。Host の Dispose だけでは StopAsync は走らない) / AuditIOInterceptor (IO インターセプタ。保存の合流点で対象を記録 + 監査テーブルへのモジュール保存を拒否。他のインターセプタより先 = 外側に登録)
  • 設定: AuditLog (Enabled / FailureMode 既定 Strict だけ。★2026-10-02 ユーザー決定で Categories / AttemptCategories は削除 = 何を記録するかはコントローラの [Audit] が決める。★同日ユーザー「Id は残さない場合でもアクセスに来たログは残したい」で訂正: 一覧取得 (ModuleData.GetList) は常に [Audit(DataRead)]、AuditContext.AddRead(module, page, recordIds) = 既定 false はモジュール名の Read + Rows= だけ、true で行ごとの Id (テンプレ false / tfs Selenium サイト true)。RetentionDays / Purge も削除 = アプリは監査ログを消さない・IAuditSink は WriteAsync だけ) 。出力先は同じセクションの入れ子 Database (DataSourceName / Table) / File (Directory) (★2026-10-02 3 セクション → 1 つに統合)。有効なのに出力先が無ければ起動エラー。ホストは AuditSinkTable (FileStorageTable と同形) で IAuditSink の並びを組み AddAuditLog(settings, sinks) + UseAuditLog()
  • 結線 (Starter Cookie): Controller に [Audit]、共通の API はハンドラの GetAuditCategory。ModuleData = list (行ごと Read) / submit / submit_by_file / bulk_submit = 対象 (行ごとの Add / Update / Delete と Id)・件数・結果の ExceptionMessage → Failure は AuditIOInterceptor → AuditContext.RecordSubmitAsync (internal) が保存の合流点で記録する (コントローラは包まない。対象は保存前の送信内容から、仮 Id は結果の TemporaryIdMap/DestinationId で採番 Id に。Id の無い新規行には仮 Id を付けて引く) / list_file・list_file_by_data (Export。行の Id と Rows) / upload (DataWrite) / download (Export。添付の持ち出しは一覧のファイル出力と同じ扱い)。Account = login (LoginName を Detail・ユーザー Id は Cookie 発行時だけ = 二要素待ちの Success 行は user_id 空・二要素のコード不一致は Deny)・login_ticket・logout (Authentication)。共通の API: Mail.Send / SendBulkSearch (Export)・Mail.Preview / PreviewBulkSearch (DataRead。一斉は描いた宛先の行ごと Read を MailPreviewBuilder が足す)・Job.Run (DataWrite)・Approval.Execute (DataWrite・結果の TargetId)・AIChat.Send (DataRead)・SemanticSearch.Reindex (Admin)・Totp.Reset (Authentication)・Excel.ConvertPdf (Export)・AITextAnalyze.AnalyzeFile / AnalyzeText (Other)
  • 外部ログインのコールバック: IdP からの戻り (/signin-{name}) は認証ハンドラが処理しコントローラのアクションではない = ミドルウェアに見えないので、ExternalLoginAuthentication が AuditLogger (DI にあれば) へ直接書く。Action = Account.ExternalLoginCallback、成立 = Success + ユーザー Id、未登録・クレーム不備・IdP 側の失敗 = Denied (Provider=...; Error=...)。Strict で書けなければサインインしない。ネイティブアプリの成立は login_ticket のアクションが記録する (コールバックでは書かない = 二重に数えない)
  • DB の時刻: DatabaseAuditSink は日時を Kind=Unspecified で渡す (Kind=Utc のままだと Npgsql が timestamptz で送り、timestamp 列へセッションのタイムゾーンに直して入る)
  • デザインの版 (AuditEvent.DesignVersion / design_version 列): App.zip の SHA-256。ホストが AddAuditLog(settings, sinks, designVersion) に Func<HttpContext?, string> を渡す (HttpContext あり = そのリクエストが使う版、null = 今読み込んでいる版)。ミドルウェアは前段の前に版を取り、試行と結果の 2 行に同じ値を書く。ホスト (Starter Cookie) は RequestDesign (スコープ) でリクエストの最初に版を 1 つに固定し、コントローラは DataService.Design 経由でその版だけを使う (AppDesign.Store (Extras.Server Design/DesignDataStore。版 = LoadedDesign.Version) から直接取らない。途中で差し替わった版が混ざるため)。プロセスの版が替わると AuditLogger が System の Design.Loaded (Previous=前の版) を 1 回書く (インスタンスごと)。App.zip の保管と送信者の記録はシステム外 (デザイナの送信履歴フォルダ DeployInfo.HistoryDirectory が 版の先頭 16 桁_UTC 日時_マシン名.zip で保存する。送信者は記録しない = なりすましを防げない自己申告は証拠にならない)
  • DB の DDL: MySQL の targets / detail は longtext (text は 64KB で行の多い対象が入らない)。SQL Server は時刻が 1/300 秒に丸まる (Dapper が DateTime を datetime で送る。実害なし)
  • 監査の対象外 (2026-10-01 ユーザー決定): AI チャットの RawDataAccessAgent (execute_sql / search_records / {embed:}) は監査対象外。AI 用 DB ユーザーの生 SQL で行制限が効かず、ジョブ実行で読んだ行を Id で残せないため。★2026-10-02 ユーザー「使ったログは残る想定」で訂正: AIChat Send は [Audit(DataRead)] (3 ホスト) + AIChatService.StartAsync が AuditContext.Current に対象 (モジュール・AIChat:フィールド名) と Agent=名前 ((default) = 空) を足す = 誰がどの画面でどの Agent に聞いたかは残る。残らないのは発言と Agent が読んだ行だけ。AI チャットの「監査ログ」という言葉は「実行ログ (ILogger)」に改めた。方針: 監査が要る環境向けに、実行ユーザーの ModuleDataIO 経由で読む制御の効いた Agent を作る (search_records も SearchCondition + GetListAsync に。ジョブは Start 時点のユーザー・RequestId・版で AuditLogger に直書き)
  • 権限拒否は Denied (2026-10-02): 本体 1.3.38 の LowCodeAccessDeniedException (権限で拒否する全箇所が投げる派生型。文言は同じ) をミドルウェア (例外 → Denied) と AuditContext.RecordSubmitAsync (catch → Deny + rethrow。本体 SubmitWithoutInterceptorsAsync はこの型だけインターセプタまで上げ、SubmitAsync が同じ形の ExceptionMessage にする) が見る = result = 'Denied' で権限外アクセスの試行を文言に依存せず引ける。Extras.Server は core 1.3.38 を直接参照。★2026-10-13 (意味検索 18 回目): RecordSubmitAsync は権限拒否以外の例外も catch → Fail + rethrow。内側のインターセプタ (編集履歴・意味検索) が投げた例外は本体 SubmitAsync が最外で ExceptionMessage (HTTP 200) にするのでミドルウェアには届かず、付けないと保存が巻き戻ったのに Success で残る
  • 対象と件数 (2026-10-01): 触ったレコードは経路によらず Id で残す。AuditContext.Current (AsyncLocal。ミドルウェアが入れる。リクエスト外は null。リクエストから起動するジョブ (SemanticSearchService / AIChatService の Task.Run) は ExecutionContext.SuppressFlow() で引き継がない = 応答済みのリクエストの記録に書き足さない) から、引数で持ち回れない処理 (インターセプタ・BulkFileTransfer・Mail) が AddTarget / AddCount (Detail 先頭に 名前=件数) / AddNote (File=取込ファイルの SHA-256) を足す = 公開メソッドに監査用の引数を足さない。1 レコードの対象は AuditLogger.MaxTargetsPerRecord = 500 件までで、超えたら同じ RequestId の続きの行 (AuditResult.Continued。切り捨てない)。本体の一括 INSERT (BulkAddThreshold 以上の純追加) は採番 Id を返さない → その Add は Id 無しでモジュールごとに 1 件 + 件数 (監査のために一括 INSERT を止めない。編集履歴と違い全モジュールが対象なので)。メールは送信元レコード + 件数、一斉送信は宛先の行を Id で (MailTo。アドレスは残さない)。SearchDelete (ListField の洗い替え) はモジュール名だけ (Id は本体の変更が要る)
  • テストの罠: AuditContext.Current (AsyncLocal) を async の SetUp で入れてもテスト本体には伝わらない → 各テストの先頭で同期のヘルパーから入れる
  • セットアップ (Extras.Designer Setup/AuditLogSetupService / AuditLogSetupWindow / CLI audit-log-setup): テンプレート = Example の AuditLog.mod.json (固定列の閲覧モジュール。日時は SaveAsUtc・分類/結果は候補付き Select・操作者はユーザーリンク・誰も書けない保護条件) を埋め込み、名前・テーブル名・データソース・ユーザーモジュールを差し替える。DDL はモジュールからではなく AuditLogTableDdl (Extras.Designer 内。DatabaseAuditSink.CreateTableSql / CreateIndexSql の複製。Designer は Server を参照しないため。同一性は Test/Setup/AuditLogSetupServiceTest が 5 DB で検証) = テーブル + occurred_at_utc のインデックス。テーブルが既にあれば DDL を出さない。FieldDocs は無い (フィールドが無いのでフィールドカタログには載らない)。代わりに SpecDocs/AuditLog.md を SpecDocCatalog に登録 (ExtrasSpecDocs。埋め込み Codeer.LowCode.Blazor.Extras.Designer.SpecDocs.*.md) し、ai-refresh が CCFD の _specs/AuditLog.md に書き出す = CCFD はこれで「監査ログ = サーバー機能・自作しない・audit-log-setup で生成・appsettings はユーザー」を知る。CLI の記述は Test/Setup/AuditLogSpecDocTest が SetupCli のオプション名と突き合わせる。Standard の許可リストに verb あり
  • 未対応: MultiTenant ホストの Controller
  • テスト: Test/AuditLog/ (Logger の記録規則/Strict/BestEffort、File/Database (SQLite) Sink、TableGuard、TestServer でミドルウェア 10 本、bulk_submit の対象と失敗)、外部ログインのコールバックは Test/Auth/ExternalLoginFlowTest の Audit_*。MVC はテストクラス内にネストしたコントローラを見つけないのでトップレベルに置く。セットアップは Test/Setup/AuditLogSetupServiceTest

EnterFocusMoveField - Enterキーでフォーカス移動

  • 状態: 実装済み (docs/EnterFocusMoveField.md)
  • 機能: モジュール内で Enter キー押下時に次の入力要素にフォーカスを移動 (末尾→先頭ループ、tabindex尊重、IME対応)
  • UI: なし (デザインモードでのみ EnterFocusMove ラベルを表示)
  • JS interop: wwwroot/enterfocusmove-interop.js でモジュールルート ([data-module] / [data-module-design]) に keydown をバインド
  • 除外: <textarea>, contenteditable, data-consumes-enter 属性を持つ要素, Submit ボタン
  • ファイル: Designs/EnterFocusMoveFieldDesign.cs, Fields/EnterFocusMoveField.cs, Components/EnterFocusMoveFieldComponent.razor, wwwroot/enterfocusmove-interop.js

LoginAccountContractField - ログインアカウント契約

  • 状態: 実装済み (Extras.Designer FieldDocs/LoginAccountContractFieldDesign.md)
  • 機能: ユーザーモジュール (CurrentUserModule) に置き、行がログインアカウントとしてどう振る舞うかを宣言する契約フィールド。役割 (フィールド参照): LoginName (必須) / ExternalLoginName (外部 IdP の突き合わせ。空なら LoginName) / IsActive (偽なら拒否) / DisplayName (Cookie の Name と TOTP の QR) / TwoFactorEmail (メール二要素の送信先)。ログイン用の書き込み専用列: DbColumnPasswordHash + Salt (照合用。空 = パスワードログイン無し)、DbColumnTotp* 3 列 (認証アプリの二要素。空 = 無効)。テンプレートのログイン (Extras.Server PasswordLogin = 検証の流れ 1 回呼び: 契約の確認 → LoginLockout → LoginAccountStore.VerifyPasswordAsync → TotpLogin / EmailOtpLogin → 結果 PasswordLoginResult{Status(NotAvailable/Locked/Unauthorized/TwoFactor/Ok), Account(Ok だけ), Response(クライアントへそのまま), Detail, IsDenied}。HTTP・監査・Cookie はホストの AccountController。★2026-10-30 ホストの Login 80 行をここへ) はこの契約だけを見る。PasswordHashField は「書く側」として別に置く
  • セッションの印 (ログイン済み Cookie の失効。SecurityStamp と同じ考え): 書き込み専用列 DbColumnSessionStamp ([DbColumn(SessionStamp, IsWriteOnly = true)] = DDL 生成に載る。TEXT。空 = 照合しない = 従来どおり。デザインチェックは列の存在だけ。HasSessionStamp)。値は Services/SessionStamp.Create() (サーバーだけが作る)。ログインで LoginAccountStore が行の印を読み (NULL なら採番 = 移行スクリプト不要)、LoginPrincipal.Create(..., sessionStamp) が session_stamp クレームに積む。リクエストごとに LoginSessionValidation.ValidatePrincipalAsync (ホストが Cookie の OnValidatePrincipal から呼ぶ) が IsSessionValidAsync で主キー 1 行を読んで照合 (キャッシュ無し = 遅延ゼロ・複数インスタンスでも DB が正本)。行無し・停止・不一致・印の無い Cookie は RejectPrincipal + SignOut (401・Cookie 削除。列を足した時点で全員再ログイン)、DB 障害は例外のまま 500 (Cookie は消さない)
  • 印の書き換え (= 全端末の Cookie 失効): パスワード変更 (契約の PasswordField → ApplyPasswordHash が契約のデータに新しい印も入れる・戻り値 bool / 同じ表の別モジュールの PasswordHashField → PasswordHashingModuleDataIO が RevokeSessionsAsync)、ユーザーモジュールの保存で IsActive 偽 (同上。同じトランザクション)、管理者による全端末ログアウト (SessionRevokeButtonField が印を NULL に。次のログインで採番)。表示名だけの保存では変えない。保証は「書き換えた保存のコミット後の次の認証チェックから拒否」(全端末一括・処理中のリクエストは止めない・新しいログインは止めない)。外部 IdP は ExternalLoginUser.SessionStamp、モバイルチケットも本人確認時の印を持つ。直接 SQL でパスワード / 有効フラグを変えるときは印も新しい乱数に (契約)
  • ファイル: Designs/LoginAccountContractFieldDesign.cs (ContractFieldDesignBase 派生。UI 無し)、Data/LoginAccountContractFieldData.cs (PasswordHash / PasswordSalt / SessionStamp)、Services/SessionStamp.cs、Extras.Server Auth/LoginSessionValidation.cs

EmailOtpLogin - 二要素認証 (メールのワンタイムコード)

  • 状態: 実装済み (docs/TwoFactorLogin.md)。Extras.Server Auth/EmailOtpLogin.cs (フィールドは無し)
  • 機能: LoginAccountContractField の TwoFactorEmail (送信先フィールド) を設定するとパスワード成功後に 6 桁コードをメールで送り入力を求める。コードはサーバーのキャッシュ (IDistributedCache) に有効期限付き。試行超過で破棄。契約の TOTP 列があれば認証アプリ優先
  • 設定: appsettings EmailOtpLogin (MailInfraName / Subject / Body / CodeLifetimeMinutes / MaxAttempts、すべて任意)

TotpResetButtonField - 認証アプリ解除ボタン (管理者用)

  • 状態: 実装済み (Extras.Designer FieldDocs/TotpResetButtonFieldDesign.md)
  • 機能: ログインユーザーモジュールの詳細画面に置き、表示中の行のユーザーの認証アプリ (TOTP) 登録を解除する。TOTP の 3 列を DbColumn(IsWriteOnly) で持ち、解除は「3 列を空にするデータ」を通常の Submit で書く (SubmitButtonField と同じく Module.SubmitAsync)。権限は CLB の権限モデル (UserWrite / DataWrite 条件) がそのまま効くのでサーバーに専用の入口は無い。列は契約と同じでなければならない (デザインチェック)。登録済みかは表示しない (書き込み専用列は SELECT されない)
  • ファイル: Designs/TotpResetButtonFieldDesign.cs, Data/TotpResetButtonFieldData.cs, Fields/TotpResetButtonField.cs, Components/TotpResetButtonFieldComponent.razor

SessionRevokeButtonField - 全端末ログアウトボタン (管理者用)

  • 状態: 実装済み (Extras.Designer FieldDocs/SessionRevokeButtonFieldDesign.md)
  • 機能: ログインユーザーモジュールの詳細画面に置き、表示中の行のユーザーを全端末からログアウトさせる。セッションの印の列を DbColumn(IsWriteOnly) で持ち、「印を NULL にするデータ」を通常の Submit で書く (TotpResetButtonField と同じ形。Module.SubmitAsync)。印が NULL の行は照合で落ち、次のログインで採番される。権限は CLB の権限モデル (UserWrite / DataWrite 条件) がそのまま効くのでサーバーに専用の入口は無い。列は契約の DbColumnSessionStamp と同じでなければならない (デザインチェック)
  • ファイル: Designs/SessionRevokeButtonFieldDesign.cs, Data/SessionRevokeButtonFieldData.cs, Fields/SessionRevokeButtonField.cs, Components/SessionRevokeButtonFieldComponent.razor

MyTotpResetButtonField - 自分の認証アプリ解除ボタン

  • 状態: 実装済み (Extras.Designer FieldDocs/MyTotpResetButtonFieldDesign.md)
  • 機能: ログイン中の自分の認証アプリ (TOTP) 登録を解除するボタン。表示中の行とは無関係で、どのモジュールにも置ける (設定画面・マイページ)。状態はサーバー問い合わせ (列は書き込み専用)
  • 結線: 共通の API の TotpApi(TotpLoginSettings) (Totp.Status / Reset。ログイン中のユーザーが対象)。専用ルートは TotpResetClient.StatusEndPoint / ResetEndPoint (互換)
  • ファイル: Designs/MyTotpResetButtonFieldDesign.cs, Fields/MyTotpResetButtonField.cs, Fields/TotpResetClient.cs, Components/MyTotpResetButtonFieldComponent.razor

LoginAccountContractField のパスワード書き込み (2026-09-07)

  • 契約の PasswordField (同モジュールの PasswordField 参照) を指定すると、PasswordHashHelper.ApplyPasswordHash が契約のデータ (LoginAccountContractFieldData = PasswordHash / PasswordSalt。印の列があれば新しい SessionStamp も) を差し込み、本体が契約の書き込み専用列へ書く。ユーザーモジュールに PasswordHashField は不要 (併用はデザインチェック エラー・ヘルパーも契約を優先)
  • TOTP の 3 列は契約の列名プロパティだけ (DbColumn 属性なし)。本体の書き込みは「送られたフィールドの DbColumn メンバーを全部書く」ので、同じデータに載せるとパスワード保存で NULL 上書きされるため。ContractFieldDesignBase.GetRoleProperties は CandidateType.DbColumn のプロパティを役割から除外する

PasswordHashField - パスワードハッシュ

  • 状態: 実装済み (docs/PasswordHashField.md)
  • 機能: 平文 PasswordField を Submit 時にハッシュ+ソルトへ変換し、2つのDBカラムへ書き込む書き込み専用フィールド。UIなし
  • 要件: サーバ側で CustomizedModuleDataIO が PasswordHashingModuleDataIO (Extras.Server Auth/。本体の行書き込み AddAsync / UpdateAsync / BulkAddAsync を override して PasswordHashHelper.ApplyPasswordHash(...) を呼ぶ ModuleDataIO 派生) を継承するのが必須 (テンプレートは継承済み。Helper は Extras 本体に同梱)。行を書く直前の変換なので、保存だけでなくシステム権限の内部書き込み (ModuleDataIOSystemAccess・ホストの AddSystemRecordAsync) も通り、本体のフィールド書き込み権限の検査の後に入る (ハッシュ列の書き込み権限は見ない。ユーザーが書くのは PasswordField)。自前で override して ApplyPasswordHash を呼ぶ形も動く
  • ファイル: Designs/PasswordHashFieldDesign.cs, Fields/PasswordHashField.cs, Data/PasswordHashFieldData.cs, Components/PasswordHashFieldComponent.razor

OrientationLockField - 画面の向き制御

  • 状態: 実装済み (docs/OrientationLockField.md)
  • 機能: タッチ端末で画面の向き(横/縦)が指定と異なるとき、全画面オーバーレイで回転を促す。CSSメディアクエリ (pointer: coarse) のみで制御 (JS不要)
  • UI: デザインモードでは OrientationLock (...) プレースホルダを表示
  • ファイル: Designs/OrientationLockFieldDesign.cs, Fields/OrientationLockField.cs, Components/OrientationLockFieldComponent.razor

実験済みプロトタイプ (Experiments/)

  • タスクガントチャート (TaskGantt.razor): SVGベース、ドラッグ移動/リサイズ、タスク依存線、スナップ。→ GanttField として製品化済み
  • 設備稼働ガントチャート (DeviceStateGantt.razor): SVGベース、ステータス色分け(正常/警告/異常)、時間軸

フィールド型の実装パターン

ファイル構成

Extrasでは以下の3ファイル構成が基本 (データなしフィールドの場合):

ファイル 配置先 役割
XxxFieldDesign.cs Designs/ デザイン時定義 (JSON永続化)
XxxField.cs Fields/ ランタイムロジック
XxxFieldComponent.razor Components/ Blazor UI + .razor.css

値を持つフィールドは Data/XxxFieldData.cs も必要 (4ファイル構成。データクラスは Data/ に配置)。

名前空間

Codeer.LowCode.Blazor.Extras.Designs     # デザインクラス
Codeer.LowCode.Blazor.Extras.Fields      # ランタイムクラス
Codeer.LowCode.Blazor.Extras.Components  # Blazorコンポーネント

デザインクラスのテンプレート

using Codeer.LowCode.Blazor.DesignLogic.Check;
using Codeer.LowCode.Blazor.DesignLogic.Refactor;
using Codeer.LowCode.Blazor.Extras.Components;
using Codeer.LowCode.Blazor.Extras.Fields;
using Codeer.LowCode.Blazor.OperatingModel;
using Codeer.LowCode.Blazor.Repository.Data;
using Codeer.LowCode.Blazor.Repository.Design;

namespace Codeer.LowCode.Blazor.Extras.Designs
{
    public class XxxFieldDesign() : FieldDesignBase(typeof(XxxFieldDesign).FullName!)
    {
        // デザイナーで設定可能なプロパティ
        [Designer(CandidateType = CandidateType.Field)]
        public string SomeField { get; set; } = string.Empty;

        [Designer(CandidateType = CandidateType.ScriptEvent)]
        public string OnSomeEvent { get; set; } = string.Empty;

        public override string GetWebComponentTypeFullName() => typeof(XxxFieldComponent).FullName!;
        public override string GetSearchWebComponentTypeFullName() => string.Empty;
        public override string GetSearchControlTypeFullName() => string.Empty;
        public override FieldDataBase? CreateData() => null;
        public override FieldBase CreateField() => new XxxField(this);

        public override List<DesignCheckInfo> CheckDesign(DesignCheckContext context)
        {
            var result = base.CheckDesign(context);
            // フィールド参照の存在チェック
            context.CheckFieldFieldExistence(Name, nameof(SomeField), SomeField).AddTo(result);
            return result;
        }

        public override RenameResult ChangeName(RenameContext context)
            => context.Builder(base.ChangeName(context))
                .AddField(SomeField, x => SomeField = x)
                .Build();
    }
}

ランタイムクラスのテンプレート

using Codeer.LowCode.Blazor.Extras.Designs;
using Codeer.LowCode.Blazor.OperatingModel;
using Codeer.LowCode.Blazor.Repository.Data;
using Codeer.LowCode.Blazor.Script;

namespace Codeer.LowCode.Blazor.Extras.Fields
{
    public class XxxField(XxxFieldDesign design) : FieldBase<XxxFieldDesign>(design)
    {
        public override bool IsModified => false;
        public override FieldDataBase? GetData() => null;
        public override FieldSubmitData GetSubmitData() => new();
        public override Task SetDataAsync(FieldDataBase? fieldDataBase) => Task.CompletedTask;

        [ScriptHide]
        public override async Task InitializeDataAsync(FieldDataBase? fieldDataBase)
        {
            // 初期化ロジック
        }
    }
}

Blazorコンポーネントのテンプレート

@using Codeer.LowCode.Blazor.Components
@using Codeer.LowCode.Blazor.Extras.Fields
@inherits FieldComponentBase<XxxField>

<div>
  <!-- UI実装 -->
</div>

@code {
    protected override async Task OnInitializedAsync()
    {
        await base.OnInitializedAsync();
    }
}

本体の主要クラス階層 (参照情報)

フィールドデザイン基底クラス

FieldDesignBase                        # 全フィールドの基底 (データなし)
├── ValueFieldDesignBase               # 値を持つフィールド
│   └── DbValueFieldDesignBase         # DB列にマッピングされるフィールド
└── ListFieldDesignBase                # リスト系フィールド

フィールドランタイム基底クラス

FieldBase : IUIContext                 # 全フィールドの基底
└── FieldBase<TDesign>                 # デザイン型付き
    └── ValueFieldBase<TDesign, TData, TValue>  # 値あり (Source/Current/IsModified)

主要インターフェース

  • ISearchResultsViewFieldDesign - 検索条件を持つフィールドデザイン (SearchCondition)
  • ISearchResultsViewField - 検索結果を表示するフィールド (SetAdditionalConditionAsync, ReloadAsync)
  • IDataDependentField - 他フィールドのデータに依存するフィールド (GetDependencyFields())
  • IDisplayName - 表示名を持つフィールド

本体の主要フィールド型 (参照用)

TextField, NumberField, BooleanField, DateField, DateTimeField, TimeField, SelectField, RadioGroupField, LinkField, IdField, FileField, ListField, DetailListField, TileListField, SearchField, ButtonField, SubmitButtonField, LabelField, ImageViewerField, ModuleField, ProCodeField, ExecuteSqlField, QueryField

データフロー

ModuleData → Module.InitializeDataAsync() → Field.InitializeDataAsync()
→ ユーザー操作
→ GetSubmitData() → ModuleDataService.SaveAsync()

子モジュール操作 (カレンダー等で使用)

// 検索してモジュール一覧取得
var items = await this.GetChildModulesAsync(searchCondition, ModuleLayoutType.Detail, layoutName);

// 新規モジュール作成
var mod = await this.CreateChildModuleAsync(moduleName, ModuleLayoutType.Detail, layoutName);

// ダイアログ表示
var result = await mod.ShowDialogAsync("OK", "Cancel");

// 保存
await mod.SubmitAsync();

Designer属性の使い方

[Designer]                                          // 基本プロパティ (デザイナーUIに表示)
[Designer(Scope = DesignerScope.All)]               // 全スコープで表示
[Designer(CandidateType = CandidateType.Field)]     // フィールド名の候補リスト
[Designer(CandidateType = CandidateType.ScriptEvent)]  // スクリプトイベント
[Designer(CandidateType = CandidateType.DetailLayout)] // DetailLayout候補
[Designer(CandidateType = CandidateType.Resource)]  // リソースパス候補

[ModuleMember(Member = "...")]                      // どのモジュールのメンバーか
[TargetFieldType(Types = [typeof(TextFieldDesign)])] // 対象フィールド型を制限
[Layout(ModuleNameMember = "...")]                  // レイアウト候補のモジュール指定
[ScriptMethod(ArgumentTypes = ["string"], ArgumentNames = ["id"])]  // スクリプトイベントの引数定義

Script関連属性

[ScriptHide]                           // スクリプトから非表示
[ScriptName("Reload")]                 // スクリプト内での名前 (メソッド名からAsyncを除く等)
[ScriptMethodToProperty("Value")]      // 非同期メソッドをプロパティとして公開

コード規約

  • privateフィールド: _camelCase
  • publicプロパティ: PascalCase
  • 非同期メソッド: Async サフィックス
  • C#: インデント4スペース / Razor, CSS: インデント2スペース
  • 改行: CRLF、末尾改行あり
  • コードビハインドファイルは使わない (.razor 内の @code {} に記述)
  • CSSスコープファイル (.razor.css) を各コンポーネントに配置

本体ソースの参照先

フィールド実装の参考にする場合、本体ソースは以下から読める:

C:\tfs\codeer\Codeer.LowCode.Blazor\Source\Codeer.LowCode.Blazor\
├── Repository\Design\          # デザインクラスの実装例
├── OperatingModel\             # ランタイムクラスの実装例
├── Components\Fields\          # Blazorコンポーネントの実装例
├── Repository\Data\            # データクラスの実装例
└── DataIO\                     # FieldBase拡張メソッド (GetChildModulesAsync等)

特に参考になるファイル:

  • ListField 系: ListFieldDesignBase.cs, ListField.cs, ListFieldComponent.razor
  • CalendarField の元になったパターン: ISearchResultsViewField の実装

今後追加予定のフィールド

(ガントチャート / カンバンボード / リッチテキストエディタ は実装済み。「既存フィールド」参照)

  • チャート/ダッシュボード (ChartField) - 各種グラフ表示
  • 署名パッド (SignatureField) - 手書き署名キャプチャ
  • 地図 (MapField) - 位置データ可視化