From bf62bfdc063abca172f10576317eab133b647830 Mon Sep 17 00:00:00 2001 From: Hirotada Kobayashi Date: Thu, 3 Sep 2026 14:45:43 +0900 Subject: [PATCH 01/12] tools: add LINE Flex Message live preview (line_flex_* MCP + canvas extension) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a LINE Flex Message live-preview capability in two forms that share one browser renderer ("one renderer, four surfaces"): Deliverable A — Copilot canvas extension (extensions/line-flex-viewer/): a self-contained canvas provider plus a zero-dependency Node stdio MCP server and a standalone client-side HTML viewer (copied verbatim from the contribution bundle). Deliverable B — .NET MCP tools (tools/Line.OpenApi.Tools): a line_flex_* tool group backed by a loopback-only HttpListener + SSE preview server (FlexPreviewService) reusing the same web/ renderer, embedded as assembly resources. It makes no LINE API calls and stores no secrets, so it registers unconditionally alongside ReadTools and is safe under --read-only. Tools: line_flex_preview / line_flex_get_content / line_flex_validate / line_flex_open. Env: LINE_FLEX_MCP_NO_OPEN, LINE_FLEX_MCP_STATE_DIR. Gate review (docs/reviews/2026-09-03-flex-preview-review.md): code/security/ test-arch all non-blocking. Findings applied: - Open() now truly reopens the tab (explicit user gesture; respects NO_OPEN). - /api/* Host-header + POST Origin validation (blocks DNS-rebind reads and cross-origin CSRF writes; returns 403). - line_flex_validate no longer throws on malformed input; Dispose closes SSE streams; Normalize message corrected. - New service-layer unit tests + a byte-identical guard between the two web/ trees; tool-surface snapshot extended to cover the flex tools. Verification: build 0 warnings/0 errors; full suite green (264 lib + 97 Tools + 26 AI + 1 isolation); tools/list shows the four tools under default and --read-only; loopback server serves viewer + assets + /api/state; pack-verify 12-package contract intact (Tools excluded). No new package dependencies (BCL only). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + README.md | 13 + README_ja.md | 14 + .../reviews/2026-09-03-flex-preview-review.md | 83 +++ extensions/line-flex-viewer/README.ja.md | 153 ++++++ extensions/line-flex-viewer/README.md | 157 ++++++ .../line-flex-viewer/copilot-extension.json | 4 + extensions/line-flex-viewer/extension.mjs | 398 ++++++++++++++ extensions/line-flex-viewer/mcp/package.json | 15 + extensions/line-flex-viewer/mcp/server.mjs | 470 ++++++++++++++++ extensions/line-flex-viewer/web/flex.css | 334 +++++++++++ extensions/line-flex-viewer/web/renderer.js | 520 ++++++++++++++++++ extensions/line-flex-viewer/web/samples.js | 207 +++++++ .../line-flex-viewer/web/standalone.html | 50 ++ extensions/line-flex-viewer/web/standalone.js | 312 +++++++++++ extensions/line-flex-viewer/web/viewer.html | 46 ++ extensions/line-flex-viewer/web/viewer.js | 207 +++++++ .../FlexPreviewServiceTests.cs | 155 ++++++ .../FlexWebAssetsParityTests.cs | 49 ++ .../McpToolRegistrationTests.cs | 22 +- .../Hosting/McpServerHost.cs | 3 +- .../Hosting/ServiceRegistration.cs | 3 + .../Line.OpenApi.Tools.csproj | 9 + .../Mcp/FlexPreviewTools.cs | 69 +++ .../Services/FlexPreviewService.cs | 501 +++++++++++++++++ tools/Line.OpenApi.Tools/web/flex.css | 334 +++++++++++ tools/Line.OpenApi.Tools/web/renderer.js | 520 ++++++++++++++++++ tools/Line.OpenApi.Tools/web/samples.js | 207 +++++++ tools/Line.OpenApi.Tools/web/viewer.html | 46 ++ tools/Line.OpenApi.Tools/web/viewer.js | 207 +++++++ tools/README.md | 20 +- tools/README_ja.md | 19 +- 32 files changed, 5144 insertions(+), 6 deletions(-) create mode 100644 docs/reviews/2026-09-03-flex-preview-review.md create mode 100644 extensions/line-flex-viewer/README.ja.md create mode 100644 extensions/line-flex-viewer/README.md create mode 100644 extensions/line-flex-viewer/copilot-extension.json create mode 100644 extensions/line-flex-viewer/extension.mjs create mode 100644 extensions/line-flex-viewer/mcp/package.json create mode 100644 extensions/line-flex-viewer/mcp/server.mjs create mode 100644 extensions/line-flex-viewer/web/flex.css create mode 100644 extensions/line-flex-viewer/web/renderer.js create mode 100644 extensions/line-flex-viewer/web/samples.js create mode 100644 extensions/line-flex-viewer/web/standalone.html create mode 100644 extensions/line-flex-viewer/web/standalone.js create mode 100644 extensions/line-flex-viewer/web/viewer.html create mode 100644 extensions/line-flex-viewer/web/viewer.js create mode 100644 tests/Line.OpenApi.Tools.Tests/FlexPreviewServiceTests.cs create mode 100644 tests/Line.OpenApi.Tools.Tests/FlexWebAssetsParityTests.cs create mode 100644 tools/Line.OpenApi.Tools/Mcp/FlexPreviewTools.cs create mode 100644 tools/Line.OpenApi.Tools/Services/FlexPreviewService.cs create mode 100644 tools/Line.OpenApi.Tools/web/flex.css create mode 100644 tools/Line.OpenApi.Tools/web/renderer.js create mode 100644 tools/Line.OpenApi.Tools/web/samples.js create mode 100644 tools/Line.OpenApi.Tools/web/viewer.html create mode 100644 tools/Line.OpenApi.Tools/web/viewer.js diff --git a/.gitignore b/.gitignore index e2785fb..95c6285 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,6 @@ docs/SESSION-HANDOFF.md # Conceptual articles (en/, ja/) and config (docfx.json, filterConfig.yml) are tracked. docs/manual/api/ docs/manual/_site/ + +# Flex preview contribution bundle (staging only; integrated into extensions/ and tools/, not committed) +dotnet-flex-preview-mcp/ diff --git a/README.md b/README.md index d7762be..c4eb35d 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,19 @@ line mcp # start as an MCP server See [`tools/README.md`](https://github.com/pierre3/line-openapi-dotnet/blob/main/tools/README.md) ([日本語](https://github.com/pierre3/line-openapi-dotnet/blob/main/tools/README_ja.md)) for details. +### LINE Flex Message viewer (Copilot canvas extension) + +Preview a LINE Flex Message in a live, LINE-faithful browser view. The `line` MCP tool exposes this +as `line_flex_*` (read-only-safe, no LINE API / secrets). The **same** renderer also ships as a +Copilot CLI canvas extension — install it from this repo: + +``` +install_extension https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer +``` + +Or use the bundled `mcp/server.mjs` from Claude Desktop/Code, or open `web/standalone.html` in any +browser. See [`extensions/line-flex-viewer/`](https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer). + ## AI tools (`Line.OpenApi.Extensions.AI`) `Line.OpenApi.Extensions.AI` wraps the Messaging use case as [Microsoft.Extensions.AI](https://learn.microsoft.com/dotnet/ai/) `AIFunction` tools, so an LLM agent built on Semantic Kernel or any Microsoft.Extensions.AI host can operate LINE **in-process** (this complements the CLI/MCP tool, which runs out-of-process). It depends only on `Line.OpenApi.Messaging` and `Microsoft.Extensions.AI.Abstractions`. diff --git a/README_ja.md b/README_ja.md index 5acf005..5bc9ed9 100644 --- a/README_ja.md +++ b/README_ja.md @@ -313,6 +313,20 @@ line mcp # MCP サーバとして起動 詳細は [`tools/README.md`](https://github.com/pierre3/line-openapi-dotnet/blob/main/tools/README.md)([日本語](https://github.com/pierre3/line-openapi-dotnet/blob/main/tools/README_ja.md))を参照してください。 +### LINE Flex Message ビューア(Copilot canvas 拡張) + +LINE Flex Message を、LINE アプリに近い見た目でブラウザにライブプレビューできます。`line` MCP ツールは +これを `line_flex_*`(read-only 安全・LINE API/シークレット非使用)として公開します。**同じ**レンダラは +Copilot CLI の canvas 拡張としても提供しており、このリポジトリのフォルダ URL から導入できます。 + +``` +install_extension https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer +``` + +あるいは Claude Desktop/Code から同梱の `mcp/server.mjs` を使う、または任意のブラウザで +`web/standalone.html` を開く方法もあります。詳細は +[`extensions/line-flex-viewer/`](https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer) を参照してください。 + ## AI ツール(`Line.OpenApi.Extensions.AI`) `Line.OpenApi.Extensions.AI` は Messaging 利用シーンを [Microsoft.Extensions.AI](https://learn.microsoft.com/dotnet/ai/) の `AIFunction` ツールとしてラップし、Semantic Kernel や任意の Microsoft.Extensions.AI ホストから LLM エージェントが LINE を **アプリ内 in-process** で操作できるようにします(別プロセスで動く上記 CLI/MCP ツールを補完する関係です)。依存は `Line.OpenApi.Messaging` と `Microsoft.Extensions.AI.Abstractions` の 2 本のみです。 diff --git a/docs/reviews/2026-09-03-flex-preview-review.md b/docs/reviews/2026-09-03-flex-preview-review.md new file mode 100644 index 0000000..258046a --- /dev/null +++ b/docs/reviews/2026-09-03-flex-preview-review.md @@ -0,0 +1,83 @@ +# G-gate review — LINE Flex Message live preview (`line_flex_*` MCP + canvas extension) + +- **日付:** 2026-09-03 +- **ブランチ:** `feat-flex-preview` +- **対象:** `dotnet-flex-preview-mcp/AGENT_TASK.md` の取り込み。Deliverable A(Copilot canvas 拡張 `extensions/line-flex-viewer/`)+ Deliverable B(`line` CLI/MCP ツールへの `line_flex_*` 追加=ループバック `HttpListener`+SSE プレビュー `FlexPreviewService`)。 +- **レビュー対象ファイル:** `tools/Line.OpenApi.Tools/Services/FlexPreviewService.cs`・`Mcp/FlexPreviewTools.cs`・`Hosting/{ServiceRegistration,McpServerHost}.cs`・`Line.OpenApi.Tools.csproj`・`tests/Line.OpenApi.Tools.Tests/McpToolRegistrationTests.cs`・`extensions/line-flex-viewer/`(配線のみ)。 +- **制約:** bundle 由来の web レンダラ・canvas 拡張・`FlexPreviewService.cs` は AGENT_TASK.md で **verbatim(byte-for-byte)コピー**契約。 + +## 検証結果(実施済み) + +- `dotnet build tools/Line.OpenApi.Tools` = 0 warning / 0 error。 +- 埋め込みリソース = `Line.OpenApi.Tools.web.{viewer.html,viewer.js,renderer.js,flex.css,samples.js}` 5本を manifest で確認。 +- MCP `tools/list` = `line_flex_preview`/`get_content`/`validate`/`open` の4本が既定(51本)でも `--read-only`(29本・変更系は消える)でも広告。 +- 機能スモーク = `line_flex_preview`(最小 bubble)→ `{ok:true, valid:true, warnings:[], opened:false}`(`LINE_FLEX_MCP_NO_OPEN=1`)。ループバック URL が `/`(viewer)・`/renderer.js`・`/flex.css`(200)・`/api/state`(内容ラウンドトリップ)を配信。 +- 全テスト緑(264 lib + 83 Tools + 26 AI + 1 Isolation)。`pack-verify` = 12パッケージ契約維持(Tools は `ExcludeToolFromPack` で除外)。 + +## 3役ゲート結論 + +| 役 | 判定 | BLOCKING | +| --- | --- | --- | +| code-reviewer | CONCERNS | なし(High なし・Medium 複数) | +| security-reviewer | PASS | なし(非ブロッキング CONCERNS のみ) | +| test-arch-reviewer | CONCERNS | なし | + +### security(PASS) + +- 秘密情報・LINE API・トークンの読取/保存/ログ/返却は皆無 → `--read-only` 公開は妥当。timing 比較・R1(BaseUrl) は該当なし。 +- ループバック束縛 `http://127.0.0.1:{port}/` 固定(ワイルドカードなし)。静的配信は厳格ホワイトリスト(`Array.IndexOf`)+埋め込みリソースの suffix 解決のみ=パストラバーサル不可。state 書込先は固定 `content.json`(リクエストで経路操作不可)。ブラウザ起動 URL は内部生成(int port)=コマンド/引数インジェクションなし。 +- 非ブロッキング指摘: + - **[Medium] `/api/state` POST が未認証・Origin/Host 未検証** → 他ローカルページからの CSRF 書換で preview 内容差し替えの可能性(`line_flex_get_content` 経由で LLM 送信フローが攻撃者 JSON を拾う恐れ。送信自体は別 write ツール+ユーザー操作が必要で影響限定)。 + - **[Low] Host 未検証で DNS リバインドによる下書き読取**/**[Low] POST ボディ無制限(ループバック DoS)**。 + - 推奨: `/api/*` に `Host` ヘッダ(POST は `Origin`)検証を足すと CSRF-write と rebind-read を同時に塞げる。 + - Informational: LLM/CSRF 由来の Flex JSON を `renderer.js` が描画 → renderer のサニタイズは別レビュー(本 PR のスコープ外・verbatim)。 + +### code(CONCERNS・High なし) + +- Positive: ループバック限定・ホワイトリスト・DI singleton・埋め込みリソース配線は既存規約に忠実。read-only 分類は正しくテストで両面ガード。 +- Medium: + 1. **`Open()` が閉じたタブを再オープンできない**(`_opened` が初回で永続 latch → `line_flex_open` の「(re)open」説明とデッドコード矛盾。Node 参照 `server.mjs` は常に open)。 + 2. **SSE ライフサイクル**: keepalive ping なし・切断時クリーンアップなし・`Dispose` が開いた SSE レスポンスを閉じない(Node 参照にはある)。 + 3. **サービス層テスト不在**(`Validate`/`Normalize` は純粋関数で容易。`Open()` バグはテストがあれば捕捉できた)。 + 4. **リポジトリ衛生**: 未追跡の `dotnet-flex-preview-mcp/`(`AGENT_TASK.md`・`_verify/bin,obj` 含む)が commit に紛れうる → gitignore/除外。 +- Low: `validate` が不正 JSON で throw(`{valid:false,...}` を返さない)/`Normalize` が array を弾くのにメッセージは "JSON object"/`RenderIndex` の marker 依存が無言 no-op 化しうる/port TOCTOU(ループバックで実害軽微)。 + +### test-arch(CONCERNS・非ブロッキング) + +- read-only 安全性(変更系が `--read-only` で出ない)は良く守られており、依存・pack 契約・read-only 登録は健全。 +- ギャップ: `FlexPreviewService` の純粋な検証/永続ロジックに直接の単体テストが無い(`internal`+`InternalsVisibleTo` で到達可能・HTTP/ブラウザ不要でテスト可)。推奨(高→低): `Validate`/`ValidateInput`(bubble/carousel 1..12 境界/非 bubble/flex ラッパ/null)・`Normalize` ガード・state ラウンドトリップ・(任意)ループバック route スモーク。 +- web アセット重複(`tools/.../web` と `extensions/.../web` が byte 同一)は DRY ハザード。task が verbatim を要求するためブロッキングではないが、**両 `web/` の共有サブセットが byte 同一であることを検査する CI/テストガード**を推奨(単一ソース化より低コストで退行検知)。 +- 第3のツール型(`FlexPreviewTools`)として登録するのは妥当(LINE の read でも write でもない)。`WithTools()` 付近に「Read/Write 二分の外に置く理由」を1行コメント推奨。 + +## 人の go/no-go → 指摘反映(人の判断で修正実施) + +3役とも BLOCKING なし。人の go/no-go で「verbatim 契約下の `FlexPreviewService.cs` も修正する/非 verbatim 改善も全て PR 前に実施」を選択。以下を反映済み。 + +**`FlexPreviewService.cs`(.NET 専用ファイル=4面共有の web レンダラとは別。bundle 側コピーも同期しドリフト回避):** + +- **[code Medium] `Open()` 再オープン不可を修正** — 明示ユーザー操作として常にブラウザを開く(`_autoOpen`=`LINE_FLEX_MCP_NO_OPEN` は尊重)。once-only latch は `Preview` 専用に限定。 +- **[security Medium/Low] `/api/*` に Host/Origin 検証を追加** — Host ヘッダがバインド先ループバック権限(`127.0.0.1:`/`localhost` 別名)でなければ 403(DNS リバインド読取を遮断)。POST は Origin があり同一オリジンでなければ 403(CSRF 書換を遮断)。`GET /`・静的アセットは非対象(機密なし)。 +- **[code Low] `line_flex_validate` を非 throw 化** — 不正 JSON/スカラーを `{valid:false, warnings:[]}` で返す(ツール契約順守)。 +- **[code Low] `Normalize` メッセージ修正**("JSON object or array")。 +- **[code Medium 一部] `Dispose` が開いた SSE レスポンスを閉じる**ように。 + +**非 verbatim(テスト・CI ガード・衛生):** + +- **[test-arch HIGH] サービス層単体テスト追加** `tests/.../FlexPreviewServiceTests.cs`(13 件)= `ValidateInput`/`Validate`/`Normalize`(bubble/flex ラッパ/carousel 1・12・0・13/非 bubble/任意オブジェクト/不正 JSON/スカラー/null)+ state ラウンドトリップ。 +- **[test-arch] 両 `web/` byte 同一 CI ガード** `tests/.../FlexWebAssetsParityTests.cs`(共有5ファイルの SHA-256 一致を検査)。 +- **[code Medium] staging バンドル gitignore** — `.gitignore` に `dotnet-flex-preview-mcp/` を追加(`AGENT_TASK.md`・`_verify/bin,obj` 等が commit に紛れない)。 + +**修正後の再検証:** + +- ビルド 0 warning / 0 error。全テスト緑(264 lib + **97** Tools + 26 AI + 1 Isolation)。 +- Host/Origin ガード e2e 確認: 正常 GET `/api/state`=200/Host 詐称=403/同一オリジン POST=200/クロスオリジン POST=403/`/`・`/renderer.js`=200。 + +**残(follow-up・非ブロッキング):** + +- SSE keepalive ping(Node 参照 `server.mjs` にはある定期ハートビート=切断検知兼用)。今回は `Dispose` クリーンアップのみ実施。 +- `RenderIndex` の marker 未検出時の無言 no-op(drift 検知の throw/log)/port 選択 TOCTOU(ループバックで実害軽微)。 +- `/api/*` の per-instance トークン化・POST ボディサイズ上限(GA 前の追加ハードニング候補)。 +- renderer.js の XSS サニタイズは renderer 自体のレビュー範囲(本 PR は verbatim・スコープ外)。 +- web レンダラの単一ソース化(現状は byte 同一ガードで drift を検知)。 + +**総合:** 3役 BLOCKING なし+指摘反映済み → **GO(実装完了・PR 可)。** diff --git a/extensions/line-flex-viewer/README.ja.md b/extensions/line-flex-viewer/README.ja.md new file mode 100644 index 0000000..d22ca9b --- /dev/null +++ b/extensions/line-flex-viewer/README.ja.md @@ -0,0 +1,153 @@ +# LINE Flex Message ビューア(Canvas 拡張) + +*English version: [README.md](./README.md).* + +[LINE Flex Message](https://developers.line.biz/ja/docs/messaging-api/using-flex-messages/) +の JSON を、アプリのサイドパネルで LINE 風にライブレンダリングしながらプレビュー・調整できる +Copilot CLI の **Canvas 拡張**です。 + +同じレンダラを 3 通りの形で再利用しています。 + +- **Canvas 拡張** — Copilot App のサイドパネル内でライブプレビュー。 +- **standalone ページ** — Copilot App 不要の 100% クライアントサイドなブラウザプレビュー。 +- **MCP サーバ** — 任意の MCP クライアント(Claude Desktop / Claude Code など)向けのライブブラウザプレビュー。 + +## 想定ワークフロー + +1. AI/エージェントが Flex Message JSON を組み立てる(例: + [`Line.OpenApi.Tools`](https://github.com/pierre3/line-openapi-dotnet) の MCP サーバ経由)。 +2. エージェントが **`set_content`** アクションで Canvas に push すると、パネルが即座に描画。 +3. パネルのエディタで JSON を直接調整(ライブ再描画+自動保存)。 +4. エージェントが **`get_content`** で編集結果を読み戻し、反復を続ける。 +5. 拡張をプライベート gist で公開/共有(下記参照)。 + +## Canvas アクション(エージェント向け) + +| アクション | 用途 | +| ------------- | ---- | +| `set_content` | Canvas に表示する Flex JSON を設定して再描画。flex メッセージ全体(`{type:"flex",altText,contents}`)、`bubble`/`carousel` コンテナ単体、または JSON 文字列を受け付ける。`{ ok, valid, warnings }` を返す。 | +| `get_content` | 現在 Canvas にある JSON を返す(**パネル上でのユーザー編集も反映**)。 | +| `validate` | 現在(または渡した)JSON の軽量な構造チェック。 | + +### open 入力 + +```jsonc +{ + "docId": "my-doc", // 任意: 安定した id。再オープン時に内容を復元 + "content": { /* ... */ }, // 任意: 初期 Flex JSON + "altText": "..." // 任意 +} +``` + +## パネル UI + +- **左**: JSON エディタ(ライブ再描画、`Ctrl/Cmd+Enter` で強制描画、`Tab` でスペース挿入)。 +- **右**: チャット背景上の LINE 風プレビュー(light/dark 切替)。 +- ツールバー: **Render**(プレビュー更新)/ **Format**(整形)/ **Copy JSON**(JSON コピー)/ + **Load sample**(サンプル読込)/ **Toggle background**(背景切替)。 + +## レンダリング対応範囲 + +コンテナ `bubble`(nano〜giga)と `carousel`、ブロック `header`/`hero`/`body`/`footer`(`styles` 対応)、 +コンポーネント `box`(horizontal/vertical/baseline)、`text`、`span`、`image`、`button`、`icon`、 +`separator`、`filler`、`video`(プレビュー)。多くのレイアウト/スタイルプロパティに対応 +(`flex`、`spacing`、`margin`、padding、borders、`cornerRadius`、`justifyContent`、`alignItems`、 +`position`/`offset`、`gravity`、`align`、`wrap`、`maxLines`、`aspectRatio`/`aspectMode` など)。 + +> プレビューは LINE レンダラの **CSS による近似**です。キーワード→px のサイズは LINE の +> ドキュメント上の尺度に従いますが、厳密なピクセル値は LINE アプリと多少異なる場合があります。 + +## 状態/ストレージ + +内容は `$COPILOT_HOME/extensions/line-flex-viewer/artifacts/.json` +(リポジトリ内には書きません)に `docId` をキーとして永続化され、再オープン時に復元されます。 + +## Copilot App 不要のブラウザプレビュー(standalone) + +Canvas プレビューの実体は **ローカル Web アプリ**で、Copilot App の実行中は開いているパネルごとに +ループバック HTTP サーバ(`127.0.0.1:<ランダムポート>`)が立ち上がります。**Copilot App なし**で +プレビューしたい場合は、100% クライアントサイド(サーバもエージェントも不要)で同じレンダラを使う +standalone ページを利用します。 + +- **直接開く**: `web/standalone.html` をダブルクリック(`file://` で動作)。 +- **または静的配信**(推奨。一部ブラウザは `file://` を制限): + + ```bash + cd .github/extensions/line-flex-viewer/web + python -m http.server 8791 # → http://127.0.0.1:8791/standalone.html + # または: npx serve . + ``` + +standalone の機能: + +- ライブエディタ+ LINE 風プレビュー、**Format** / **Copy JSON** / **Load sample** / **Toggle background**。 +- **Open file / Download** — Flex JSON を `.json` ファイルとして入出力。 +- **Share link** — 現在の JSON を URL(`#json=`)にエンコードしてクリップボードにコピー。 + アプリもサーバ永続化も不要で、内容はリンク自体に含まれるため、誰とでもプレビューリンクを共有可能。 +- `localStorage` に自動保存し、リロード時に直近の編集を復元。 + +読み込み時のシード優先順位: URL の `#json=` ハッシュ > `localStorage` > 先頭サンプル。 + +Copilot App が起動している間は、同じ standalone ページに +`http://127.0.0.1:<パネルポート>/standalone.html`(パネル URL + `/standalone.html`)でもアクセスできます。 + +## Claude(Desktop / Code)など任意の MCP クライアントで使う + +Claude Desktop / Claude Code は Canvas ではなく **MCP サーバ**で拡張します。本リポジトリには +同じレンダラを使う MCP サーバ(`mcp/server.mjs`、**Node 標準機能のみ・依存パッケージゼロ**)を同梱しています。 +AI が Flex JSON を組み立てて `preview_flex_message` を呼ぶと、ローカルにプレビュー用サーバを立ち上げ、 +**既定のブラウザでライブプレビュー**を開きます(以降の更新は SSE で自動反映)。 + +### 提供する MCP ツール + +| ツール | 説明 | +| ---- | ---- | +| `preview_flex_message` | Flex JSON(flex メッセージ / bubble / carousel / JSON 文字列)をブラウザでプレビュー。初回はブラウザを自動で開き、以降はライブ更新。`{ url, valid, warnings }` を返す。 | +| `get_flex_content` | プレビュー中の JSON を返す(**ブラウザ上でのユーザー編集も反映**)。 | +| `validate_flex_message` | JSON を構造検証(ブラウザ不要)。`{ valid, warnings }`。 | +| `open_preview` | 内容を変えずにプレビュータブを開き直し URL を返す。 | + +### セットアップ + +必要なのは Node.js 18+ だけです(`npm install` 不要)。`` は本リポジトリを配置した絶対パスに置き換えてください。 + +**Claude Desktop** — `claude_desktop_config.json`(macOS: `~/Library/Application Support/Claude/`、 +Windows: `%APPDATA%\Claude\`)に追記: + +```jsonc +{ + "mcpServers": { + "line-flex-viewer": { + "command": "node", + "args": ["/.github/extensions/line-flex-viewer/mcp/server.mjs"] + } + } +} +``` + +**Claude Code** — CLI で登録: + +```bash +claude mcp add line-flex-viewer -- node /.github/extensions/line-flex-viewer/mcp/server.mjs +``` + +登録後、Claude に「この Flex Message をプレビューして」と頼めば `preview_flex_message` が呼ばれ、 +ブラウザにプレビューが表示されます。ブラウザ側で JSON を微調整すると、Claude は +`get_flex_content` で編集後の内容を読み戻せます。 + +### 環境変数(任意) + +| 変数 | 既定 | 用途 | +| ---- | ---- | ---- | +| `LINE_FLEX_MCP_NO_OPEN` | (未設定) | セットするとブラウザ自動起動を無効化(URL は返す)。 | +| `LINE_FLEX_MCP_STATE_DIR` | OS の一時ディレクトリ | プレビュー内容の保存先。 | +| `LINE_FLEX_MCP_HTML` | `viewer.html` | 配信するページ。`standalone.html` に変更も可。 | + +> **MCP に興味がない場合**は、上記「Copilot App 不要のブラウザプレビュー(standalone)」だけでも +> ブラウザ単体で完結します。MCP サーバは「AI ↔ プレビュー」を自動連携したいとき向けです。 + +## 公開/共有 + +このフォルダには `copilot-extension.json` が含まれているため、コマンドパレットの +「Share extension as gist…」または `share_extension` ツールでプライベート gist として共有でき、 +別環境で「Install extension from gist…」/ `install_extension` からインストールできます。 diff --git a/extensions/line-flex-viewer/README.md b/extensions/line-flex-viewer/README.md new file mode 100644 index 0000000..b83d8d5 --- /dev/null +++ b/extensions/line-flex-viewer/README.md @@ -0,0 +1,157 @@ +# LINE Flex Message Viewer (Canvas Extension) + +*Read this in [日本語](./README.ja.md).* + +A Copilot CLI **canvas extension** that previews and lets you tweak +[LINE Flex Message](https://developers.line.biz/en/docs/messaging-api/using-flex-messages/) +JSON with a live, LINE-style render in the app's side panel. + +The same renderer is reused three ways: + +- **Canvas extension** — live preview inside the Copilot App side panel. +- **Standalone page** — a 100% client-side browser preview, no Copilot App required. +- **MCP server** — a live browser preview for any MCP client (e.g. Claude Desktop / Claude Code). + +## Intended workflow + +1. An AI/agent builds Flex Message JSON — e.g. via the + [`Line.OpenApi.Tools`](https://github.com/pierre3/line-openapi-dotnet) MCP server. +2. The agent pushes it to the canvas with the **`set_content`** action; the panel + renders it instantly. +3. You tweak the JSON directly in the panel editor (live re-render + auto-save). +4. The agent reads your edits back with **`get_content`** and continues iterating. +5. Publish/share the extension via a private gist (see below). + +## Canvas actions (agent-facing) + +| Action | Purpose | +| ------------- | ------- | +| `set_content` | Set the Flex JSON shown in the canvas and re-render. Accepts a full flex message (`{type:"flex",altText,contents}`), a bare `bubble`/`carousel` container, or a JSON string. Returns `{ ok, valid, warnings }`. | +| `get_content` | Return the JSON currently in the canvas, **including the user's panel edits**. | +| `validate` | Lightweight structural check of the current (or supplied) JSON. | + +### Open input + +```jsonc +{ + "docId": "my-doc", // optional: stable id; reopening restores content + "content": { /* ... */ }, // optional: initial Flex JSON + "altText": "..." // optional +} +``` + +## Panel UI + +- **Left**: JSON editor (live re-render, `Ctrl/Cmd+Enter` to force render, `Tab` inserts spaces). +- **Right**: LINE-style preview on a chat background (toggle light/dark). +- Toolbar: **Render** / **Format** / **Copy JSON** / **Load sample** / **Toggle background**. + +## Rendering support + +Containers `bubble` (nano–giga) and `carousel`; blocks `header`/`hero`/`body`/`footer` +with `styles`; components `box` (horizontal/vertical/baseline), `text`, `span`, +`image`, `button`, `icon`, `separator`, `filler`, and `video` (preview). Most +layout/style properties are honored (`flex`, `spacing`, `margin`, padding, borders, +`cornerRadius`, `justifyContent`, `alignItems`, `position`/`offset`, `gravity`, +`align`, `wrap`, `maxLines`, `aspectRatio`/`aspectMode`, etc.). + +> The preview is a **CSS approximation** of LINE's renderer. Keyword→px sizes follow +> LINE's documented scale but exact pixel metrics may differ slightly from the LINE app. + +## State / storage + +Content persists under `$COPILOT_HOME/extensions/line-flex-viewer/artifacts/.json` +(never inside the repo), keyed by `docId` so reopening restores it. + +## Browser preview without the Copilot App (standalone) + +The canvas preview is really a **local web app** — the extension spins up a +loopback HTTP server (`127.0.0.1:`) per open panel while the Copilot +App is running. To preview **without the Copilot App**, use the standalone page, +which runs 100% client-side (no server, no agent) and reuses the same renderer: + +- **Open directly**: double-click `web/standalone.html` (works over `file://`). +- **Or serve statically** (recommended; some browsers restrict `file://`): + + ```bash + cd .github/extensions/line-flex-viewer/web + python -m http.server 8791 # → http://127.0.0.1:8791/standalone.html + # or: npx serve . + ``` + +Standalone features: + +- Live editor + LINE-style preview, **Format** / **Copy JSON** / **Load sample** / **Toggle background**. +- **Open file / Download** — import/export Flex JSON as a `.json` file. +- **Share link** — encodes the current JSON into the URL (`#json=`) and + copies it to the clipboard, so you can share a preview link with anyone (no app, + no server persistence — the content lives in the link itself). +- Auto-saves to `localStorage`, so your last edit is restored on reload. + +Seed priority on load: URL `#json=` hash > `localStorage` > first sample. + +While the Copilot App *is* running, the same standalone page is also reachable at +`http://127.0.0.1:/standalone.html` (the panel URL with `/standalone.html`). + +## Use from Claude (Desktop / Code) or any MCP client + +Claude Desktop / Claude Code extend via **MCP servers**, not canvases. This repo +bundles an MCP server (`mcp/server.mjs`, **Node built-ins only — zero dependencies**) +that reuses the same renderer. When the AI builds Flex JSON and calls +`preview_flex_message`, the server starts a local preview server and opens a +**live preview in your default browser** (subsequent updates stream in over SSE). + +### MCP tools + +| Tool | Description | +| ---- | ---- | +| `preview_flex_message` | Preview Flex JSON (flex message / bubble / carousel / JSON string) in the browser. Opens the browser on first use, then live-updates. Returns `{ url, valid, warnings }`. | +| `get_flex_content` | Return the JSON currently in the preview (**including the user's browser edits**). | +| `validate_flex_message` | Structurally validate JSON (no browser needed). Returns `{ valid, warnings }`. | +| `open_preview` | Open/reopen the preview tab and return its URL, without changing content. | + +### Setup + +You only need Node.js 18+ (`npm install` is not required). Replace `` with the +absolute path where you placed this repository. + +**Claude Desktop** — add to `claude_desktop_config.json` (macOS: +`~/Library/Application Support/Claude/`, Windows: `%APPDATA%\Claude\`): + +```jsonc +{ + "mcpServers": { + "line-flex-viewer": { + "command": "node", + "args": ["/.github/extensions/line-flex-viewer/mcp/server.mjs"] + } + } +} +``` + +**Claude Code** — register via the CLI: + +```bash +claude mcp add line-flex-viewer -- node /.github/extensions/line-flex-viewer/mcp/server.mjs +``` + +Once registered, ask Claude to "preview this Flex Message" and `preview_flex_message` +runs, showing the preview in your browser. Tweak the JSON in the browser and Claude +can read your edits back with `get_flex_content`. + +### Environment variables (optional) + +| Variable | Default | Purpose | +| ---- | ---- | ---- | +| `LINE_FLEX_MCP_NO_OPEN` | (unset) | Set to disable auto-opening the browser (the URL is still returned). | +| `LINE_FLEX_MCP_STATE_DIR` | OS temp dir | Where preview content is persisted. | +| `LINE_FLEX_MCP_HTML` | `viewer.html` | Which page to serve (can be changed to `standalone.html`). | + +> **Not interested in MCP?** The standalone browser preview above works entirely on +> its own. The MCP server is for when you want to automate the "AI ↔ preview" loop. + +## Publish / share + +This folder includes `copilot-extension.json`, so it can be shared as a private gist +from the command palette ("Share extension as gist…") or the `share_extension` tool, +and installed elsewhere with "Install extension from gist…" / `install_extension`. diff --git a/extensions/line-flex-viewer/copilot-extension.json b/extensions/line-flex-viewer/copilot-extension.json new file mode 100644 index 0000000..af183ef --- /dev/null +++ b/extensions/line-flex-viewer/copilot-extension.json @@ -0,0 +1,4 @@ +{ + "name": "line-flex-viewer", + "version": 1 +} diff --git a/extensions/line-flex-viewer/extension.mjs b/extensions/line-flex-viewer/extension.mjs new file mode 100644 index 0000000..bae124f --- /dev/null +++ b/extensions/line-flex-viewer/extension.mjs @@ -0,0 +1,398 @@ +// Extension: line-flex-viewer +// LINE Flex Message previewer/editor canvas. +// +// Flow: an agent (e.g. via the Line.OpenApi.Tools MCP server) builds Flex +// Message JSON and pushes it to the canvas with the `set_content` action; the +// user previews and tweaks it in the side panel; the agent reads back the +// user's edits with `get_content`. +// +// Wiring only. The renderer, UI, and samples live under ./web and are served +// by a per-instance loopback HTTP server. + +import { createServer } from "node:http"; +import { readFile, mkdir, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join, extname, resolve as resolvePath } from "node:path"; +import { homedir } from "node:os"; +import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const WEB_DIR = join(__dirname, "web"); + +// Durable artifact storage lives under COPILOT_HOME, never inside the repo. +const COPILOT_HOME = process.env.COPILOT_HOME || join(homedir(), ".copilot"); +const ARTIFACT_DIR = join(COPILOT_HOME, "extensions", "line-flex-viewer", "artifacts"); + +const STATIC_FILES = new Set(["viewer.html", "viewer.js", "renderer.js", "flex.css", "samples.js", "standalone.html", "standalone.js"]); +const CONTENT_TYPES = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", +}; + +// instanceId -> { server, url, docId, content, clients:Set, instanceId } +const instances = new Map(); + +let logFn = () => {}; + +// --- persistence --------------------------------------------------------- + +function safeDocId(docId) { + const s = String(docId || "default").replace(/[^A-Za-z0-9._-]/g, "_"); + return s.slice(0, 120) || "default"; +} + +function artifactPath(docId) { + return join(ARTIFACT_DIR, safeDocId(docId) + ".json"); +} + +async function loadContent(docId) { + try { + const raw = await readFile(artifactPath(docId), "utf8"); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" && "content" in parsed ? parsed.content : parsed; + } catch { + return null; + } +} + +async function saveContent(docId, content) { + await mkdir(ARTIFACT_DIR, { recursive: true }); + await writeFile(artifactPath(docId), JSON.stringify({ content }, null, 2), "utf8"); +} + +// Accept a message wrapper, a bare container, or a JSON string; return an object. +function normalizeContent(input) { + let value = input; + if (typeof value === "string") { + value = JSON.parse(value); + } + if (!value || typeof value !== "object") { + throw new CanvasError("invalid_content", "content must be an object or JSON string"); + } + return value; +} + +// --- structural validation (lightweight) --------------------------------- + +function extractContainer(json) { + if (!json || typeof json !== "object") return null; + if (json.type === "flex" && json.contents && typeof json.contents === "object") return json.contents; + if (json.type === "bubble" || json.type === "carousel") return json; + if (json.contents && (json.contents.type === "bubble" || json.contents.type === "carousel")) { + return json.contents; + } + return null; +} + +function validateContent(json) { + const warnings = []; + const container = extractContainer(json); + if (!container) { + warnings.push('root must be a "bubble"/"carousel" container or a type:"flex" message'); + return { valid: false, warnings }; + } + let bubbles = []; + if (container.type === "carousel") { + if (!Array.isArray(container.contents) || container.contents.length === 0) { + warnings.push("carousel.contents is empty"); + } else { + if (container.contents.length > 12) warnings.push("carousel supports at most 12 bubbles"); + bubbles = container.contents; + } + } else { + bubbles = [container]; + } + bubbles.forEach((b, i) => { + if (!b || b.type !== "bubble") { + warnings.push(`contents[${i}] is not a bubble`); + return; + } + if (!b.header && !b.hero && !b.body && !b.footer) { + warnings.push(`bubble[${i}] has no header/hero/body/footer block`); + } + }); + return { valid: warnings.length === 0, warnings }; +} + +// --- SSE broadcast ------------------------------------------------------- + +function broadcast(entry) { + const payload = `event: content\ndata: ${JSON.stringify({ content: entry.content })}\n\n`; + for (const res of entry.clients) { + try { + res.write(payload); + } catch { + /* ignore broken pipe */ + } + } +} + +// --- HTTP server --------------------------------------------------------- + +function renderIndex(html, entry) { + const cfg = { docId: entry.docId, instanceId: entry.instanceId }; + const script = ``; + return html.replace( + '', + script + '\n ' + ); +} + +function sendFile(res, filePath, contentType) { + readFile(filePath) + .then((buf) => { + res.writeHead(200, { "Content-Type": contentType }); + res.end(buf); + }) + .catch(() => { + res.writeHead(404); + res.end("not found"); + }); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + req.on("data", (c) => { + size += c.length; + if (size > 5 * 1024 * 1024) { + reject(new Error("payload too large")); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +function handleRequest(entry, req, res) { + const url = new URL(req.url, "http://127.0.0.1"); + const path = url.pathname; + + // Index (with injected config) + if (req.method === "GET" && (path === "/" || path === "/index.html")) { + readFile(join(WEB_DIR, "viewer.html"), "utf8") + .then((html) => { + res.writeHead(200, { "Content-Type": CONTENT_TYPES[".html"] }); + res.end(renderIndex(html, entry)); + }) + .catch(() => { + res.writeHead(500); + res.end("template error"); + }); + return; + } + + // Static assets (whitelisted) + if (req.method === "GET" && !path.startsWith("/api/")) { + const name = path.replace(/^\//, ""); + if (STATIC_FILES.has(name)) { + const filePath = resolvePath(WEB_DIR, name); + if (filePath.startsWith(resolvePath(WEB_DIR))) { + sendFile(res, filePath, CONTENT_TYPES[extname(name)] || "application/octet-stream"); + return; + } + } + res.writeHead(404); + res.end("not found"); + return; + } + + // GET current state + if (req.method === "GET" && path === "/api/state") { + res.writeHead(200, { "Content-Type": CONTENT_TYPES[".json"] }); + res.end(JSON.stringify({ docId: entry.docId, content: entry.content ?? null })); + return; + } + + // POST state (from the iframe editor) + if (req.method === "POST" && path === "/api/state") { + readBody(req) + .then(async (body) => { + const data = JSON.parse(body || "{}"); + entry.content = data.content ?? null; + await saveContent(entry.docId, entry.content); + res.writeHead(200, { "Content-Type": CONTENT_TYPES[".json"] }); + res.end(JSON.stringify({ ok: true })); + }) + .catch((e) => { + res.writeHead(400, { "Content-Type": CONTENT_TYPES[".json"] }); + res.end(JSON.stringify({ ok: false, error: String(e && e.message) })); + }); + return; + } + + // SSE stream + if (req.method === "GET" && path === "/api/events") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + res.write(": connected\n\n"); + entry.clients.add(res); + const keepAlive = setInterval(() => { + try { + res.write(": ping\n\n"); + } catch { + /* ignore */ + } + }, 25000); + req.on("close", () => { + clearInterval(keepAlive); + entry.clients.delete(res); + }); + return; + } + + res.writeHead(404); + res.end("not found"); +} + +async function startServer(entry) { + const server = createServer((req, res) => handleRequest(entry, req, res)); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + entry.server = server; + entry.url = `http://127.0.0.1:${port}/`; +} + +// --- canvas -------------------------------------------------------------- + +const canvas = createCanvas({ + id: "line-flex-viewer", + displayName: "LINE Flex Message Viewer", + description: + "Preview and edit LINE Flex Message JSON with a live LINE-style render. Push JSON with set_content, read the user's edits with get_content.", + inputSchema: { + type: "object", + properties: { + docId: { + type: "string", + description: "Stable document id; reopening the same id restores content.", + }, + content: { description: "Initial Flex Message JSON (message wrapper, bubble, or carousel)." }, + altText: { type: "string" }, + }, + additionalProperties: true, + }, + actions: [ + { + name: "set_content", + description: + "Set the Flex Message JSON shown in the canvas and re-render it. Accepts a full flex message ({type:'flex',altText,contents}), a bare bubble/carousel container, or a JSON string.", + inputSchema: { + type: "object", + properties: { + content: { description: "Flex Message JSON (object) or JSON string." }, + altText: { type: "string" }, + }, + required: ["content"], + additionalProperties: true, + }, + handler: async (ctx) => { + const entry = instances.get(ctx.instanceId); + if (!entry) throw new CanvasError("not_open", "canvas instance is not open"); + const content = normalizeContent(ctx.input && ctx.input.content); + entry.content = content; + await saveContent(entry.docId, content); + broadcast(entry); + const { valid, warnings } = validateContent(content); + logFn(`set_content on ${entry.docId}${valid ? "" : " (with warnings)"}`); + return { ok: true, valid, warnings }; + }, + }, + { + name: "get_content", + description: + "Return the Flex Message JSON currently in the canvas, including any edits the user made in the panel.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + handler: async (ctx) => { + const entry = instances.get(ctx.instanceId); + if (!entry) throw new CanvasError("not_open", "canvas instance is not open"); + return { content: entry.content ?? null }; + }, + }, + { + name: "validate", + description: + "Run a lightweight structural check on the current (or supplied) Flex Message JSON.", + inputSchema: { + type: "object", + properties: { + content: { description: "Optional JSON to validate instead of the current content." }, + }, + additionalProperties: true, + }, + handler: async (ctx) => { + const entry = instances.get(ctx.instanceId); + let content = entry ? entry.content : null; + if (ctx.input && ctx.input.content !== undefined) content = normalizeContent(ctx.input.content); + if (content == null) return { valid: false, warnings: ["no content to validate"] }; + return validateContent(content); + }, + }, + ], + open: async (ctx) => { + const input = ctx.input && typeof ctx.input === "object" ? ctx.input : {}; + const docId = safeDocId(input.docId || ctx.instanceId || "default"); + + let entry = instances.get(ctx.instanceId); + if (!entry) { + entry = { instanceId: ctx.instanceId, docId, content: null, clients: new Set() }; + instances.set(ctx.instanceId, entry); + await startServer(entry); + } + entry.docId = docId; + + // Seed content: explicit input wins, else durable storage, else keep as-is. + if (input.content !== undefined && input.content !== null) { + try { + entry.content = normalizeContent(input.content); + await saveContent(docId, entry.content); + } catch (e) { + logFn("open: invalid input.content ignored: " + (e && e.message)); + } + } else if (entry.content == null) { + entry.content = await loadContent(docId); + } + + broadcast(entry); + return { title: "LINE Flex Message Viewer", url: entry.url, status: docId }; + }, + onClose: async (ctx) => { + const entry = instances.get(ctx.instanceId); + if (!entry) return; + instances.delete(ctx.instanceId); + for (const res of entry.clients) { + try { + res.end(); + } catch { + /* ignore */ + } + } + if (entry.server) await new Promise((r) => entry.server.close(() => r())); + }, +}); + +const session = await joinSession({ canvases: [canvas] }); + +logFn = (msg) => { + // session.log returns a promise; swallow rejections so a logging failure + // can never turn into an unhandled rejection that crashes the extension. + try { + const p = session.log(`[line-flex-viewer] ${msg}`, { level: "info", ephemeral: true }); + if (p && typeof p.catch === "function") p.catch(() => {}); + } catch { + /* ignore */ + } +}; + +// Last-resort guard: never let an unexpected rejection crash the provider. +process.on("unhandledRejection", () => {}); diff --git a/extensions/line-flex-viewer/mcp/package.json b/extensions/line-flex-viewer/mcp/package.json new file mode 100644 index 0000000..c0c6a79 --- /dev/null +++ b/extensions/line-flex-viewer/mcp/package.json @@ -0,0 +1,15 @@ +{ + "name": "line-flex-viewer-mcp", + "version": "1.0.0", + "description": "MCP server that previews LINE Flex Message JSON in a live browser tab (Claude Desktop / Claude Code / any MCP client). Zero runtime dependencies.", + "type": "module", + "bin": { + "line-flex-viewer-mcp": "server.mjs" + }, + "main": "server.mjs", + "engines": { + "node": ">=18" + }, + "license": "MIT", + "keywords": ["mcp", "line", "flex-message", "preview", "claude"] +} diff --git a/extensions/line-flex-viewer/mcp/server.mjs b/extensions/line-flex-viewer/mcp/server.mjs new file mode 100644 index 0000000..9eab590 --- /dev/null +++ b/extensions/line-flex-viewer/mcp/server.mjs @@ -0,0 +1,470 @@ +#!/usr/bin/env node +/* + * LINE Flex Message Viewer — MCP server (stdio, zero-dependency). + * + * Lets any MCP client (Claude Desktop, Claude Code, etc.) preview LINE Flex + * Message JSON in a live browser tab. The AI builds Flex JSON and calls + * `preview_flex_message`; this server serves the shared renderer over a local + * loopback HTTP port, opens it in the default browser, and live-updates it via + * SSE. `get_flex_content` reads back the user's in-browser edits. + * + * Uses only Node.js built-ins — run with `node server.mjs`, no `npm install`. + * Reuses the same web assets as the Copilot canvas (../web). + */ + +import { createServer } from "node:http"; +import { readFile, mkdir, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join, extname, resolve as resolvePath } from "node:path"; +import { tmpdir } from "node:os"; +import { spawn } from "node:child_process"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const WEB_DIR = join(__dirname, "..", "web"); + +const STATE_DIR = process.env.LINE_FLEX_MCP_STATE_DIR || join(tmpdir(), "line-flex-mcp"); +const STATE_FILE = join(STATE_DIR, "content.json"); +const HTML_ENTRY = process.env.LINE_FLEX_MCP_HTML || "viewer.html"; // viewer.html = live push +const AUTO_OPEN = process.env.LINE_FLEX_MCP_NO_OPEN ? false : true; + +const STATIC_FILES = new Set([ + "viewer.html", "viewer.js", "renderer.js", "flex.css", "samples.js", + "standalone.html", "standalone.js", +]); +const CONTENT_TYPES = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", +}; + +const state = { + server: null, + url: null, + content: null, + clients: new Set(), + opened: false, +}; + +function log(...args) { + // stdout is reserved for JSON-RPC; everything human-facing goes to stderr. + process.stderr.write("[line-flex-mcp] " + args.map(String).join(" ") + "\n"); +} + +// --- persistence --------------------------------------------------------- + +async function loadContent() { + try { + const raw = await readFile(STATE_FILE, "utf8"); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" && "content" in parsed ? parsed.content : parsed; + } catch { + return null; + } +} + +async function saveContent(content) { + await mkdir(STATE_DIR, { recursive: true }); + await writeFile(STATE_FILE, JSON.stringify({ content }, null, 2), "utf8"); +} + +// Accept a message wrapper, a bare container, or a JSON string; return an object. +function normalizeContent(input) { + let value = input; + if (typeof value === "string") value = JSON.parse(value); + if (!value || typeof value !== "object") { + throw new Error("content must be an object or JSON string"); + } + return value; +} + +// --- structural validation ------------------------------------------------ + +function extractContainer(json) { + if (!json || typeof json !== "object") return null; + if (json.type === "flex" && json.contents && typeof json.contents === "object") return json.contents; + if (json.type === "bubble" || json.type === "carousel") return json; + if (json.contents && (json.contents.type === "bubble" || json.contents.type === "carousel")) { + return json.contents; + } + return null; +} + +function validateContent(json) { + const warnings = []; + const container = extractContainer(json); + if (!container) { + warnings.push('root must be a "bubble"/"carousel" container or a type:"flex" message'); + return { valid: false, warnings }; + } + let bubbles = []; + if (container.type === "carousel") { + if (!Array.isArray(container.contents) || container.contents.length === 0) { + warnings.push("carousel.contents is empty"); + } else { + if (container.contents.length > 12) warnings.push("carousel supports at most 12 bubbles"); + bubbles = container.contents; + } + } else { + bubbles = [container]; + } + bubbles.forEach((b, i) => { + if (!b || b.type !== "bubble") { + warnings.push(`contents[${i}] is not a bubble`); + return; + } + if (!b.header && !b.hero && !b.body && !b.footer) { + warnings.push(`bubble[${i}] has no header/hero/body/footer block`); + } + }); + return { valid: warnings.length === 0, warnings }; +} + +// --- SSE broadcast -------------------------------------------------------- + +function broadcast() { + const payload = `event: content\ndata: ${JSON.stringify({ content: state.content })}\n\n`; + for (const res of state.clients) { + try { + res.write(payload); + } catch { + /* ignore broken pipe */ + } + } +} + +// --- HTTP server ---------------------------------------------------------- + +function renderIndex(html) { + const cfg = { docId: "default", instanceId: "mcp" }; + const script = ``; + return html.replace( + '', + script + '\n ' + ); +} + +function sendFile(res, filePath, contentType) { + readFile(filePath) + .then((buf) => { + res.writeHead(200, { "Content-Type": contentType }); + res.end(buf); + }) + .catch(() => { + res.writeHead(404); + res.end("not found"); + }); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + req.on("data", (c) => { + size += c.length; + if (size > 5 * 1024 * 1024) { + reject(new Error("payload too large")); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +function handleRequest(req, res) { + const url = new URL(req.url, "http://127.0.0.1"); + const path = url.pathname; + + if (req.method === "GET" && (path === "/" || path === "/index.html")) { + readFile(join(WEB_DIR, HTML_ENTRY), "utf8") + .then((html) => { + res.writeHead(200, { "Content-Type": CONTENT_TYPES[".html"] }); + res.end(renderIndex(html)); + }) + .catch(() => { + res.writeHead(500); + res.end("template error"); + }); + return; + } + + if (req.method === "GET" && !path.startsWith("/api/")) { + const name = path.replace(/^\//, ""); + if (STATIC_FILES.has(name)) { + const filePath = resolvePath(WEB_DIR, name); + if (filePath.startsWith(resolvePath(WEB_DIR))) { + sendFile(res, filePath, CONTENT_TYPES[extname(name)] || "application/octet-stream"); + return; + } + } + res.writeHead(404); + res.end("not found"); + return; + } + + if (req.method === "GET" && path === "/api/state") { + res.writeHead(200, { "Content-Type": CONTENT_TYPES[".json"] }); + res.end(JSON.stringify({ docId: "default", content: state.content ?? null })); + return; + } + + if (req.method === "POST" && path === "/api/state") { + readBody(req) + .then(async (body) => { + const data = JSON.parse(body || "{}"); + state.content = data.content ?? null; + await saveContent(state.content); + res.writeHead(200, { "Content-Type": CONTENT_TYPES[".json"] }); + res.end(JSON.stringify({ ok: true })); + }) + .catch((e) => { + res.writeHead(400, { "Content-Type": CONTENT_TYPES[".json"] }); + res.end(JSON.stringify({ ok: false, error: String(e && e.message) })); + }); + return; + } + + if (req.method === "GET" && path === "/api/events") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + res.write(": connected\n\n"); + state.clients.add(res); + const keepAlive = setInterval(() => { + try { + res.write(": ping\n\n"); + } catch { + /* ignore */ + } + }, 25000); + req.on("close", () => { + clearInterval(keepAlive); + state.clients.delete(res); + }); + return; + } + + res.writeHead(404); + res.end("not found"); +} + +async function ensureServer() { + if (state.server) return state.url; + if (state.content == null) state.content = await loadContent(); + const server = createServer(handleRequest); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + state.server = server; + state.url = `http://127.0.0.1:${port}/`; + log("preview server listening at", state.url); + return state.url; +} + +function openBrowser(url) { + try { + let cmd, args; + if (process.platform === "win32") { + cmd = "cmd"; + args = ["/c", "start", "", url]; + } else if (process.platform === "darwin") { + cmd = "open"; + args = [url]; + } else { + cmd = "xdg-open"; + args = [url]; + } + const child = spawn(cmd, args, { detached: true, stdio: "ignore" }); + child.on("error", (e) => log("failed to open browser:", e.message)); + child.unref(); + } catch (e) { + log("failed to open browser:", e && e.message); + } +} + +// --- MCP tools ------------------------------------------------------------ + +const TOOLS = [ + { + name: "preview_flex_message", + description: + "Preview LINE Flex Message JSON in a live browser tab. Accepts a full flex message " + + '({type:"flex",altText,contents}), a bare bubble/carousel container, or a JSON string. ' + + "Opens the preview in the default browser on first use and live-updates it on subsequent calls. " + + "Returns the preview URL plus validation warnings.", + inputSchema: { + type: "object", + properties: { + content: { + description: "Flex Message JSON: object (flex message / bubble / carousel) or JSON string.", + type: ["object", "string"], + }, + altText: { type: "string", description: "Optional alt text (metadata only)." }, + }, + required: ["content"], + }, + }, + { + name: "get_flex_content", + description: + "Return the Flex Message JSON currently shown in the preview, INCLUDING any edits the user " + + "made in the browser editor. Use this to read back the user's tweaks.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "validate_flex_message", + description: + "Structurally validate Flex Message JSON (container/bubble/blocks) without opening a browser. " + + "Returns { valid, warnings }.", + inputSchema: { + type: "object", + properties: { + content: { + description: "Flex Message JSON to validate. Omit to validate the current preview content.", + type: ["object", "string"], + }, + }, + }, + }, + { + name: "open_preview", + description: + "Open (or reopen) the live preview tab in the default browser and return its URL, without changing content.", + inputSchema: { type: "object", properties: {} }, + }, +]; + +function toolResultText(obj) { + return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] }; +} + +async function callTool(name, args) { + args = args || {}; + if (name === "preview_flex_message") { + const normalized = normalizeContent(args.content); + const { valid, warnings } = validateContent(normalized); + state.content = normalized; + await saveContent(normalized); + const url = await ensureServer(); + broadcast(); + if (AUTO_OPEN && !state.opened) { + state.opened = true; + openBrowser(url); + } + return toolResultText({ ok: true, url, valid, warnings, opened: state.opened }); + } + if (name === "get_flex_content") { + if (state.content == null) state.content = await loadContent(); + return toolResultText({ content: state.content ?? null }); + } + if (name === "validate_flex_message") { + let target = state.content; + if (args.content !== undefined) target = normalizeContent(args.content); + if (target == null) return toolResultText({ valid: false, warnings: ["no content to validate"] }); + return toolResultText(validateContent(target)); + } + if (name === "open_preview") { + const url = await ensureServer(); + state.opened = true; + openBrowser(url); + return toolResultText({ ok: true, url }); + } + throw new Error(`unknown tool: ${name}`); +} + +// --- MCP stdio JSON-RPC --------------------------------------------------- + +const PROTOCOL_VERSION = "2024-11-05"; +const SERVER_INFO = { name: "line-flex-viewer", version: "1.0.0" }; + +function send(msg) { + process.stdout.write(JSON.stringify(msg) + "\n"); +} + +function reply(id, result) { + send({ jsonrpc: "2.0", id, result }); +} + +function replyError(id, code, message) { + send({ jsonrpc: "2.0", id, error: { code, message } }); +} + +async function handleMessage(msg) { + if (!msg || msg.jsonrpc !== "2.0") return; + const { id, method, params } = msg; + + // Notifications (no id) — nothing to reply. + if (id === undefined || id === null) { + // e.g. notifications/initialized, notifications/cancelled + return; + } + + try { + if (method === "initialize") { + const clientProto = params && params.protocolVersion; + reply(id, { + protocolVersion: typeof clientProto === "string" ? clientProto : PROTOCOL_VERSION, + capabilities: { tools: { listChanged: false } }, + serverInfo: SERVER_INFO, + }); + return; + } + if (method === "ping") { + reply(id, {}); + return; + } + if (method === "tools/list") { + reply(id, { tools: TOOLS }); + return; + } + if (method === "tools/call") { + const name = params && params.name; + const args = params && params.arguments; + try { + const result = await callTool(name, args); + reply(id, result); + } catch (e) { + // Tool-level errors are reported as a result with isError, per MCP. + reply(id, { + content: [{ type: "text", text: `Error: ${e && e.message ? e.message : String(e)}` }], + isError: true, + }); + } + return; + } + replyError(id, -32601, `Method not found: ${method}`); + } catch (e) { + replyError(id, -32603, e && e.message ? e.message : String(e)); + } +} + +function main() { + let buffer = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + buffer += chunk; + let idx; + while ((idx = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (!line) continue; + let msg; + try { + msg = JSON.parse(line); + } catch { + log("failed to parse message:", line.slice(0, 200)); + continue; + } + handleMessage(msg); + } + }); + process.stdin.on("end", () => process.exit(0)); + log("ready (stdio). Web assets:", WEB_DIR); +} + +main(); diff --git a/extensions/line-flex-viewer/web/flex.css b/extensions/line-flex-viewer/web/flex.css new file mode 100644 index 0000000..a7107b8 --- /dev/null +++ b/extensions/line-flex-viewer/web/flex.css @@ -0,0 +1,334 @@ +:root { + color-scheme: light dark; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; +} + +body { + background: var(--background-color-default, #f5f5f5); + color: var(--text-color-default, #1f2328); + font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", + "Helvetica Neue", "Hiragino Kaku Gothic ProN", Meiryo, sans-serif); + font-size: var(--text-body-medium, 14px); + line-height: var(--leading-body-medium, 20px); +} + +/* ---- App shell ---------------------------------------------------------- */ + +.app { + display: flex; + flex-direction: column; + height: 100vh; +} + +.toolbar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 8px 12px; + border-bottom: 1px solid var(--border-color-default, rgba(0, 0, 0, 0.12)); + background: var(--background-color-default, #fff); +} + +.toolbar h1 { + font-size: var(--text-title-small, 15px); + font-weight: var(--font-weight-semibold, 600); + margin: 0 8px 0 0; + white-space: nowrap; +} + +.toolbar .spacer { + flex: 1; +} + +button.btn { + font: inherit; + font-size: 13px; + padding: 5px 10px; + border-radius: 6px; + border: 1px solid var(--border-color-default, rgba(0, 0, 0, 0.2)); + background: var(--background-color-default, #fff); + color: var(--text-color-default, #1f2328); + cursor: pointer; + white-space: nowrap; +} + +button.btn:hover { + border-color: var(--color-focus-outline, #0969da); +} + +button.btn.primary { + background: var(--true-color-blue, #0969da); + border-color: var(--true-color-blue, #0969da); + color: #fff; +} + +.select-wrap { + position: relative; +} + +select.btn { + font: inherit; + font-size: 13px; + padding: 5px 8px; + border-radius: 6px; + border: 1px solid var(--border-color-default, rgba(0, 0, 0, 0.2)); + background: var(--background-color-default, #fff); + color: var(--text-color-default, #1f2328); + cursor: pointer; +} + +/* ---- Split panes -------------------------------------------------------- */ + +.panes { + display: flex; + flex: 1; + min-height: 0; +} + +.pane { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +.pane-editor { + width: 42%; + border-right: 1px solid var(--border-color-default, rgba(0, 0, 0, 0.12)); +} + +.pane-preview { + flex: 1; +} + +.pane-header { + padding: 6px 12px; + font-size: 12px; + color: var(--text-color-muted, #6e7781); + border-bottom: 1px solid var(--border-color-default, rgba(0, 0, 0, 0.08)); + display: flex; + align-items: center; + gap: 8px; +} + +#editor { + flex: 1; + width: 100%; + border: 0; + outline: none; + resize: none; + padding: 12px; + font-family: var(--font-mono, "SFMono-Regular", Consolas, "Liberation Mono", + monospace); + font-size: var(--text-code-block, 12.5px); + line-height: 1.5; + tab-size: 2; + background: var(--background-color-default, #fff); + color: var(--text-color-default, #1f2328); +} + +.status-bar { + padding: 6px 12px; + font-size: 12px; + border-top: 1px solid var(--border-color-default, rgba(0, 0, 0, 0.08)); + min-height: 28px; + white-space: pre-wrap; +} + +.status-bar.ok { + color: var(--true-color-green, #1a7f37); +} + +.status-bar.error { + color: var(--true-color-red, #cf222e); +} + +/* ---- Preview surface (LINE-like chat background) ------------------------ */ + +.preview-scroll { + flex: 1; + overflow: auto; + padding: 24px; +} + +.preview-scroll[data-bg="light"] { + background: #8cabd8; +} + +.preview-scroll[data-bg="dark"] { + background: #1e2732; +} + +.preview-stage { + display: flex; + justify-content: center; + min-height: 100%; +} + +/* ---- Flex renderer ------------------------------------------------------ */ + +.flex-bubble { + background: #ffffff; + border-radius: 14px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.28); + font-size: 16px; + line-height: 1.3; + color: #111111; + align-self: flex-start; +} + +.flex-carousel { + display: flex; + gap: 8px; + align-items: flex-start; +} + +.flex-carousel-item { + flex: 0 0 auto; +} + +.flex-block { + position: relative; +} + +.flex-hero { + overflow: hidden; +} + +.flex-block-separator { + height: 1px; + width: 100%; +} + +.flex-box { + display: flex; + align-items: stretch; +} + +.flex-text { + font-size: 16px; + color: inherit; + min-width: 0; + word-break: break-word; +} + +.flex-span { + white-space: inherit; +} + +.flex-image { + position: relative; + overflow: hidden; + display: flex; + justify-content: var(--img-justify, center); + align-items: center; +} + +.flex-image > img { + width: 100%; + height: 100%; + display: block; +} + +.flex-image-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: #eceff3; + color: #97a0ad; + font-size: 13px; +} + +.flex-video { + background: #000; +} + +.flex-video > img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.flex-video-play { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 28px; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6); + pointer-events: none; +} + +.flex-icon { + display: inline-block; + object-fit: contain; + vertical-align: baseline; +} + +.flex-button-wrap { + display: flex; +} + +.flex-button { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + font-size: 16px; + font-weight: 700; + padding: 0 12px; + text-align: center; + cursor: default; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.flex-button--link { + font-weight: 400; +} + +.flex-separator { + flex: 0 0 auto; +} + +.flex-filler { + flex: 1; +} + +.flex-unknown { + font-size: 12px; + color: #cf222e; + border: 1px dashed #cf222e; + border-radius: 4px; + padding: 2px 4px; +} + +.flex-render-error { + align-self: center; + margin: auto; + max-width: 320px; + text-align: center; + color: #fff; + background: rgba(0, 0, 0, 0.35); + padding: 12px 16px; + border-radius: 8px; + font-size: 13px; +} diff --git a/extensions/line-flex-viewer/web/renderer.js b/extensions/line-flex-viewer/web/renderer.js new file mode 100644 index 0000000..02d9c02 --- /dev/null +++ b/extensions/line-flex-viewer/web/renderer.js @@ -0,0 +1,520 @@ +/* + * LINE Flex Message renderer (browser global: window.FlexRenderer). + * + * Converts a Flex Message JSON tree (message wrapper, bubble, or carousel) + * into DOM nodes styled with CSS flexbox to approximate LINE's rendering. + * Size keyword -> px mappings follow LINE's documented scale (approximate). + */ +(function () { + "use strict"; + + // --- Keyword -> px maps ------------------------------------------------- + + // spacing / margin / padding / cornerRadius / offset keywords + const SPACE_PX = { none: 0, xs: 2, sm: 4, md: 8, lg: 12, xl: 16, xxl: 20 }; + + // text / span / icon font size keywords + const FONT_PX = { + xxs: 11, xs: 13, sm: 14, md: 16, lg: 19, xl: 22, xxl: 29, + "3xl": 35, "4xl": 48, "5xl": 74, + }; + + // image size keywords + const IMAGE_PX = { + xxs: 40, xs: 60, sm: 80, md: 100, lg: 120, xl: 140, xxl: 160, + "3xl": 180, "4xl": 200, "5xl": 240, + }; + + // bubble width by size keyword + const BUBBLE_W = { + nano: 120, micro: 160, deca: 220, hecto: 241, + kilo: 260, mega: 300, giga: 386, + }; + + // button heights + const BUTTON_H = { sm: 40, md: 52 }; + + const DEFAULT_LINK_COLOR = "#0367D3"; + const DEFAULT_PRIMARY_BG = "#17c950"; + const DEFAULT_SECONDARY_BG = "#dcdfe5"; + const DEFAULT_SEPARATOR_COLOR = "rgba(0,0,0,0.12)"; + const DEFAULT_BLOCK_PADDING = 20; // header/body/footer default paddingAll + + // --- helpers ------------------------------------------------------------ + + function el(tag, className) { + const node = document.createElement(tag); + if (className) node.className = className; + return node; + } + + function isObj(v) { + return v && typeof v === "object" && !Array.isArray(v); + } + + // Resolve a size-ish value (keyword | "10px" | "50%") into a CSS length. + function lenFromKeyword(value, map) { + if (value === undefined || value === null) return undefined; + if (typeof value === "number") return value + "px"; + const s = String(value).trim(); + if (s in map) return map[s] + "px"; + if (/^\d+(\.\d+)?px$/.test(s)) return s; + if (/^\d+(\.\d+)?%$/.test(s)) return s; + if (/^\d+(\.\d+)?$/.test(s)) return s + "px"; // bare number string + return s; // pass-through (e.g. "full" handled by caller) + } + + function spacePx(value) { + return lenFromKeyword(value, SPACE_PX); + } + + // --- Text / Span -------------------------------------------------------- + + function applyTextStyle(node, comp) { + if (comp.size) { + const px = FONT_PX[comp.size]; + node.style.fontSize = px ? px + "px" : lenFromKeyword(comp.size, FONT_PX); + } + if (comp.weight) node.style.fontWeight = comp.weight === "bold" ? "700" : "400"; + if (comp.color) node.style.color = comp.color; + if (comp.style) node.style.fontStyle = comp.style; // normal | italic + if (comp.decoration && comp.decoration !== "none") { + node.style.textDecoration = + comp.decoration === "line-through" ? "line-through" : comp.decoration; + } + } + + function renderSpan(span) { + const node = el("span", "flex-span"); + node.textContent = span.text != null ? String(span.text) : ""; + applyTextStyle(node, span); + return node; + } + + function renderText(comp) { + const node = el("div", "flex-text"); + const wrap = comp.wrap === true; + node.style.whiteSpace = wrap ? "pre-wrap" : "nowrap"; + if (!wrap) { + node.style.overflow = "hidden"; + node.style.textOverflow = "ellipsis"; + } + if (comp.align) node.style.textAlign = comp.align; // start | end | center + if (comp.lineSpacing) { + const px = spacePx(comp.lineSpacing); + if (px) node.style.lineHeight = `calc(1.15em + ${px})`; + } + if (wrap && comp.maxLines && comp.maxLines > 0) { + node.style.display = "-webkit-box"; + node.style.webkitBoxOrient = "vertical"; + node.style.webkitLineClamp = String(comp.maxLines); + node.style.overflow = "hidden"; + } + applyTextStyle(node, comp); + + if (Array.isArray(comp.contents) && comp.contents.length) { + for (const span of comp.contents) { + if (isObj(span) && span.type === "span") node.appendChild(renderSpan(span)); + } + } else { + node.textContent = comp.text != null ? String(comp.text) : ""; + } + return node; + } + + // --- Image -------------------------------------------------------------- + + function renderImage(comp) { + const box = el("div", "flex-image"); + const size = comp.size || "md"; + + if (size === "full") { + box.style.width = "100%"; + } else if (IMAGE_PX[size]) { + box.style.width = IMAGE_PX[size] + "px"; + } else { + const l = lenFromKeyword(size, IMAGE_PX); + if (l) box.style.width = l; + } + + const ratio = parseAspectRatio(comp.aspectRatio) || 1; + box.style.aspectRatio = String(ratio); + + if (comp.backgroundColor) box.style.background = comp.backgroundColor; + if (comp.align) box.style.setProperty("--img-justify", alignToJustify(comp.align)); + + const img = el("img"); + img.alt = ""; + img.src = comp.url || ""; + img.style.objectFit = comp.aspectMode === "cover" ? "cover" : "contain"; + img.addEventListener("error", function () { + box.classList.add("flex-image--error"); + img.remove(); + const ph = el("div", "flex-image-placeholder"); + ph.textContent = "🖼 image"; + box.appendChild(ph); + }); + box.appendChild(img); + return box; + } + + function parseAspectRatio(ar) { + if (!ar) return undefined; + const m = String(ar).split(":"); + if (m.length === 2) { + const w = parseFloat(m[0]); + const h = parseFloat(m[1]); + if (w > 0 && h > 0) return w / h; + } + return undefined; + } + + function alignToJustify(align) { + if (align === "start") return "flex-start"; + if (align === "end") return "flex-end"; + return "center"; + } + + // --- Icon --------------------------------------------------------------- + + function renderIcon(comp) { + const img = el("img", "flex-icon"); + img.alt = ""; + img.src = comp.url || ""; + const size = comp.size || "md"; + const px = FONT_PX[size] ? FONT_PX[size] : parseFloat(lenFromKeyword(size, FONT_PX)) || 16; + img.style.height = px + "px"; + const ratio = parseAspectRatio(comp.aspectRatio) || 1; + img.style.width = px * ratio + "px"; + img.addEventListener("error", function () { + img.classList.add("flex-icon--error"); + }); + return img; + } + + // --- Button ------------------------------------------------------------- + + function renderButton(comp) { + const wrap = el("div", "flex-button-wrap"); + const btn = el("div", "flex-button"); + const style = comp.style || "link"; + btn.classList.add("flex-button--" + style); + + const height = BUTTON_H[comp.height] || BUTTON_H.md; + btn.style.height = height + "px"; + + if (style === "primary" || style === "secondary") { + btn.style.background = + comp.color || (style === "primary" ? DEFAULT_PRIMARY_BG : DEFAULT_SECONDARY_BG); + btn.style.color = style === "primary" ? "#fff" : "#111"; + } else { + btn.style.color = comp.color || DEFAULT_LINK_COLOR; + } + + const label = (comp.action && comp.action.label) || ""; + btn.textContent = label; + if (comp.gravity) wrap.style.alignSelf = gravityToAlign(comp.gravity); + wrap.appendChild(btn); + return wrap; + } + + function gravityToAlign(g) { + if (g === "top") return "flex-start"; + if (g === "bottom") return "flex-end"; + if (g === "center") return "center"; + return undefined; + } + + // --- Separator ---------------------------------------------------------- + + function renderSeparator(comp, parentLayout) { + const sep = el("div", "flex-separator"); + const color = comp.color || DEFAULT_SEPARATOR_COLOR; + if (parentLayout === "horizontal") { + sep.style.width = "1px"; + sep.style.alignSelf = "stretch"; + sep.style.background = color; + } else { + sep.style.height = "1px"; + sep.style.background = color; + } + return sep; + } + + // --- Video (preview only) ---------------------------------------------- + + function renderVideo(comp) { + const box = el("div", "flex-image flex-video"); + const ratio = parseAspectRatio(comp.aspectRatio) || 16 / 9; + box.style.aspectRatio = String(ratio); + box.style.width = "100%"; + if (comp.previewUrl) { + const img = el("img"); + img.src = comp.previewUrl; + img.style.objectFit = "cover"; + img.addEventListener("error", () => img.remove()); + box.appendChild(img); + } + const play = el("div", "flex-video-play"); + play.textContent = "▶"; + box.appendChild(play); + return box; + } + + // --- Box ---------------------------------------------------------------- + + function renderBox(comp, opts) { + opts = opts || {}; + const layout = comp.layout || "vertical"; + const node = el("div", "flex-box flex-box--" + layout); + + if (layout === "horizontal") node.style.flexDirection = "row"; + else if (layout === "baseline") { + node.style.flexDirection = "row"; + node.style.alignItems = "baseline"; + } else node.style.flexDirection = "column"; + + // padding + const padProps = ["paddingAll", "paddingTop", "paddingBottom", "paddingStart", "paddingEnd"]; + const hasPad = padProps.some((p) => comp[p] !== undefined); + if (comp.paddingAll !== undefined) node.style.padding = spacePx(comp.paddingAll); + if (comp.paddingTop !== undefined) node.style.paddingTop = spacePx(comp.paddingTop); + if (comp.paddingBottom !== undefined) node.style.paddingBottom = spacePx(comp.paddingBottom); + if (comp.paddingStart !== undefined) node.style.paddingInlineStart = spacePx(comp.paddingStart); + if (comp.paddingEnd !== undefined) node.style.paddingInlineEnd = spacePx(comp.paddingEnd); + if (!hasPad && opts.blockPadding !== undefined) { + node.style.padding = opts.blockPadding + "px"; + } + + if (comp.backgroundColor) node.style.background = comp.backgroundColor; + if (comp.borderColor) node.style.borderColor = comp.borderColor; + if (comp.borderWidth !== undefined) { + node.style.borderStyle = "solid"; + node.style.borderWidth = lenFromKeyword(comp.borderWidth, SPACE_PX); + } + if (comp.cornerRadius !== undefined) node.style.borderRadius = spacePx(comp.cornerRadius); + if (comp.width !== undefined) node.style.width = lenFromKeyword(comp.width, {}); + if (comp.height !== undefined) node.style.height = lenFromKeyword(comp.height, {}); + if (comp.justifyContent) node.style.justifyContent = comp.justifyContent; + if (comp.alignItems) node.style.alignItems = comp.alignItems; + + if (comp.position === "absolute") { + node.style.position = "absolute"; + if (comp.offsetTop !== undefined) node.style.top = spacePx(comp.offsetTop); + if (comp.offsetBottom !== undefined) node.style.bottom = spacePx(comp.offsetBottom); + if (comp.offsetStart !== undefined) node.style.insetInlineStart = spacePx(comp.offsetStart); + if (comp.offsetEnd !== undefined) node.style.insetInlineEnd = spacePx(comp.offsetEnd); + } else if (comp.position === "relative") { + node.style.position = "relative"; + if (comp.offsetTop !== undefined) node.style.top = spacePx(comp.offsetTop); + if (comp.offsetStart !== undefined) node.style.insetInlineStart = spacePx(comp.offsetStart); + } + + const spacing = comp.spacing; + const children = Array.isArray(comp.contents) ? comp.contents : []; + children.forEach((child, i) => { + if (!isObj(child)) return; + const childNode = renderComponent(child, layout); + if (!childNode) return; + applyChildLayout(childNode, child, layout, i, spacing); + node.appendChild(childNode); + }); + + return node; + } + + // Apply flex/margin of a child within its parent box. + function applyChildLayout(node, child, parentLayout, index, parentSpacing) { + const horizontal = parentLayout === "horizontal" || parentLayout === "baseline"; + + // leading space: explicit margin, else parent spacing (skip first child) + let lead; + if (child.margin !== undefined) lead = spacePx(child.margin); + else if (index > 0 && parentSpacing !== undefined) lead = spacePx(parentSpacing); + if (lead && lead !== "0px") { + if (horizontal) node.style.marginInlineStart = lead; + else node.style.marginTop = lead; + } + + // flex sizing + let flex = child.flex; + if (flex === undefined) flex = horizontal ? 1 : 0; + if (child.width !== undefined && parentLayout !== "horizontal") { + // fixed width in vertical box: don't grow + node.style.flex = "0 0 auto"; + } else if (flex > 0) { + node.style.flex = flex + " 1 0%"; + node.style.minWidth = "0"; + } else { + node.style.flex = "0 0 auto"; + } + + // gravity (cross-axis in horizontal/baseline) + if (horizontal && child.gravity) { + const a = gravityToAlign(child.gravity); + if (a) node.style.alignSelf = a; + } + } + + // --- Component dispatch ------------------------------------------------- + + function renderComponent(comp, parentLayout) { + try { + switch (comp.type) { + case "box": + return renderBox(comp); + case "text": + return renderText(comp); + case "image": + return renderImage(comp); + case "button": + return renderButton(comp); + case "icon": + return renderIcon(comp); + case "separator": + return renderSeparator(comp, parentLayout); + case "video": + return renderVideo(comp); + case "filler": { + const f = el("div", "flex-filler"); + return f; + } + case "span": { + // span outside text: render inline text + return renderText({ type: "text", contents: [comp] }); + } + default: + return unknown(comp.type); + } + } catch (e) { + return unknown((comp && comp.type) + " (error)"); + } + } + + function unknown(type) { + const node = el("div", "flex-unknown"); + node.textContent = "⚠ unknown: " + (type || "?"); + return node; + } + + // --- Bubble / Carousel -------------------------------------------------- + + function renderBubble(bubble) { + const size = bubble.size || "mega"; + const width = BUBBLE_W[size] || BUBBLE_W.mega; + const node = el("div", "flex-bubble"); + node.style.width = width + "px"; + if (bubble.direction === "rtl") node.style.direction = "rtl"; + + const styles = isObj(bubble.styles) ? bubble.styles : {}; + const blocks = ["header", "hero", "body", "footer"]; + let prevBlockPainted = false; + + blocks.forEach((name) => { + const block = bubble[name]; + if (!block) return; + const blockStyle = isObj(styles[name]) ? styles[name] : {}; + + // separator between this block and the previous one + if (prevBlockPainted && blockStyle.separator) { + const sep = el("div", "flex-block-separator"); + sep.style.background = blockStyle.separatorColor || DEFAULT_SEPARATOR_COLOR; + node.appendChild(sep); + } + + let blockNode; + if (name === "hero") { + // hero is usually an image or a box; no default padding + blockNode = + block.type === "box" + ? renderBox(block, { blockPadding: undefined }) + : renderComponent(block, "vertical"); + if (blockNode) blockNode.classList.add("flex-block", "flex-hero"); + } else { + blockNode = renderBox(block, { blockPadding: DEFAULT_BLOCK_PADDING }); + blockNode.classList.add("flex-block", "flex-" + name); + } + if (!blockNode) return; + if (blockStyle.backgroundColor) blockNode.style.background = blockStyle.backgroundColor; + node.appendChild(blockNode); + prevBlockPainted = true; + }); + + return node; + } + + function renderCarousel(carousel) { + const node = el("div", "flex-carousel"); + const contents = Array.isArray(carousel.contents) ? carousel.contents : []; + contents.forEach((bubble) => { + if (isObj(bubble) && bubble.type === "bubble") { + const b = renderBubble(bubble); + b.classList.add("flex-carousel-item"); + node.appendChild(b); + } + }); + return node; + } + + // Normalize any accepted shape into a container object {type:bubble|carousel} + function extractContainer(json) { + if (!isObj(json)) return null; + if (json.type === "flex" && isObj(json.contents)) return json.contents; + if (json.type === "bubble" || json.type === "carousel") return json; + if (isObj(json.contents) && (json.contents.type === "bubble" || json.contents.type === "carousel")) { + return json.contents; + } + return null; + } + + // Lightweight structural validation shared by every controller. + function validate(json) { + const w = []; + const container = extractContainer(json); + if (!container) { + w.push("No bubble/carousel container at the root"); + return w; + } + let bubbles = []; + if (container.type === "carousel") { + if (!Array.isArray(container.contents) || container.contents.length === 0) { + w.push("carousel contents is empty"); + } else { + if (container.contents.length > 12) w.push("A carousel supports at most 12 bubbles"); + bubbles = container.contents; + } + } else { + bubbles = [container]; + } + bubbles.forEach((b, i) => { + if (!b || b.type !== "bubble") { + w.push(`contents[${i}] is not a bubble`); + return; + } + if (!b.header && !b.hero && !b.body && !b.footer) { + w.push(`bubble[${i}] has no header/hero/body/footer`); + } + }); + return w; + } + + // --- Public API --------------------------------------------------------- + + function render(target, json) { + target.innerHTML = ""; + const container = extractContainer(json); + if (!container) { + const err = el("div", "flex-render-error"); + err.textContent = + 'The root must be a "bubble" / "carousel" container, or a type:"flex" message.'; + target.appendChild(err); + return; + } + let node; + if (container.type === "carousel") node = renderCarousel(container); + else node = renderBubble(container); + target.appendChild(node); + } + + window.FlexRenderer = { render: render, extractContainer: extractContainer, validate: validate }; +})(); diff --git a/extensions/line-flex-viewer/web/samples.js b/extensions/line-flex-viewer/web/samples.js new file mode 100644 index 0000000..b5c0da3 --- /dev/null +++ b/extensions/line-flex-viewer/web/samples.js @@ -0,0 +1,207 @@ +/* Sample Flex Messages for the "Load sample" menu. */ +(function () { + "use strict"; + + const receipt = { + type: "bubble", + body: { + type: "box", + layout: "vertical", + contents: [ + { type: "text", text: "RECEIPT", weight: "bold", color: "#1DB446", size: "sm" }, + { type: "text", text: "Brown Store", weight: "bold", size: "xxl", margin: "md" }, + { + type: "text", + text: "Miraina Tower, 4-1-6 Shinjuku, Tokyo", + size: "xs", + color: "#aaaaaa", + wrap: true, + }, + { type: "separator", margin: "xxl" }, + { + type: "box", + layout: "vertical", + margin: "xxl", + spacing: "sm", + contents: [ + { + type: "box", + layout: "horizontal", + contents: [ + { type: "text", text: "Energy Drink", size: "sm", color: "#555555", flex: 0 }, + { type: "text", text: "$2.99", size: "sm", color: "#111111", align: "end" }, + ], + }, + { + type: "box", + layout: "horizontal", + contents: [ + { type: "text", text: "Chewing Gum", size: "sm", color: "#555555", flex: 0 }, + { type: "text", text: "$0.99", size: "sm", color: "#111111", align: "end" }, + ], + }, + { type: "separator", margin: "xxl" }, + { + type: "box", + layout: "horizontal", + margin: "xxl", + contents: [ + { type: "text", text: "TOTAL", size: "sm", color: "#555555" }, + { type: "text", text: "$3.98", size: "sm", color: "#111111", align: "end" }, + ], + }, + ], + }, + ], + }, + }; + + const eyecatch = { + type: "bubble", + hero: { + type: "image", + url: "https://developers-resource.landpress.line.me/fx/img/01_1_cafe.png", + size: "full", + aspectRatio: "20:13", + aspectMode: "cover", + }, + body: { + type: "box", + layout: "vertical", + contents: [ + { type: "text", text: "Brown Cafe", weight: "bold", size: "xl" }, + { + type: "box", + layout: "baseline", + margin: "md", + contents: [ + { type: "icon", size: "sm", url: "https://developers-resource.landpress.line.me/fx/img/review_gold_star_28.png" }, + { type: "icon", size: "sm", url: "https://developers-resource.landpress.line.me/fx/img/review_gold_star_28.png" }, + { type: "icon", size: "sm", url: "https://developers-resource.landpress.line.me/fx/img/review_gold_star_28.png" }, + { type: "icon", size: "sm", url: "https://developers-resource.landpress.line.me/fx/img/review_gold_star_28.png" }, + { type: "icon", size: "sm", url: "https://developers-resource.landpress.line.me/fx/img/review_gray_star_28.png" }, + { type: "text", text: "4.0", size: "sm", color: "#999999", margin: "md", flex: 0 }, + ], + }, + { + type: "box", + layout: "vertical", + margin: "lg", + spacing: "sm", + contents: [ + { + type: "box", + layout: "baseline", + spacing: "sm", + contents: [ + { type: "text", text: "Place", color: "#aaaaaa", size: "sm", flex: 1 }, + { type: "text", text: "Shinjuku, Tokyo", wrap: true, color: "#666666", size: "sm", flex: 5 }, + ], + }, + { + type: "box", + layout: "baseline", + spacing: "sm", + contents: [ + { type: "text", text: "Time", color: "#aaaaaa", size: "sm", flex: 1 }, + { type: "text", text: "10:00 - 23:00", wrap: true, color: "#666666", size: "sm", flex: 5 }, + ], + }, + ], + }, + ], + }, + footer: { + type: "box", + layout: "vertical", + spacing: "sm", + contents: [ + { + type: "button", + style: "link", + height: "sm", + action: { type: "uri", label: "CALL", uri: "https://example.com" }, + }, + { + type: "button", + style: "primary", + height: "sm", + color: "#905c44", + action: { type: "uri", label: "WEBSITE", uri: "https://example.com" }, + }, + ], + }, + }; + + const carousel = { + type: "carousel", + contents: [ + { + type: "bubble", + size: "micro", + hero: { + type: "image", + url: "https://developers-resource.landpress.line.me/fx/img/01_5_carousel.png", + size: "full", + aspectMode: "cover", + aspectRatio: "1.51:1", + }, + body: { + type: "box", + layout: "vertical", + contents: [ + { type: "text", text: "Arm Chair, White", weight: "bold", size: "md", wrap: true }, + { + type: "box", + layout: "baseline", + contents: [{ type: "text", text: "$49", wrap: true, weight: "bold", size: "xl", flex: 0 }], + }, + ], + spacing: "sm", + paddingAll: "13px", + }, + }, + { + type: "bubble", + size: "micro", + hero: { + type: "image", + url: "https://developers-resource.landpress.line.me/fx/img/01_6_carousel.png", + size: "full", + aspectMode: "cover", + aspectRatio: "1.51:1", + }, + body: { + type: "box", + layout: "vertical", + contents: [ + { type: "text", text: "Metal Desk Lamp", weight: "bold", size: "md", wrap: true }, + { + type: "box", + layout: "baseline", + contents: [{ type: "text", text: "$11", wrap: true, weight: "bold", size: "xl", flex: 0 }], + }, + ], + spacing: "sm", + paddingAll: "13px", + }, + }, + ], + }; + + const minimal = { + type: "bubble", + body: { + type: "box", + layout: "vertical", + contents: [{ type: "text", text: "Hello, World!", wrap: true }], + }, + }; + + window.FLEX_SAMPLES = [ + { id: "receipt", label: "Receipt", value: receipt }, + { id: "eyecatch", label: "Store card", value: eyecatch }, + { id: "carousel", label: "Carousel", value: carousel }, + { id: "minimal", label: "Minimal", value: minimal }, + ]; +})(); diff --git a/extensions/line-flex-viewer/web/standalone.html b/extensions/line-flex-viewer/web/standalone.html new file mode 100644 index 0000000..9c5057d --- /dev/null +++ b/extensions/line-flex-viewer/web/standalone.html @@ -0,0 +1,50 @@ + + + + + + LINE Flex Message Viewer (Standalone) + + + +
+
+

LINE Flex Message Viewer

+ + + + + + + + + + + + +
+ +
+
+
Flex JSON
+ +
+
+
+
Preview (approximate)
+
+
+
+
+
+
+
+
+ + + + + + diff --git a/extensions/line-flex-viewer/web/standalone.js b/extensions/line-flex-viewer/web/standalone.js new file mode 100644 index 0000000..61bcdc7 --- /dev/null +++ b/extensions/line-flex-viewer/web/standalone.js @@ -0,0 +1,312 @@ +/* + * Standalone controller for the LINE Flex Message viewer. + * + * Runs entirely in the browser with NO server and NO Copilot App: + * open standalone.html directly (file://) or serve the web/ folder with any + * static server. Persistence uses localStorage; JSON can be imported/exported + * as a file or shared via a URL hash (#json=). + */ +(function () { + "use strict"; + + const STORAGE_KEY = "line-flex-viewer:standalone:content"; + + const editor = document.getElementById("editor"); + const preview = document.getElementById("preview"); + const status = document.getElementById("status"); + const dirtyFlag = document.getElementById("dirty-flag"); + const previewScroll = document.getElementById("preview-scroll"); + const fileInput = document.getElementById("file-input"); + + let renderTimer = null; + let saveTimer = null; + let lastSavedText = ""; + + // --- status helpers ----------------------------------------------------- + + function setStatus(msg, kind) { + status.textContent = msg || ""; + status.className = "status-bar" + (kind ? " " + kind : ""); + } + + function setDirty(v) { + dirtyFlag.textContent = v ? "● Unsaved" : ""; + } + + // --- parse + render ----------------------------------------------------- + + function parseEditor() { + const text = editor.value; + if (!text.trim()) return { ok: false, error: "JSON is empty" }; + try { + return { ok: true, value: JSON.parse(text) }; + } catch (e) { + return { ok: false, error: "JSON syntax error: " + e.message }; + } + } + + function render() { + const parsed = parseEditor(); + if (!parsed.ok) { + setStatus(parsed.error, "error"); + return null; + } + try { + window.FlexRenderer.render(preview, parsed.value); + const warnings = window.FlexRenderer.validate(parsed.value); + if (warnings.length) { + setStatus("⚠ " + warnings.join(" / "), "error"); + } else { + setStatus("✓ Rendered successfully", "ok"); + } + return parsed.value; + } catch (e) { + setStatus("Render error: " + e.message, "error"); + return null; + } + } + + // --- persistence (localStorage) ----------------------------------------- + + function saveLocal(text) { + try { + localStorage.setItem(STORAGE_KEY, text); + } catch (_) {} + lastSavedText = text; + setDirty(false); + } + + function scheduleSave(text) { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(() => saveLocal(text), 400); + } + + function loadLocal() { + try { + return localStorage.getItem(STORAGE_KEY); + } catch (_) { + return null; + } + } + + // --- events ------------------------------------------------------------- + + function onEdit() { + setDirty(editor.value !== lastSavedText); + if (renderTimer) clearTimeout(renderTimer); + renderTimer = setTimeout(() => { + const value = render(); + if (value !== null) scheduleSave(editor.value); + }, 350); + } + + function doRenderNow() { + const value = render(); + if (value !== null) saveLocal(editor.value); + } + + function formatJson() { + const parsed = parseEditor(); + if (!parsed.ok) { + setStatus(parsed.error, "error"); + return; + } + editor.value = JSON.stringify(parsed.value, null, 2); + onEdit(); + } + + function copyJson() { + const text = editor.value; + const done = () => setStatus("Copied to clipboard", "ok"); + const fail = () => setStatus("Failed to copy", "error"); + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(done, fail); + } else { + try { + editor.select(); + document.execCommand("copy"); + done(); + } catch (_) { + fail(); + } + } + } + + function setContent(value) { + editor.value = typeof value === "string" ? value : JSON.stringify(value, null, 2); + render(); + saveLocal(editor.value); + } + + // --- file import / export ----------------------------------------------- + + function openFile() { + fileInput.value = ""; + fileInput.click(); + } + + function onFileChosen() { + const file = fileInput.files && fileInput.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + try { + const parsed = JSON.parse(String(reader.result)); + setContent(parsed); + setStatus(`Loaded: ${file.name}`, "ok"); + } catch (e) { + setStatus("Failed to parse JSON from file: " + e.message, "error"); + } + }; + reader.onerror = () => setStatus("Failed to read the file", "error"); + reader.readAsText(file); + } + + function downloadJson() { + const parsed = parseEditor(); + if (!parsed.ok) { + setStatus(parsed.error, "error"); + return; + } + const text = JSON.stringify(parsed.value, null, 2); + const blob = new Blob([text], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "flex-message.json"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 1000); + setStatus("Downloaded flex-message.json", "ok"); + } + + // --- share via URL hash (#json=) -------------------------------- + + function encodeHash(text) { + // Handle non-ASCII (unescape/encodeURIComponent trick) before base64. + return btoa(unescape(encodeURIComponent(text))); + } + + function decodeHash(b64) { + return decodeURIComponent(escape(atob(b64))); + } + + function shareLink() { + const parsed = parseEditor(); + if (!parsed.ok) { + setStatus(parsed.error, "error"); + return; + } + const text = JSON.stringify(parsed.value); + let hash; + try { + hash = "#json=" + encodeHash(text); + } catch (e) { + setStatus("Failed to generate the share link", "error"); + return; + } + const url = location.origin + location.pathname + hash; + // Update the address bar without reloading. + try { + history.replaceState(null, "", hash); + } catch (_) {} + const done = () => setStatus("Copied share link (also reflected in the URL)", "ok"); + const fail = () => setStatus("Couldn't copy the link. Copy the URL from the address bar instead.", "error"); + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(url).then(done, fail); + } else { + fail(); + } + } + + function contentFromHash() { + const h = location.hash || ""; + const m = h.match(/^#json=(.+)$/); + if (!m) return null; + try { + return decodeHash(m[1]); + } catch (_) { + return null; + } + } + + // Tab inserts spaces; Ctrl/Cmd+Enter renders immediately. + function handleKeys(e) { + if (e.key === "Tab") { + e.preventDefault(); + const s = editor.selectionStart; + const eSel = editor.selectionEnd; + editor.value = editor.value.slice(0, s) + " " + editor.value.slice(eSel); + editor.selectionStart = editor.selectionEnd = s + 2; + onEdit(); + } else if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { + e.preventDefault(); + doRenderNow(); + } + } + + // --- samples ------------------------------------------------------------ + + function populateSamples() { + const sel = document.getElementById("sample-select"); + (window.FLEX_SAMPLES || []).forEach((s) => { + const opt = document.createElement("option"); + opt.value = s.id; + opt.textContent = s.label; + sel.appendChild(opt); + }); + sel.addEventListener("change", () => { + const s = (window.FLEX_SAMPLES || []).find((x) => x.id === sel.value); + if (s) setContent(s.value); + sel.value = ""; + }); + } + + function toggleBg() { + previewScroll.dataset.bg = previewScroll.dataset.bg === "dark" ? "light" : "dark"; + } + + // --- init --------------------------------------------------------------- + + function init() { + document.getElementById("btn-render").addEventListener("click", doRenderNow); + document.getElementById("btn-format").addEventListener("click", formatJson); + document.getElementById("btn-copy").addEventListener("click", copyJson); + document.getElementById("btn-open").addEventListener("click", openFile); + document.getElementById("btn-download").addEventListener("click", downloadJson); + document.getElementById("btn-share").addEventListener("click", shareLink); + document.getElementById("btn-bg").addEventListener("click", toggleBg); + fileInput.addEventListener("change", onFileChosen); + editor.addEventListener("input", onEdit); + editor.addEventListener("keydown", handleKeys); + populateSamples(); + + // Seed priority: URL hash > localStorage > first sample. + const fromHash = contentFromHash(); + if (fromHash) { + editor.value = fromHash; + formatJson(); + render(); + saveLocal(editor.value); + setStatus("Loaded from share link", "ok"); + return; + } + const saved = loadLocal(); + if (saved && saved.trim()) { + editor.value = saved; + lastSavedText = saved; + render(); + return; + } + const first = (window.FLEX_SAMPLES || [])[0]; + if (first) { + setContent(first.value); + setStatus("Showing a sample. Your edits are auto-saved locally.", "ok"); + } else { + setStatus("Enter Flex JSON."); + } + } + + init(); +})(); diff --git a/extensions/line-flex-viewer/web/viewer.html b/extensions/line-flex-viewer/web/viewer.html new file mode 100644 index 0000000..a2ffd73 --- /dev/null +++ b/extensions/line-flex-viewer/web/viewer.html @@ -0,0 +1,46 @@ + + + + + + LINE Flex Message Viewer + + + +
+
+

LINE Flex Message Viewer

+ + + + + + + + +
+ +
+
+
Flex JSON
+ +
+
+
+
Preview (approximate)
+
+
+
+
+
+
+
+
+ + + + + + diff --git a/extensions/line-flex-viewer/web/viewer.js b/extensions/line-flex-viewer/web/viewer.js new file mode 100644 index 0000000..a787cd6 --- /dev/null +++ b/extensions/line-flex-viewer/web/viewer.js @@ -0,0 +1,207 @@ +/* Client controller for the Flex Message viewer iframe. */ +(function () { + "use strict"; + + const cfg = window.__FLEX_CFG__ || { docId: "default" }; + const editor = document.getElementById("editor"); + const preview = document.getElementById("preview"); + const status = document.getElementById("status"); + const dirtyFlag = document.getElementById("dirty-flag"); + const previewScroll = document.getElementById("preview-scroll"); + + let renderTimer = null; + let saveTimer = null; + let dirty = false; + let lastServerText = ""; + + // --- status helpers ----------------------------------------------------- + + function setStatus(msg, kind) { + status.textContent = msg || ""; + status.className = "status-bar" + (kind ? " " + kind : ""); + } + + function setDirty(v) { + dirty = v; + dirtyFlag.textContent = v ? "● Unsaved" : ""; + } + + // --- parse + render ----------------------------------------------------- + + function parseEditor() { + const text = editor.value; + if (!text.trim()) return { ok: false, error: "JSON is empty" }; + try { + return { ok: true, value: JSON.parse(text) }; + } catch (e) { + return { ok: false, error: "JSON syntax error: " + e.message }; + } + } + + function render() { + const parsed = parseEditor(); + if (!parsed.ok) { + setStatus(parsed.error, "error"); + return null; + } + try { + window.FlexRenderer.render(preview, parsed.value); + const warnings = window.FlexRenderer.validate(parsed.value); + if (warnings.length) { + setStatus("⚠ " + warnings.join(" / "), "error"); + } else { + setStatus("✓ Rendered successfully", "ok"); + } + return parsed.value; + } catch (e) { + setStatus("Render error: " + e.message, "error"); + return null; + } + } + + // --- persistence -------------------------------------------------------- + + function saveToServer(value) { + fetch("./api/state", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ docId: cfg.docId, content: value }), + }) + .then(() => { + lastServerText = editor.value; + setDirty(false); + }) + .catch(() => {}); + } + + function scheduleSave(value) { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(() => saveToServer(value), 400); + } + + // --- events ------------------------------------------------------------- + + function onEdit() { + setDirty(editor.value !== lastServerText); + if (renderTimer) clearTimeout(renderTimer); + renderTimer = setTimeout(() => { + const value = render(); + if (value !== null) scheduleSave(value); + }, 350); + } + + function doRenderNow() { + const value = render(); + if (value !== null) saveToServer(value); + } + + function formatJson() { + const parsed = parseEditor(); + if (!parsed.ok) { + setStatus(parsed.error, "error"); + return; + } + editor.value = JSON.stringify(parsed.value, null, 2); + onEdit(); + } + + function copyJson() { + navigator.clipboard.writeText(editor.value).then( + () => setStatus("Copied to clipboard", "ok"), + () => setStatus("Failed to copy", "error") + ); + } + + function setContent(value, opts) { + opts = opts || {}; + editor.value = JSON.stringify(value, null, 2); + lastServerText = editor.value; + setDirty(false); + render(); + if (opts.flash) setStatus("The assistant updated the content", "ok"); + } + + // Tab key inserts spaces instead of leaving the textarea. + function handleTab(e) { + if (e.key === "Tab") { + e.preventDefault(); + const s = editor.selectionStart; + const eSel = editor.selectionEnd; + editor.value = editor.value.slice(0, s) + " " + editor.value.slice(eSel); + editor.selectionStart = editor.selectionEnd = s + 2; + onEdit(); + } else if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { + e.preventDefault(); + doRenderNow(); + } + } + + // --- SSE (assistant pushes) --------------------------------------------- + + function connectEvents() { + try { + const es = new EventSource("./api/events?docId=" + encodeURIComponent(cfg.docId)); + es.addEventListener("content", (ev) => { + try { + const data = JSON.parse(ev.data); + if (data && data.content !== undefined) { + setContent(data.content, { flash: true }); + } + } catch (_) {} + }); + es.onerror = () => { + /* browser auto-reconnects */ + }; + } catch (_) {} + } + + // --- samples ------------------------------------------------------------ + + function populateSamples() { + const sel = document.getElementById("sample-select"); + (window.FLEX_SAMPLES || []).forEach((s) => { + const opt = document.createElement("option"); + opt.value = s.id; + opt.textContent = s.label; + sel.appendChild(opt); + }); + sel.addEventListener("change", () => { + const s = (window.FLEX_SAMPLES || []).find((x) => x.id === sel.value); + if (s) { + setContent(s.value); + saveToServer(s.value); + } + sel.value = ""; + }); + } + + function toggleBg() { + previewScroll.dataset.bg = previewScroll.dataset.bg === "dark" ? "light" : "dark"; + } + + // --- init --------------------------------------------------------------- + + function init() { + document.getElementById("btn-render").addEventListener("click", doRenderNow); + document.getElementById("btn-format").addEventListener("click", formatJson); + document.getElementById("btn-copy").addEventListener("click", copyJson); + document.getElementById("btn-bg").addEventListener("click", toggleBg); + editor.addEventListener("input", onEdit); + editor.addEventListener("keydown", handleTab); + populateSamples(); + connectEvents(); + + fetch("./api/state?docId=" + encodeURIComponent(cfg.docId)) + .then((r) => r.json()) + .then((data) => { + if (data && data.content !== undefined && data.content !== null) { + setContent(data.content); + } else { + setStatus("Enter Flex JSON or load a sample."); + } + }) + .catch(() => setStatus("Failed to load state.")); + } + + init(); +})(); diff --git a/tests/Line.OpenApi.Tools.Tests/FlexPreviewServiceTests.cs b/tests/Line.OpenApi.Tools.Tests/FlexPreviewServiceTests.cs new file mode 100644 index 0000000..204fc08 --- /dev/null +++ b/tests/Line.OpenApi.Tools.Tests/FlexPreviewServiceTests.cs @@ -0,0 +1,155 @@ +using System.Text.Json.Nodes; +using Line.OpenApi.Tools.Services; +using Xunit; + +namespace Line.OpenApi.Tools.Tests; + +/// +/// Unit tests for the pure validation / normalization / persistence logic of +/// . These paths do not start the HTTP listener +/// or open a browser, so they run fast and offline. +/// +public sealed class FlexPreviewServiceTests +{ + private static FlexPreviewService NewService() + { + // Never spawn a browser from a test run. + Environment.SetEnvironmentVariable("LINE_FLEX_MCP_NO_OPEN", "1"); + return new FlexPreviewService(); + } + + private static string Bubble(string block = "\"body\":{\"type\":\"box\",\"layout\":\"vertical\",\"contents\":[{\"type\":\"text\",\"text\":\"hi\"}]}") + => "{\"type\":\"bubble\"," + block + "}"; + + private static string Carousel(int bubbles) + { + var items = string.Join(",", Enumerable.Repeat(Bubble(), bubbles)); + return "{\"type\":\"carousel\",\"contents\":[" + items + "]}"; + } + + [Fact] + public void Bare_bubble_with_a_block_is_valid() + { + var (valid, warnings) = NewService().ValidateInput(Bubble()); + Assert.True(valid); + Assert.Empty(warnings); + } + + [Fact] + public void Bubble_without_any_block_warns() + { + var (valid, warnings) = NewService().ValidateInput("{\"type\":\"bubble\"}"); + Assert.False(valid); + Assert.Contains(warnings, w => w.Contains("header/hero/body/footer")); + } + + [Fact] + public void Flex_message_wrapper_is_unwrapped_and_validated() + { + var msg = "{\"type\":\"flex\",\"altText\":\"x\",\"contents\":" + Bubble() + "}"; + var (valid, warnings) = NewService().ValidateInput(msg); + Assert.True(valid); + Assert.Empty(warnings); + } + + [Theory] + [InlineData(1)] + [InlineData(12)] + public void Carousel_within_bounds_is_valid(int count) + { + var (valid, warnings) = NewService().ValidateInput(Carousel(count)); + Assert.True(valid); + Assert.Empty(warnings); + } + + [Fact] + public void Empty_carousel_is_invalid() + { + var (valid, warnings) = NewService().ValidateInput("{\"type\":\"carousel\",\"contents\":[]}"); + Assert.False(valid); + Assert.Contains(warnings, w => w.Contains("empty")); + } + + [Fact] + public void Carousel_over_twelve_bubbles_is_invalid() + { + var (valid, warnings) = NewService().ValidateInput(Carousel(13)); + Assert.False(valid); + Assert.Contains(warnings, w => w.Contains("12")); + } + + [Fact] + public void Non_bubble_entry_in_carousel_is_reported() + { + var payload = "{\"type\":\"carousel\",\"contents\":[{\"type\":\"box\",\"layout\":\"vertical\",\"contents\":[]}]}"; + var (valid, warnings) = NewService().ValidateInput(payload); + Assert.False(valid); + Assert.Contains(warnings, w => w.Contains("is not a bubble")); + } + + [Fact] + public void Arbitrary_object_is_not_a_container() + { + var (valid, warnings) = NewService().ValidateInput("{\"foo\":1}"); + Assert.False(valid); + Assert.Contains(warnings, w => w.Contains("container")); + } + + [Fact] + public void Malformed_json_is_reported_structurally_not_thrown() + { + // The validate tool must never throw on bad input; it returns { valid:false, warnings }. + var (valid, warnings) = NewService().ValidateInput("{ not json"); + Assert.False(valid); + Assert.NotEmpty(warnings); + } + + [Fact] + public void Scalar_json_is_reported_structurally_not_thrown() + { + var (valid, warnings) = NewService().ValidateInput("123"); + Assert.False(valid); + Assert.NotEmpty(warnings); + } + + [Fact] + public void Null_input_with_no_stored_content_reports_nothing_to_validate() + { + var stateDir = Path.Combine(Path.GetTempPath(), "line-flex-mcp-tests", Guid.NewGuid().ToString("N")); + var prev = Environment.GetEnvironmentVariable("LINE_FLEX_MCP_STATE_DIR"); + try + { + Environment.SetEnvironmentVariable("LINE_FLEX_MCP_STATE_DIR", stateDir); + var (valid, warnings) = NewService().ValidateInput(null); + Assert.False(valid); + Assert.Contains(warnings, w => w.Contains("no content")); + } + finally + { + Environment.SetEnvironmentVariable("LINE_FLEX_MCP_STATE_DIR", prev); + } + } + + [Fact] + public void GetContent_unwraps_the_persisted_state_wrapper() + { + var stateDir = Path.Combine(Path.GetTempPath(), "line-flex-mcp-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(stateDir); + var bubble = Bubble(); + File.WriteAllText(Path.Combine(stateDir, "content.json"), + "{\"content\":" + bubble + "}"); + + var prev = Environment.GetEnvironmentVariable("LINE_FLEX_MCP_STATE_DIR"); + try + { + Environment.SetEnvironmentVariable("LINE_FLEX_MCP_STATE_DIR", stateDir); + var content = NewService().GetContent(); + Assert.NotNull(content); + Assert.Equal("bubble", (string?)content!["type"]); + } + finally + { + Environment.SetEnvironmentVariable("LINE_FLEX_MCP_STATE_DIR", prev); + } + } +} diff --git a/tests/Line.OpenApi.Tools.Tests/FlexWebAssetsParityTests.cs b/tests/Line.OpenApi.Tools.Tests/FlexWebAssetsParityTests.cs new file mode 100644 index 0000000..40e858d --- /dev/null +++ b/tests/Line.OpenApi.Tools.Tests/FlexWebAssetsParityTests.cs @@ -0,0 +1,49 @@ +using System.Security.Cryptography; +using Xunit; + +namespace Line.OpenApi.Tools.Tests; + +/// +/// The Flex renderer is shared across four surfaces (Copilot canvas, bundled Node MCP, +/// the .NET line_flex_* tools, and the standalone page). The .NET tool embeds a subset +/// under tools/Line.OpenApi.Tools/web/, and the canvas extension keeps its own +/// copy under extensions/line-flex-viewer/web/. Until the two are single-sourced, +/// this guard fails the build if the shared subset ever drifts apart. +/// +public sealed class FlexWebAssetsParityTests +{ + private static readonly string[] SharedAssets = + { "renderer.js", "flex.css", "samples.js", "viewer.html", "viewer.js" }; + + [Fact] + public void Shared_web_assets_are_byte_identical_across_surfaces() + { + var root = RepoRoot(); + var toolsWeb = Path.Combine(root, "tools", "Line.OpenApi.Tools", "web"); + var extWeb = Path.Combine(root, "extensions", "line-flex-viewer", "web"); + + foreach (var name in SharedAssets) + { + var a = Path.Combine(toolsWeb, name); + var b = Path.Combine(extWeb, name); + Assert.True(File.Exists(a), $"missing: {a}"); + Assert.True(File.Exists(b), $"missing: {b}"); + Assert.Equal(Sha256(a), Sha256(b)); + } + } + + private static string Sha256(string path) + { + using var sha = SHA256.Create(); + return Convert.ToHexString(sha.ComputeHash(File.ReadAllBytes(path))); + } + + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "LineOpenApi.slnx"))) + dir = dir.Parent; + Assert.NotNull(dir); + return dir!.FullName; + } +} diff --git a/tests/Line.OpenApi.Tools.Tests/McpToolRegistrationTests.cs b/tests/Line.OpenApi.Tools.Tests/McpToolRegistrationTests.cs index 5c98213..b44e3c2 100644 --- a/tests/Line.OpenApi.Tools.Tests/McpToolRegistrationTests.cs +++ b/tests/Line.OpenApi.Tools.Tests/McpToolRegistrationTests.cs @@ -46,9 +46,14 @@ public void Tool_surface_is_exactly_the_expected_set() "line_audience_create", "line_audience_add_users", "line_audience_delete", // Shop "line_shop_mission", + // Flex preview (read-only-safe: no LINE API / secrets; registered unconditionally) + "line_flex_preview", "line_flex_get_content", "line_flex_validate", "line_flex_open", }.OrderBy(n => n).ToArray(); - var actual = ToolNames(typeof(ReadTools)).Concat(ToolNames(typeof(WriteTools))).OrderBy(n => n).ToArray(); + var actual = ToolNames(typeof(ReadTools)) + .Concat(ToolNames(typeof(WriteTools))) + .Concat(ToolNames(typeof(FlexPreviewTools))) + .OrderBy(n => n).ToArray(); Assert.Equal(expected, actual); } @@ -56,7 +61,9 @@ public void Tool_surface_is_exactly_the_expected_set() public void Every_tool_has_a_non_empty_description() { // The [Description] is the LLM-facing contract; it must never be blank. - var methods = ToolMethods(typeof(ReadTools)).Concat(ToolMethods(typeof(WriteTools))); + var methods = ToolMethods(typeof(ReadTools)) + .Concat(ToolMethods(typeof(WriteTools))) + .Concat(ToolMethods(typeof(FlexPreviewTools))); Assert.All(methods, m => { var description = m.GetCustomAttribute()?.Description; @@ -67,7 +74,9 @@ public void Every_tool_has_a_non_empty_description() [Fact] public void All_tool_names_use_line_prefix() { - var all = ToolNames(typeof(ReadTools)).Concat(ToolNames(typeof(WriteTools))); + var all = ToolNames(typeof(ReadTools)) + .Concat(ToolNames(typeof(WriteTools))) + .Concat(ToolNames(typeof(FlexPreviewTools))); Assert.All(all, name => Assert.StartsWith("line_", name)); } @@ -131,6 +140,13 @@ public void WriteTools_contains_the_mutating_operations() Assert.Contains("line_token_issue", write); Assert.Contains("line_webhook_replay", write); Assert.Contains("line_webhook_set_endpoint", write); + + // Flex preview tools touch no LINE API / secrets and must stay available under --read-only, + // so they must never be classified as mutating. + Assert.DoesNotContain("line_flex_preview", write); + Assert.DoesNotContain("line_flex_get_content", write); + Assert.DoesNotContain("line_flex_validate", write); + Assert.DoesNotContain("line_flex_open", write); } [Fact] diff --git a/tools/Line.OpenApi.Tools/Hosting/McpServerHost.cs b/tools/Line.OpenApi.Tools/Hosting/McpServerHost.cs index 1f437c5..b2d529b 100644 --- a/tools/Line.OpenApi.Tools/Hosting/McpServerHost.cs +++ b/tools/Line.OpenApi.Tools/Hosting/McpServerHost.cs @@ -30,7 +30,8 @@ public static async Task RunAsync(string[] args) var mcp = builder.Services .AddMcpServer() .WithStdioServerTransport() - .WithTools(); + .WithTools() + .WithTools(); if (!readOnly) { diff --git a/tools/Line.OpenApi.Tools/Hosting/ServiceRegistration.cs b/tools/Line.OpenApi.Tools/Hosting/ServiceRegistration.cs index fdb1361..42e2154 100644 --- a/tools/Line.OpenApi.Tools/Hosting/ServiceRegistration.cs +++ b/tools/Line.OpenApi.Tools/Hosting/ServiceRegistration.cs @@ -30,6 +30,9 @@ public static IServiceCollection AddCliCoreServices(this IServiceCollection serv // Singleton so the embedded ~190 KB spec is parsed once and the schema map is cached. services.AddSingleton(); + // Local Flex Message preview server (loopback HTTP + SSE). No LINE API / secrets. + services.AddSingleton(); + // CLI adapter helper (credential resolution + exit-code mapping). services.AddSingleton(); diff --git a/tools/Line.OpenApi.Tools/Line.OpenApi.Tools.csproj b/tools/Line.OpenApi.Tools/Line.OpenApi.Tools.csproj index 0b8ca8a..729d524 100644 --- a/tools/Line.OpenApi.Tools/Line.OpenApi.Tools.csproj +++ b/tools/Line.OpenApi.Tools/Line.OpenApi.Tools.csproj @@ -77,4 +77,13 @@ + + + Line.OpenApi.Tools.web.viewer.html + Line.OpenApi.Tools.web.viewer.js + Line.OpenApi.Tools.web.renderer.js + Line.OpenApi.Tools.web.flex.css + Line.OpenApi.Tools.web.samples.js + + diff --git a/tools/Line.OpenApi.Tools/Mcp/FlexPreviewTools.cs b/tools/Line.OpenApi.Tools/Mcp/FlexPreviewTools.cs new file mode 100644 index 0000000..0c0a506 --- /dev/null +++ b/tools/Line.OpenApi.Tools/Mcp/FlexPreviewTools.cs @@ -0,0 +1,69 @@ +using System.ComponentModel; +using Line.OpenApi.Tools.Output; +using Line.OpenApi.Tools.Services; +using ModelContextProtocol.Server; + +namespace Line.OpenApi.Tools.Mcp; + +/// +/// Read-only MCP tools for previewing a LINE Flex Message in a live local browser view. +/// +/// These tools spin up a loopback-only web server that renders the Flex JSON exactly as +/// the LINE app would (using the same renderer as the Copilot canvas / standalone viewer), +/// open the default browser once, and push subsequent updates to the open tab. They call no +/// LINE API and return no secrets, so they are safe under --read-only. Tool names +/// follow line_<area>_<verb> (spec §4.5). +/// +[McpServerToolType] +internal class FlexPreviewTools +{ + [McpServerTool(Name = "line_flex_preview"), Description( + "Render a LINE Flex Message in a live local browser preview and return its URL. " + + "Pass the Flex JSON as a string: either a full flex message object " + + "(type:\"flex\", altText, contents) or a bare container (type:\"bubble\"/\"carousel\"). " + + "The preview renders like the LINE app, opens the browser on first call, and is " + + "hot-updated on later calls. Returns { ok, url, valid, warnings, opened }.")] + public static string Preview( + FlexPreviewService preview, + [Description("The Flex Message JSON as a string (a flex message object or a bubble/carousel container).")] + string contentJson, + [Description("Optional altText to record with the message (informational; not required for rendering).")] + string? altText = null) + { + var result = preview.Preview(contentJson, altText); + return Json.Serialize(new + { + ok = result.Ok, + url = result.Url, + valid = result.Valid, + warnings = result.Warnings, + opened = result.Opened, + }); + } + + [McpServerTool(Name = "line_flex_get_content"), Description( + "Get the Flex JSON currently shown in the preview, including any edits the user made " + + "in the browser. Use this to pick up manual adjustments before saving or sending. " + + "Returns { content } (null when nothing has been previewed yet).")] + public static string GetContent(FlexPreviewService preview) + => Json.Serialize(new { content = preview.GetContent() }); + + [McpServerTool(Name = "line_flex_validate"), Description( + "Structurally validate Flex JSON (container shape, carousel size, per-bubble blocks) " + + "without rendering. Pass contentJson to validate that, or omit it to validate the " + + "current preview content. Returns { valid, warnings }.")] + public static string Validate( + FlexPreviewService preview, + [Description("Optional Flex JSON string to validate. Omit to validate the current preview content.")] + string? contentJson = null) + { + var (valid, warnings) = preview.ValidateInput(contentJson); + return Json.Serialize(new { valid, warnings }); + } + + [McpServerTool(Name = "line_flex_open"), Description( + "Ensure the preview server is running and (re)open it in the browser. Useful when the " + + "tab was closed. Returns { ok, url }.")] + public static string Open(FlexPreviewService preview) + => Json.Serialize(new { ok = true, url = preview.Open() }); +} diff --git a/tools/Line.OpenApi.Tools/Services/FlexPreviewService.cs b/tools/Line.OpenApi.Tools/Services/FlexPreviewService.cs new file mode 100644 index 0000000..31fdf4c --- /dev/null +++ b/tools/Line.OpenApi.Tools/Services/FlexPreviewService.cs @@ -0,0 +1,501 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Line.OpenApi.Tools.Services; + +/// +/// Hosts a local, loopback-only web preview of a LINE Flex Message and keeps it live. +/// +/// The browser-side renderer (renderer.js + flex.css) is the same one used by the +/// Copilot canvas / standalone viewer, embedded here as assembly resources +/// (web/*). This service mirrors the zero-dependency Node MCP server: it +/// serves the viewer page over an ephemeral 127.0.0.1 port, exposes +/// /api/state (GET/POST) and /api/events (SSE), opens the default +/// browser once, and pushes updates to already-open tabs. +/// +/// It performs no LINE API calls and stores no secrets, so it is safe under +/// --read-only. State (the current Flex JSON) is persisted to a temp file +/// so a reopened tab restores the last preview. +/// +internal sealed class FlexPreviewService : IDisposable +{ + private static readonly string[] StaticFiles = + { "viewer.html", "viewer.js", "renderer.js", "flex.css", "samples.js" }; + + private readonly object _gate = new(); + private readonly ConcurrentDictionary _clients = new(); + private readonly string _stateFile; + private readonly bool _autoOpen; + + private HttpListener? _listener; + private string? _url; + private JsonNode? _content; + private bool _opened; + + public FlexPreviewService() + { + var stateDir = Environment.GetEnvironmentVariable("LINE_FLEX_MCP_STATE_DIR") + ?? Path.Combine(Path.GetTempPath(), "line-flex-mcp"); + _stateFile = Path.Combine(stateDir, "content.json"); + _autoOpen = string.IsNullOrEmpty(Environment.GetEnvironmentVariable("LINE_FLEX_MCP_NO_OPEN")); + } + + // --- public API (called by the MCP tools) -------------------------------- + + /// + /// Set the Flex JSON to preview, (re)start the server, push it to any open + /// tab, and open the browser on first use. + /// + public PreviewResult Preview(string contentJson, string? altText) + { + var node = Normalize(contentJson); + var (valid, warnings) = Validate(node); + + lock (_gate) { _content = node; } + SaveContent(node); + + var url = EnsureServer(); + Broadcast(node); + + var opened = false; + lock (_gate) + { + if (_autoOpen && !_opened) + { + _opened = true; + opened = true; + } + } + if (opened) OpenBrowser(url); + + return new PreviewResult(true, url, valid, warnings, opened); + } + + /// Return the JSON currently shown in the preview, including the user's browser edits. + public JsonNode? GetContent() + { + lock (_gate) + { + if (_content is not null) return _content.DeepClone(); + } + var loaded = LoadContent(); + lock (_gate) { _content = loaded; } + return loaded?.DeepClone(); + } + + /// Structurally validate the supplied JSON, or the current preview content when null. + public (bool Valid, IReadOnlyList Warnings) ValidateInput(string? contentJson) + { + JsonNode? node; + if (string.IsNullOrWhiteSpace(contentJson)) + { + node = GetContent(); + if (node is null) return (false, new[] { "no content to validate" }); + } + else + { + // A "validate" tool should report bad input structurally, not throw. + try { node = Normalize(contentJson); } + catch (Exception e) { return (false, new[] { e.Message }); } + } + return Validate(node); + } + + /// + /// Ensure the server is running, (re)open it in the browser, and return the preview URL. + /// Unlike (which opens at most once automatically), this is an + /// explicit user gesture, so it always opens — useful when the tab was closed. + /// + public string Open() + { + var url = EnsureServer(); + lock (_gate) { _opened = true; } + if (_autoOpen) OpenBrowser(url); + return url; + } + + // --- persistence --------------------------------------------------------- + + private JsonNode? LoadContent() + { + try + { + var raw = File.ReadAllText(_stateFile); + var parsed = JsonNode.Parse(raw); + if (parsed is JsonObject obj && obj.TryGetPropertyValue("content", out var inner)) + return inner?.DeepClone(); + return parsed; + } + catch + { + return null; + } + } + + private void SaveContent(JsonNode? content) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_stateFile)!); + var wrapper = new JsonObject { ["content"] = content?.DeepClone() }; + File.WriteAllText(_stateFile, wrapper.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + } + catch + { + // Persistence is best-effort; a failure must not break the preview. + } + } + + // Accept a message wrapper, a bare container, or a JSON string; return a JsonNode. + private static JsonNode Normalize(string contentJson) + { + if (string.IsNullOrWhiteSpace(contentJson)) + throw new ArgumentException("content must be a non-empty JSON string."); + var node = JsonNode.Parse(contentJson) + ?? throw new ArgumentException("content parsed to null."); + if (node is not JsonObject && node is not JsonArray) + throw new ArgumentException("content must be a JSON object or array."); + return node; + } + + // --- structural validation (ported from the shared renderer) ------------- + + private static JsonObject? ExtractContainer(JsonNode? json) + { + if (json is not JsonObject obj) return null; + var type = (string?)obj["type"]; + if (type == "flex" && obj["contents"] is JsonObject flexContents) return flexContents; + if (type is "bubble" or "carousel") return obj; + if (obj["contents"] is JsonObject c && ((string?)c["type"]) is "bubble" or "carousel") return c; + return null; + } + + private static (bool Valid, IReadOnlyList Warnings) Validate(JsonNode? json) + { + var warnings = new List(); + var container = ExtractContainer(json); + if (container is null) + { + warnings.Add("root must be a \"bubble\"/\"carousel\" container or a type:\"flex\" message"); + return (false, warnings); + } + + var bubbles = new List(); + if ((string?)container["type"] == "carousel") + { + if (container["contents"] is not JsonArray arr || arr.Count == 0) + { + warnings.Add("carousel.contents is empty"); + } + else + { + if (arr.Count > 12) warnings.Add("carousel supports at most 12 bubbles"); + bubbles.AddRange(arr); + } + } + else + { + bubbles.Add(container); + } + + for (var i = 0; i < bubbles.Count; i++) + { + if (bubbles[i] is not JsonObject b || (string?)b["type"] != "bubble") + { + warnings.Add($"contents[{i}] is not a bubble"); + continue; + } + if (b["header"] is null && b["hero"] is null && b["body"] is null && b["footer"] is null) + warnings.Add($"bubble[{i}] has no header/hero/body/footer block"); + } + + return (warnings.Count == 0, warnings); + } + + // --- HTTP server --------------------------------------------------------- + + private string EnsureServer() + { + lock (_gate) + { + if (_listener is not null && _url is not null) return _url; + if (_content is null) _content = LoadContent(); + + var port = FreeLoopbackPort(); + var listener = new HttpListener(); + listener.Prefixes.Add($"http://127.0.0.1:{port}/"); + listener.Start(); + _listener = listener; + _url = $"http://127.0.0.1:{port}/"; + + _ = Task.Run(() => AcceptLoop(listener)); + return _url; + } + } + + // The Host header must be the loopback authority we bound to (127.0.0.1:), + // accepting the "localhost" alias for the same port. Anything else (a rebound DNS + // name, another port) is rejected. + private bool IsLoopbackHost(string? hostHeader) + { + string? url; + lock (_gate) { url = _url; } + if (url is null || string.IsNullOrEmpty(hostHeader)) return false; + var boundPort = new Uri(url).Port; + var colon = hostHeader.LastIndexOf(':'); + if (colon < 0) return false; + var host = hostHeader[..colon]; + var portText = hostHeader[(colon + 1)..]; + return (host.Equals("127.0.0.1", StringComparison.Ordinal) + || host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) + && portText == boundPort.ToString(); + } + + // For state-mutating POSTs, when an Origin header is present it must be same-origin + // (loopback authority). Absent Origin (non-browser clients) is allowed; the Host + // check above still applies. + private bool IsAllowedOrigin(string? origin) + { + if (string.IsNullOrEmpty(origin)) return true; + return Uri.TryCreate(origin, UriKind.Absolute, out var o) && IsLoopbackHost(o.Authority); + } + + private static int FreeLoopbackPort() + { + var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + var port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + return port; + } + + private async Task AcceptLoop(HttpListener listener) + { + while (listener.IsListening) + { + HttpListenerContext ctx; + try { ctx = await listener.GetContextAsync().ConfigureAwait(false); } + catch { break; } + _ = Task.Run(() => HandleRequest(ctx)); + } + } + + private void HandleRequest(HttpListenerContext ctx) + { + var req = ctx.Request; + var res = ctx.Response; + var path = req.Url?.AbsolutePath ?? "/"; + + try + { + if (req.HttpMethod == "GET" && (path == "/" || path == "/index.html")) + { + var html = RenderIndex(ReadResource("viewer.html")); + WriteText(res, 200, html, "text/html; charset=utf-8"); + return; + } + + if (req.HttpMethod == "GET" && !path.StartsWith("/api/")) + { + var name = path.TrimStart('/'); + if (Array.IndexOf(StaticFiles, name) >= 0) + { + WriteText(res, 200, ReadResource(name), ContentType(name)); + return; + } + WriteText(res, 404, "not found", "text/plain"); + return; + } + + // Guard the /api/* endpoints against DNS-rebinding reads and cross-origin + // writes (CSRF). Browsers always send Host; a rebound page or a foreign + // origin will not match the loopback authority we bound to. + if (path.StartsWith("/api/", StringComparison.Ordinal)) + { + if (!IsLoopbackHost(req.UserHostName) + || (req.HttpMethod == "POST" && !IsAllowedOrigin(req.Headers["Origin"]))) + { + WriteText(res, 403, "forbidden", "text/plain"); + return; + } + } + + if (req.HttpMethod == "GET" && path == "/api/state") + { + JsonNode? content; + lock (_gate) { content = _content?.DeepClone(); } + var body = new JsonObject { ["docId"] = "default", ["content"] = content }; + WriteText(res, 200, body.ToJsonString(), "application/json; charset=utf-8"); + return; + } + + if (req.HttpMethod == "POST" && path == "/api/state") + { + string raw; + using (var reader = new StreamReader(req.InputStream, req.ContentEncoding ?? Encoding.UTF8)) + raw = reader.ReadToEnd(); + try + { + var data = JsonNode.Parse(string.IsNullOrEmpty(raw) ? "{}" : raw) as JsonObject; + var content = data? ["content"]?.DeepClone(); + lock (_gate) { _content = content; } + SaveContent(content); + WriteText(res, 200, "{\"ok\":true}", "application/json; charset=utf-8"); + } + catch (Exception e) + { + var err = new JsonObject { ["ok"] = false, ["error"] = e.Message }; + WriteText(res, 400, err.ToJsonString(), "application/json; charset=utf-8"); + } + return; + } + + if (req.HttpMethod == "GET" && path == "/api/events") + { + ServeEvents(res); + return; // response is kept open by ServeEvents + } + + WriteText(res, 404, "not found", "text/plain"); + } + catch + { + try { WriteText(res, 500, "error", "text/plain"); } catch { /* ignore */ } + } + } + + private void ServeEvents(HttpListenerResponse res) + { + res.StatusCode = 200; + res.SendChunked = true; + res.ContentType = "text/event-stream"; + res.Headers["Cache-Control"] = "no-cache"; + res.KeepAlive = true; + + var id = Guid.NewGuid(); + _clients[id] = res; + try + { + WriteRaw(res, ": connected\n\n"); + // Push the current content immediately so a freshly opened tab renders at once. + JsonNode? current; + lock (_gate) { current = _content?.DeepClone(); } + if (current is not null) WriteRaw(res, ContentEvent(current)); + } + catch + { + _clients.TryRemove(id, out _); + try { res.Close(); } catch { /* ignore */ } + } + // The response stays open; Broadcast() writes to it until the client disconnects. + } + + private void Broadcast(JsonNode? content) + { + var payload = ContentEvent(content); + foreach (var (id, res) in _clients) + { + try { WriteRaw(res, payload); } + catch + { + _clients.TryRemove(id, out _); + try { res.Close(); } catch { /* ignore */ } + } + } + } + + private static string ContentEvent(JsonNode? content) + { + var data = new JsonObject { ["content"] = content?.DeepClone() }; + return $"event: content\ndata: {data.ToJsonString()}\n\n"; + } + + private static void WriteRaw(HttpListenerResponse res, string text) + { + var bytes = Encoding.UTF8.GetBytes(text); + res.OutputStream.Write(bytes, 0, bytes.Length); + res.OutputStream.Flush(); + } + + private static void WriteText(HttpListenerResponse res, int status, string text, string contentType) + { + var bytes = Encoding.UTF8.GetBytes(text); + res.StatusCode = status; + res.ContentType = contentType; + res.ContentLength64 = bytes.Length; + res.OutputStream.Write(bytes, 0, bytes.Length); + res.OutputStream.Close(); + } + + // Inject the viewer config before the first script, exactly like the Node server. + private static string RenderIndex(string html) + { + const string marker = ""; + const string cfg = ""; + return html.Replace(marker, cfg + "\n " + marker); + } + + private static string ContentType(string name) => Path.GetExtension(name) switch + { + ".html" => "text/html; charset=utf-8", + ".js" => "text/javascript; charset=utf-8", + ".css" => "text/css; charset=utf-8", + ".json" => "application/json; charset=utf-8", + _ => "application/octet-stream", + }; + + // --- embedded web assets ------------------------------------------------- + + private static string ReadResource(string name) + { + var asm = typeof(FlexPreviewService).Assembly; + var suffix = ".web." + name; + var resource = Array.Find(asm.GetManifestResourceNames(), n => n.EndsWith(suffix, StringComparison.Ordinal)) + ?? throw new FileNotFoundException($"Embedded web asset not found: {name}"); + using var stream = asm.GetManifestResourceStream(resource)!; + using var reader = new StreamReader(stream, Encoding.UTF8); + return reader.ReadToEnd(); + } + + // --- browser open -------------------------------------------------------- + + private static void OpenBrowser(string url) + { + try + { + System.Diagnostics.ProcessStartInfo psi; + if (OperatingSystem.IsWindows()) + psi = new System.Diagnostics.ProcessStartInfo("cmd", $"/c start \"\" \"{url}\"") { CreateNoWindow = true }; + else if (OperatingSystem.IsMacOS()) + psi = new System.Diagnostics.ProcessStartInfo("open", url); + else + psi = new System.Diagnostics.ProcessStartInfo("xdg-open", url); + psi.UseShellExecute = false; + System.Diagnostics.Process.Start(psi); + } + catch + { + // The URL is still returned to the caller; opening is best-effort. + } + } + + public void Dispose() + { + // Close any open SSE streams so a shutdown does not leak held responses. + foreach (var (id, res) in _clients) + { + _clients.TryRemove(id, out _); + try { res.Close(); } catch { /* ignore */ } + } + try { _listener?.Stop(); } catch { /* ignore */ } + try { _listener?.Close(); } catch { /* ignore */ } + } + + public readonly record struct PreviewResult( + bool Ok, string Url, bool Valid, IReadOnlyList Warnings, bool Opened); +} diff --git a/tools/Line.OpenApi.Tools/web/flex.css b/tools/Line.OpenApi.Tools/web/flex.css new file mode 100644 index 0000000..a7107b8 --- /dev/null +++ b/tools/Line.OpenApi.Tools/web/flex.css @@ -0,0 +1,334 @@ +:root { + color-scheme: light dark; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; +} + +body { + background: var(--background-color-default, #f5f5f5); + color: var(--text-color-default, #1f2328); + font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", + "Helvetica Neue", "Hiragino Kaku Gothic ProN", Meiryo, sans-serif); + font-size: var(--text-body-medium, 14px); + line-height: var(--leading-body-medium, 20px); +} + +/* ---- App shell ---------------------------------------------------------- */ + +.app { + display: flex; + flex-direction: column; + height: 100vh; +} + +.toolbar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 8px 12px; + border-bottom: 1px solid var(--border-color-default, rgba(0, 0, 0, 0.12)); + background: var(--background-color-default, #fff); +} + +.toolbar h1 { + font-size: var(--text-title-small, 15px); + font-weight: var(--font-weight-semibold, 600); + margin: 0 8px 0 0; + white-space: nowrap; +} + +.toolbar .spacer { + flex: 1; +} + +button.btn { + font: inherit; + font-size: 13px; + padding: 5px 10px; + border-radius: 6px; + border: 1px solid var(--border-color-default, rgba(0, 0, 0, 0.2)); + background: var(--background-color-default, #fff); + color: var(--text-color-default, #1f2328); + cursor: pointer; + white-space: nowrap; +} + +button.btn:hover { + border-color: var(--color-focus-outline, #0969da); +} + +button.btn.primary { + background: var(--true-color-blue, #0969da); + border-color: var(--true-color-blue, #0969da); + color: #fff; +} + +.select-wrap { + position: relative; +} + +select.btn { + font: inherit; + font-size: 13px; + padding: 5px 8px; + border-radius: 6px; + border: 1px solid var(--border-color-default, rgba(0, 0, 0, 0.2)); + background: var(--background-color-default, #fff); + color: var(--text-color-default, #1f2328); + cursor: pointer; +} + +/* ---- Split panes -------------------------------------------------------- */ + +.panes { + display: flex; + flex: 1; + min-height: 0; +} + +.pane { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +.pane-editor { + width: 42%; + border-right: 1px solid var(--border-color-default, rgba(0, 0, 0, 0.12)); +} + +.pane-preview { + flex: 1; +} + +.pane-header { + padding: 6px 12px; + font-size: 12px; + color: var(--text-color-muted, #6e7781); + border-bottom: 1px solid var(--border-color-default, rgba(0, 0, 0, 0.08)); + display: flex; + align-items: center; + gap: 8px; +} + +#editor { + flex: 1; + width: 100%; + border: 0; + outline: none; + resize: none; + padding: 12px; + font-family: var(--font-mono, "SFMono-Regular", Consolas, "Liberation Mono", + monospace); + font-size: var(--text-code-block, 12.5px); + line-height: 1.5; + tab-size: 2; + background: var(--background-color-default, #fff); + color: var(--text-color-default, #1f2328); +} + +.status-bar { + padding: 6px 12px; + font-size: 12px; + border-top: 1px solid var(--border-color-default, rgba(0, 0, 0, 0.08)); + min-height: 28px; + white-space: pre-wrap; +} + +.status-bar.ok { + color: var(--true-color-green, #1a7f37); +} + +.status-bar.error { + color: var(--true-color-red, #cf222e); +} + +/* ---- Preview surface (LINE-like chat background) ------------------------ */ + +.preview-scroll { + flex: 1; + overflow: auto; + padding: 24px; +} + +.preview-scroll[data-bg="light"] { + background: #8cabd8; +} + +.preview-scroll[data-bg="dark"] { + background: #1e2732; +} + +.preview-stage { + display: flex; + justify-content: center; + min-height: 100%; +} + +/* ---- Flex renderer ------------------------------------------------------ */ + +.flex-bubble { + background: #ffffff; + border-radius: 14px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.28); + font-size: 16px; + line-height: 1.3; + color: #111111; + align-self: flex-start; +} + +.flex-carousel { + display: flex; + gap: 8px; + align-items: flex-start; +} + +.flex-carousel-item { + flex: 0 0 auto; +} + +.flex-block { + position: relative; +} + +.flex-hero { + overflow: hidden; +} + +.flex-block-separator { + height: 1px; + width: 100%; +} + +.flex-box { + display: flex; + align-items: stretch; +} + +.flex-text { + font-size: 16px; + color: inherit; + min-width: 0; + word-break: break-word; +} + +.flex-span { + white-space: inherit; +} + +.flex-image { + position: relative; + overflow: hidden; + display: flex; + justify-content: var(--img-justify, center); + align-items: center; +} + +.flex-image > img { + width: 100%; + height: 100%; + display: block; +} + +.flex-image-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: #eceff3; + color: #97a0ad; + font-size: 13px; +} + +.flex-video { + background: #000; +} + +.flex-video > img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.flex-video-play { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 28px; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6); + pointer-events: none; +} + +.flex-icon { + display: inline-block; + object-fit: contain; + vertical-align: baseline; +} + +.flex-button-wrap { + display: flex; +} + +.flex-button { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + font-size: 16px; + font-weight: 700; + padding: 0 12px; + text-align: center; + cursor: default; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.flex-button--link { + font-weight: 400; +} + +.flex-separator { + flex: 0 0 auto; +} + +.flex-filler { + flex: 1; +} + +.flex-unknown { + font-size: 12px; + color: #cf222e; + border: 1px dashed #cf222e; + border-radius: 4px; + padding: 2px 4px; +} + +.flex-render-error { + align-self: center; + margin: auto; + max-width: 320px; + text-align: center; + color: #fff; + background: rgba(0, 0, 0, 0.35); + padding: 12px 16px; + border-radius: 8px; + font-size: 13px; +} diff --git a/tools/Line.OpenApi.Tools/web/renderer.js b/tools/Line.OpenApi.Tools/web/renderer.js new file mode 100644 index 0000000..02d9c02 --- /dev/null +++ b/tools/Line.OpenApi.Tools/web/renderer.js @@ -0,0 +1,520 @@ +/* + * LINE Flex Message renderer (browser global: window.FlexRenderer). + * + * Converts a Flex Message JSON tree (message wrapper, bubble, or carousel) + * into DOM nodes styled with CSS flexbox to approximate LINE's rendering. + * Size keyword -> px mappings follow LINE's documented scale (approximate). + */ +(function () { + "use strict"; + + // --- Keyword -> px maps ------------------------------------------------- + + // spacing / margin / padding / cornerRadius / offset keywords + const SPACE_PX = { none: 0, xs: 2, sm: 4, md: 8, lg: 12, xl: 16, xxl: 20 }; + + // text / span / icon font size keywords + const FONT_PX = { + xxs: 11, xs: 13, sm: 14, md: 16, lg: 19, xl: 22, xxl: 29, + "3xl": 35, "4xl": 48, "5xl": 74, + }; + + // image size keywords + const IMAGE_PX = { + xxs: 40, xs: 60, sm: 80, md: 100, lg: 120, xl: 140, xxl: 160, + "3xl": 180, "4xl": 200, "5xl": 240, + }; + + // bubble width by size keyword + const BUBBLE_W = { + nano: 120, micro: 160, deca: 220, hecto: 241, + kilo: 260, mega: 300, giga: 386, + }; + + // button heights + const BUTTON_H = { sm: 40, md: 52 }; + + const DEFAULT_LINK_COLOR = "#0367D3"; + const DEFAULT_PRIMARY_BG = "#17c950"; + const DEFAULT_SECONDARY_BG = "#dcdfe5"; + const DEFAULT_SEPARATOR_COLOR = "rgba(0,0,0,0.12)"; + const DEFAULT_BLOCK_PADDING = 20; // header/body/footer default paddingAll + + // --- helpers ------------------------------------------------------------ + + function el(tag, className) { + const node = document.createElement(tag); + if (className) node.className = className; + return node; + } + + function isObj(v) { + return v && typeof v === "object" && !Array.isArray(v); + } + + // Resolve a size-ish value (keyword | "10px" | "50%") into a CSS length. + function lenFromKeyword(value, map) { + if (value === undefined || value === null) return undefined; + if (typeof value === "number") return value + "px"; + const s = String(value).trim(); + if (s in map) return map[s] + "px"; + if (/^\d+(\.\d+)?px$/.test(s)) return s; + if (/^\d+(\.\d+)?%$/.test(s)) return s; + if (/^\d+(\.\d+)?$/.test(s)) return s + "px"; // bare number string + return s; // pass-through (e.g. "full" handled by caller) + } + + function spacePx(value) { + return lenFromKeyword(value, SPACE_PX); + } + + // --- Text / Span -------------------------------------------------------- + + function applyTextStyle(node, comp) { + if (comp.size) { + const px = FONT_PX[comp.size]; + node.style.fontSize = px ? px + "px" : lenFromKeyword(comp.size, FONT_PX); + } + if (comp.weight) node.style.fontWeight = comp.weight === "bold" ? "700" : "400"; + if (comp.color) node.style.color = comp.color; + if (comp.style) node.style.fontStyle = comp.style; // normal | italic + if (comp.decoration && comp.decoration !== "none") { + node.style.textDecoration = + comp.decoration === "line-through" ? "line-through" : comp.decoration; + } + } + + function renderSpan(span) { + const node = el("span", "flex-span"); + node.textContent = span.text != null ? String(span.text) : ""; + applyTextStyle(node, span); + return node; + } + + function renderText(comp) { + const node = el("div", "flex-text"); + const wrap = comp.wrap === true; + node.style.whiteSpace = wrap ? "pre-wrap" : "nowrap"; + if (!wrap) { + node.style.overflow = "hidden"; + node.style.textOverflow = "ellipsis"; + } + if (comp.align) node.style.textAlign = comp.align; // start | end | center + if (comp.lineSpacing) { + const px = spacePx(comp.lineSpacing); + if (px) node.style.lineHeight = `calc(1.15em + ${px})`; + } + if (wrap && comp.maxLines && comp.maxLines > 0) { + node.style.display = "-webkit-box"; + node.style.webkitBoxOrient = "vertical"; + node.style.webkitLineClamp = String(comp.maxLines); + node.style.overflow = "hidden"; + } + applyTextStyle(node, comp); + + if (Array.isArray(comp.contents) && comp.contents.length) { + for (const span of comp.contents) { + if (isObj(span) && span.type === "span") node.appendChild(renderSpan(span)); + } + } else { + node.textContent = comp.text != null ? String(comp.text) : ""; + } + return node; + } + + // --- Image -------------------------------------------------------------- + + function renderImage(comp) { + const box = el("div", "flex-image"); + const size = comp.size || "md"; + + if (size === "full") { + box.style.width = "100%"; + } else if (IMAGE_PX[size]) { + box.style.width = IMAGE_PX[size] + "px"; + } else { + const l = lenFromKeyword(size, IMAGE_PX); + if (l) box.style.width = l; + } + + const ratio = parseAspectRatio(comp.aspectRatio) || 1; + box.style.aspectRatio = String(ratio); + + if (comp.backgroundColor) box.style.background = comp.backgroundColor; + if (comp.align) box.style.setProperty("--img-justify", alignToJustify(comp.align)); + + const img = el("img"); + img.alt = ""; + img.src = comp.url || ""; + img.style.objectFit = comp.aspectMode === "cover" ? "cover" : "contain"; + img.addEventListener("error", function () { + box.classList.add("flex-image--error"); + img.remove(); + const ph = el("div", "flex-image-placeholder"); + ph.textContent = "🖼 image"; + box.appendChild(ph); + }); + box.appendChild(img); + return box; + } + + function parseAspectRatio(ar) { + if (!ar) return undefined; + const m = String(ar).split(":"); + if (m.length === 2) { + const w = parseFloat(m[0]); + const h = parseFloat(m[1]); + if (w > 0 && h > 0) return w / h; + } + return undefined; + } + + function alignToJustify(align) { + if (align === "start") return "flex-start"; + if (align === "end") return "flex-end"; + return "center"; + } + + // --- Icon --------------------------------------------------------------- + + function renderIcon(comp) { + const img = el("img", "flex-icon"); + img.alt = ""; + img.src = comp.url || ""; + const size = comp.size || "md"; + const px = FONT_PX[size] ? FONT_PX[size] : parseFloat(lenFromKeyword(size, FONT_PX)) || 16; + img.style.height = px + "px"; + const ratio = parseAspectRatio(comp.aspectRatio) || 1; + img.style.width = px * ratio + "px"; + img.addEventListener("error", function () { + img.classList.add("flex-icon--error"); + }); + return img; + } + + // --- Button ------------------------------------------------------------- + + function renderButton(comp) { + const wrap = el("div", "flex-button-wrap"); + const btn = el("div", "flex-button"); + const style = comp.style || "link"; + btn.classList.add("flex-button--" + style); + + const height = BUTTON_H[comp.height] || BUTTON_H.md; + btn.style.height = height + "px"; + + if (style === "primary" || style === "secondary") { + btn.style.background = + comp.color || (style === "primary" ? DEFAULT_PRIMARY_BG : DEFAULT_SECONDARY_BG); + btn.style.color = style === "primary" ? "#fff" : "#111"; + } else { + btn.style.color = comp.color || DEFAULT_LINK_COLOR; + } + + const label = (comp.action && comp.action.label) || ""; + btn.textContent = label; + if (comp.gravity) wrap.style.alignSelf = gravityToAlign(comp.gravity); + wrap.appendChild(btn); + return wrap; + } + + function gravityToAlign(g) { + if (g === "top") return "flex-start"; + if (g === "bottom") return "flex-end"; + if (g === "center") return "center"; + return undefined; + } + + // --- Separator ---------------------------------------------------------- + + function renderSeparator(comp, parentLayout) { + const sep = el("div", "flex-separator"); + const color = comp.color || DEFAULT_SEPARATOR_COLOR; + if (parentLayout === "horizontal") { + sep.style.width = "1px"; + sep.style.alignSelf = "stretch"; + sep.style.background = color; + } else { + sep.style.height = "1px"; + sep.style.background = color; + } + return sep; + } + + // --- Video (preview only) ---------------------------------------------- + + function renderVideo(comp) { + const box = el("div", "flex-image flex-video"); + const ratio = parseAspectRatio(comp.aspectRatio) || 16 / 9; + box.style.aspectRatio = String(ratio); + box.style.width = "100%"; + if (comp.previewUrl) { + const img = el("img"); + img.src = comp.previewUrl; + img.style.objectFit = "cover"; + img.addEventListener("error", () => img.remove()); + box.appendChild(img); + } + const play = el("div", "flex-video-play"); + play.textContent = "▶"; + box.appendChild(play); + return box; + } + + // --- Box ---------------------------------------------------------------- + + function renderBox(comp, opts) { + opts = opts || {}; + const layout = comp.layout || "vertical"; + const node = el("div", "flex-box flex-box--" + layout); + + if (layout === "horizontal") node.style.flexDirection = "row"; + else if (layout === "baseline") { + node.style.flexDirection = "row"; + node.style.alignItems = "baseline"; + } else node.style.flexDirection = "column"; + + // padding + const padProps = ["paddingAll", "paddingTop", "paddingBottom", "paddingStart", "paddingEnd"]; + const hasPad = padProps.some((p) => comp[p] !== undefined); + if (comp.paddingAll !== undefined) node.style.padding = spacePx(comp.paddingAll); + if (comp.paddingTop !== undefined) node.style.paddingTop = spacePx(comp.paddingTop); + if (comp.paddingBottom !== undefined) node.style.paddingBottom = spacePx(comp.paddingBottom); + if (comp.paddingStart !== undefined) node.style.paddingInlineStart = spacePx(comp.paddingStart); + if (comp.paddingEnd !== undefined) node.style.paddingInlineEnd = spacePx(comp.paddingEnd); + if (!hasPad && opts.blockPadding !== undefined) { + node.style.padding = opts.blockPadding + "px"; + } + + if (comp.backgroundColor) node.style.background = comp.backgroundColor; + if (comp.borderColor) node.style.borderColor = comp.borderColor; + if (comp.borderWidth !== undefined) { + node.style.borderStyle = "solid"; + node.style.borderWidth = lenFromKeyword(comp.borderWidth, SPACE_PX); + } + if (comp.cornerRadius !== undefined) node.style.borderRadius = spacePx(comp.cornerRadius); + if (comp.width !== undefined) node.style.width = lenFromKeyword(comp.width, {}); + if (comp.height !== undefined) node.style.height = lenFromKeyword(comp.height, {}); + if (comp.justifyContent) node.style.justifyContent = comp.justifyContent; + if (comp.alignItems) node.style.alignItems = comp.alignItems; + + if (comp.position === "absolute") { + node.style.position = "absolute"; + if (comp.offsetTop !== undefined) node.style.top = spacePx(comp.offsetTop); + if (comp.offsetBottom !== undefined) node.style.bottom = spacePx(comp.offsetBottom); + if (comp.offsetStart !== undefined) node.style.insetInlineStart = spacePx(comp.offsetStart); + if (comp.offsetEnd !== undefined) node.style.insetInlineEnd = spacePx(comp.offsetEnd); + } else if (comp.position === "relative") { + node.style.position = "relative"; + if (comp.offsetTop !== undefined) node.style.top = spacePx(comp.offsetTop); + if (comp.offsetStart !== undefined) node.style.insetInlineStart = spacePx(comp.offsetStart); + } + + const spacing = comp.spacing; + const children = Array.isArray(comp.contents) ? comp.contents : []; + children.forEach((child, i) => { + if (!isObj(child)) return; + const childNode = renderComponent(child, layout); + if (!childNode) return; + applyChildLayout(childNode, child, layout, i, spacing); + node.appendChild(childNode); + }); + + return node; + } + + // Apply flex/margin of a child within its parent box. + function applyChildLayout(node, child, parentLayout, index, parentSpacing) { + const horizontal = parentLayout === "horizontal" || parentLayout === "baseline"; + + // leading space: explicit margin, else parent spacing (skip first child) + let lead; + if (child.margin !== undefined) lead = spacePx(child.margin); + else if (index > 0 && parentSpacing !== undefined) lead = spacePx(parentSpacing); + if (lead && lead !== "0px") { + if (horizontal) node.style.marginInlineStart = lead; + else node.style.marginTop = lead; + } + + // flex sizing + let flex = child.flex; + if (flex === undefined) flex = horizontal ? 1 : 0; + if (child.width !== undefined && parentLayout !== "horizontal") { + // fixed width in vertical box: don't grow + node.style.flex = "0 0 auto"; + } else if (flex > 0) { + node.style.flex = flex + " 1 0%"; + node.style.minWidth = "0"; + } else { + node.style.flex = "0 0 auto"; + } + + // gravity (cross-axis in horizontal/baseline) + if (horizontal && child.gravity) { + const a = gravityToAlign(child.gravity); + if (a) node.style.alignSelf = a; + } + } + + // --- Component dispatch ------------------------------------------------- + + function renderComponent(comp, parentLayout) { + try { + switch (comp.type) { + case "box": + return renderBox(comp); + case "text": + return renderText(comp); + case "image": + return renderImage(comp); + case "button": + return renderButton(comp); + case "icon": + return renderIcon(comp); + case "separator": + return renderSeparator(comp, parentLayout); + case "video": + return renderVideo(comp); + case "filler": { + const f = el("div", "flex-filler"); + return f; + } + case "span": { + // span outside text: render inline text + return renderText({ type: "text", contents: [comp] }); + } + default: + return unknown(comp.type); + } + } catch (e) { + return unknown((comp && comp.type) + " (error)"); + } + } + + function unknown(type) { + const node = el("div", "flex-unknown"); + node.textContent = "⚠ unknown: " + (type || "?"); + return node; + } + + // --- Bubble / Carousel -------------------------------------------------- + + function renderBubble(bubble) { + const size = bubble.size || "mega"; + const width = BUBBLE_W[size] || BUBBLE_W.mega; + const node = el("div", "flex-bubble"); + node.style.width = width + "px"; + if (bubble.direction === "rtl") node.style.direction = "rtl"; + + const styles = isObj(bubble.styles) ? bubble.styles : {}; + const blocks = ["header", "hero", "body", "footer"]; + let prevBlockPainted = false; + + blocks.forEach((name) => { + const block = bubble[name]; + if (!block) return; + const blockStyle = isObj(styles[name]) ? styles[name] : {}; + + // separator between this block and the previous one + if (prevBlockPainted && blockStyle.separator) { + const sep = el("div", "flex-block-separator"); + sep.style.background = blockStyle.separatorColor || DEFAULT_SEPARATOR_COLOR; + node.appendChild(sep); + } + + let blockNode; + if (name === "hero") { + // hero is usually an image or a box; no default padding + blockNode = + block.type === "box" + ? renderBox(block, { blockPadding: undefined }) + : renderComponent(block, "vertical"); + if (blockNode) blockNode.classList.add("flex-block", "flex-hero"); + } else { + blockNode = renderBox(block, { blockPadding: DEFAULT_BLOCK_PADDING }); + blockNode.classList.add("flex-block", "flex-" + name); + } + if (!blockNode) return; + if (blockStyle.backgroundColor) blockNode.style.background = blockStyle.backgroundColor; + node.appendChild(blockNode); + prevBlockPainted = true; + }); + + return node; + } + + function renderCarousel(carousel) { + const node = el("div", "flex-carousel"); + const contents = Array.isArray(carousel.contents) ? carousel.contents : []; + contents.forEach((bubble) => { + if (isObj(bubble) && bubble.type === "bubble") { + const b = renderBubble(bubble); + b.classList.add("flex-carousel-item"); + node.appendChild(b); + } + }); + return node; + } + + // Normalize any accepted shape into a container object {type:bubble|carousel} + function extractContainer(json) { + if (!isObj(json)) return null; + if (json.type === "flex" && isObj(json.contents)) return json.contents; + if (json.type === "bubble" || json.type === "carousel") return json; + if (isObj(json.contents) && (json.contents.type === "bubble" || json.contents.type === "carousel")) { + return json.contents; + } + return null; + } + + // Lightweight structural validation shared by every controller. + function validate(json) { + const w = []; + const container = extractContainer(json); + if (!container) { + w.push("No bubble/carousel container at the root"); + return w; + } + let bubbles = []; + if (container.type === "carousel") { + if (!Array.isArray(container.contents) || container.contents.length === 0) { + w.push("carousel contents is empty"); + } else { + if (container.contents.length > 12) w.push("A carousel supports at most 12 bubbles"); + bubbles = container.contents; + } + } else { + bubbles = [container]; + } + bubbles.forEach((b, i) => { + if (!b || b.type !== "bubble") { + w.push(`contents[${i}] is not a bubble`); + return; + } + if (!b.header && !b.hero && !b.body && !b.footer) { + w.push(`bubble[${i}] has no header/hero/body/footer`); + } + }); + return w; + } + + // --- Public API --------------------------------------------------------- + + function render(target, json) { + target.innerHTML = ""; + const container = extractContainer(json); + if (!container) { + const err = el("div", "flex-render-error"); + err.textContent = + 'The root must be a "bubble" / "carousel" container, or a type:"flex" message.'; + target.appendChild(err); + return; + } + let node; + if (container.type === "carousel") node = renderCarousel(container); + else node = renderBubble(container); + target.appendChild(node); + } + + window.FlexRenderer = { render: render, extractContainer: extractContainer, validate: validate }; +})(); diff --git a/tools/Line.OpenApi.Tools/web/samples.js b/tools/Line.OpenApi.Tools/web/samples.js new file mode 100644 index 0000000..b5c0da3 --- /dev/null +++ b/tools/Line.OpenApi.Tools/web/samples.js @@ -0,0 +1,207 @@ +/* Sample Flex Messages for the "Load sample" menu. */ +(function () { + "use strict"; + + const receipt = { + type: "bubble", + body: { + type: "box", + layout: "vertical", + contents: [ + { type: "text", text: "RECEIPT", weight: "bold", color: "#1DB446", size: "sm" }, + { type: "text", text: "Brown Store", weight: "bold", size: "xxl", margin: "md" }, + { + type: "text", + text: "Miraina Tower, 4-1-6 Shinjuku, Tokyo", + size: "xs", + color: "#aaaaaa", + wrap: true, + }, + { type: "separator", margin: "xxl" }, + { + type: "box", + layout: "vertical", + margin: "xxl", + spacing: "sm", + contents: [ + { + type: "box", + layout: "horizontal", + contents: [ + { type: "text", text: "Energy Drink", size: "sm", color: "#555555", flex: 0 }, + { type: "text", text: "$2.99", size: "sm", color: "#111111", align: "end" }, + ], + }, + { + type: "box", + layout: "horizontal", + contents: [ + { type: "text", text: "Chewing Gum", size: "sm", color: "#555555", flex: 0 }, + { type: "text", text: "$0.99", size: "sm", color: "#111111", align: "end" }, + ], + }, + { type: "separator", margin: "xxl" }, + { + type: "box", + layout: "horizontal", + margin: "xxl", + contents: [ + { type: "text", text: "TOTAL", size: "sm", color: "#555555" }, + { type: "text", text: "$3.98", size: "sm", color: "#111111", align: "end" }, + ], + }, + ], + }, + ], + }, + }; + + const eyecatch = { + type: "bubble", + hero: { + type: "image", + url: "https://developers-resource.landpress.line.me/fx/img/01_1_cafe.png", + size: "full", + aspectRatio: "20:13", + aspectMode: "cover", + }, + body: { + type: "box", + layout: "vertical", + contents: [ + { type: "text", text: "Brown Cafe", weight: "bold", size: "xl" }, + { + type: "box", + layout: "baseline", + margin: "md", + contents: [ + { type: "icon", size: "sm", url: "https://developers-resource.landpress.line.me/fx/img/review_gold_star_28.png" }, + { type: "icon", size: "sm", url: "https://developers-resource.landpress.line.me/fx/img/review_gold_star_28.png" }, + { type: "icon", size: "sm", url: "https://developers-resource.landpress.line.me/fx/img/review_gold_star_28.png" }, + { type: "icon", size: "sm", url: "https://developers-resource.landpress.line.me/fx/img/review_gold_star_28.png" }, + { type: "icon", size: "sm", url: "https://developers-resource.landpress.line.me/fx/img/review_gray_star_28.png" }, + { type: "text", text: "4.0", size: "sm", color: "#999999", margin: "md", flex: 0 }, + ], + }, + { + type: "box", + layout: "vertical", + margin: "lg", + spacing: "sm", + contents: [ + { + type: "box", + layout: "baseline", + spacing: "sm", + contents: [ + { type: "text", text: "Place", color: "#aaaaaa", size: "sm", flex: 1 }, + { type: "text", text: "Shinjuku, Tokyo", wrap: true, color: "#666666", size: "sm", flex: 5 }, + ], + }, + { + type: "box", + layout: "baseline", + spacing: "sm", + contents: [ + { type: "text", text: "Time", color: "#aaaaaa", size: "sm", flex: 1 }, + { type: "text", text: "10:00 - 23:00", wrap: true, color: "#666666", size: "sm", flex: 5 }, + ], + }, + ], + }, + ], + }, + footer: { + type: "box", + layout: "vertical", + spacing: "sm", + contents: [ + { + type: "button", + style: "link", + height: "sm", + action: { type: "uri", label: "CALL", uri: "https://example.com" }, + }, + { + type: "button", + style: "primary", + height: "sm", + color: "#905c44", + action: { type: "uri", label: "WEBSITE", uri: "https://example.com" }, + }, + ], + }, + }; + + const carousel = { + type: "carousel", + contents: [ + { + type: "bubble", + size: "micro", + hero: { + type: "image", + url: "https://developers-resource.landpress.line.me/fx/img/01_5_carousel.png", + size: "full", + aspectMode: "cover", + aspectRatio: "1.51:1", + }, + body: { + type: "box", + layout: "vertical", + contents: [ + { type: "text", text: "Arm Chair, White", weight: "bold", size: "md", wrap: true }, + { + type: "box", + layout: "baseline", + contents: [{ type: "text", text: "$49", wrap: true, weight: "bold", size: "xl", flex: 0 }], + }, + ], + spacing: "sm", + paddingAll: "13px", + }, + }, + { + type: "bubble", + size: "micro", + hero: { + type: "image", + url: "https://developers-resource.landpress.line.me/fx/img/01_6_carousel.png", + size: "full", + aspectMode: "cover", + aspectRatio: "1.51:1", + }, + body: { + type: "box", + layout: "vertical", + contents: [ + { type: "text", text: "Metal Desk Lamp", weight: "bold", size: "md", wrap: true }, + { + type: "box", + layout: "baseline", + contents: [{ type: "text", text: "$11", wrap: true, weight: "bold", size: "xl", flex: 0 }], + }, + ], + spacing: "sm", + paddingAll: "13px", + }, + }, + ], + }; + + const minimal = { + type: "bubble", + body: { + type: "box", + layout: "vertical", + contents: [{ type: "text", text: "Hello, World!", wrap: true }], + }, + }; + + window.FLEX_SAMPLES = [ + { id: "receipt", label: "Receipt", value: receipt }, + { id: "eyecatch", label: "Store card", value: eyecatch }, + { id: "carousel", label: "Carousel", value: carousel }, + { id: "minimal", label: "Minimal", value: minimal }, + ]; +})(); diff --git a/tools/Line.OpenApi.Tools/web/viewer.html b/tools/Line.OpenApi.Tools/web/viewer.html new file mode 100644 index 0000000..a2ffd73 --- /dev/null +++ b/tools/Line.OpenApi.Tools/web/viewer.html @@ -0,0 +1,46 @@ + + + + + + LINE Flex Message Viewer + + + +
+
+

LINE Flex Message Viewer

+ + + + + + + + +
+ +
+
+
Flex JSON
+ +
+
+
+
Preview (approximate)
+
+
+
+
+
+
+
+
+ + + + + + diff --git a/tools/Line.OpenApi.Tools/web/viewer.js b/tools/Line.OpenApi.Tools/web/viewer.js new file mode 100644 index 0000000..a787cd6 --- /dev/null +++ b/tools/Line.OpenApi.Tools/web/viewer.js @@ -0,0 +1,207 @@ +/* Client controller for the Flex Message viewer iframe. */ +(function () { + "use strict"; + + const cfg = window.__FLEX_CFG__ || { docId: "default" }; + const editor = document.getElementById("editor"); + const preview = document.getElementById("preview"); + const status = document.getElementById("status"); + const dirtyFlag = document.getElementById("dirty-flag"); + const previewScroll = document.getElementById("preview-scroll"); + + let renderTimer = null; + let saveTimer = null; + let dirty = false; + let lastServerText = ""; + + // --- status helpers ----------------------------------------------------- + + function setStatus(msg, kind) { + status.textContent = msg || ""; + status.className = "status-bar" + (kind ? " " + kind : ""); + } + + function setDirty(v) { + dirty = v; + dirtyFlag.textContent = v ? "● Unsaved" : ""; + } + + // --- parse + render ----------------------------------------------------- + + function parseEditor() { + const text = editor.value; + if (!text.trim()) return { ok: false, error: "JSON is empty" }; + try { + return { ok: true, value: JSON.parse(text) }; + } catch (e) { + return { ok: false, error: "JSON syntax error: " + e.message }; + } + } + + function render() { + const parsed = parseEditor(); + if (!parsed.ok) { + setStatus(parsed.error, "error"); + return null; + } + try { + window.FlexRenderer.render(preview, parsed.value); + const warnings = window.FlexRenderer.validate(parsed.value); + if (warnings.length) { + setStatus("⚠ " + warnings.join(" / "), "error"); + } else { + setStatus("✓ Rendered successfully", "ok"); + } + return parsed.value; + } catch (e) { + setStatus("Render error: " + e.message, "error"); + return null; + } + } + + // --- persistence -------------------------------------------------------- + + function saveToServer(value) { + fetch("./api/state", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ docId: cfg.docId, content: value }), + }) + .then(() => { + lastServerText = editor.value; + setDirty(false); + }) + .catch(() => {}); + } + + function scheduleSave(value) { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(() => saveToServer(value), 400); + } + + // --- events ------------------------------------------------------------- + + function onEdit() { + setDirty(editor.value !== lastServerText); + if (renderTimer) clearTimeout(renderTimer); + renderTimer = setTimeout(() => { + const value = render(); + if (value !== null) scheduleSave(value); + }, 350); + } + + function doRenderNow() { + const value = render(); + if (value !== null) saveToServer(value); + } + + function formatJson() { + const parsed = parseEditor(); + if (!parsed.ok) { + setStatus(parsed.error, "error"); + return; + } + editor.value = JSON.stringify(parsed.value, null, 2); + onEdit(); + } + + function copyJson() { + navigator.clipboard.writeText(editor.value).then( + () => setStatus("Copied to clipboard", "ok"), + () => setStatus("Failed to copy", "error") + ); + } + + function setContent(value, opts) { + opts = opts || {}; + editor.value = JSON.stringify(value, null, 2); + lastServerText = editor.value; + setDirty(false); + render(); + if (opts.flash) setStatus("The assistant updated the content", "ok"); + } + + // Tab key inserts spaces instead of leaving the textarea. + function handleTab(e) { + if (e.key === "Tab") { + e.preventDefault(); + const s = editor.selectionStart; + const eSel = editor.selectionEnd; + editor.value = editor.value.slice(0, s) + " " + editor.value.slice(eSel); + editor.selectionStart = editor.selectionEnd = s + 2; + onEdit(); + } else if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { + e.preventDefault(); + doRenderNow(); + } + } + + // --- SSE (assistant pushes) --------------------------------------------- + + function connectEvents() { + try { + const es = new EventSource("./api/events?docId=" + encodeURIComponent(cfg.docId)); + es.addEventListener("content", (ev) => { + try { + const data = JSON.parse(ev.data); + if (data && data.content !== undefined) { + setContent(data.content, { flash: true }); + } + } catch (_) {} + }); + es.onerror = () => { + /* browser auto-reconnects */ + }; + } catch (_) {} + } + + // --- samples ------------------------------------------------------------ + + function populateSamples() { + const sel = document.getElementById("sample-select"); + (window.FLEX_SAMPLES || []).forEach((s) => { + const opt = document.createElement("option"); + opt.value = s.id; + opt.textContent = s.label; + sel.appendChild(opt); + }); + sel.addEventListener("change", () => { + const s = (window.FLEX_SAMPLES || []).find((x) => x.id === sel.value); + if (s) { + setContent(s.value); + saveToServer(s.value); + } + sel.value = ""; + }); + } + + function toggleBg() { + previewScroll.dataset.bg = previewScroll.dataset.bg === "dark" ? "light" : "dark"; + } + + // --- init --------------------------------------------------------------- + + function init() { + document.getElementById("btn-render").addEventListener("click", doRenderNow); + document.getElementById("btn-format").addEventListener("click", formatJson); + document.getElementById("btn-copy").addEventListener("click", copyJson); + document.getElementById("btn-bg").addEventListener("click", toggleBg); + editor.addEventListener("input", onEdit); + editor.addEventListener("keydown", handleTab); + populateSamples(); + connectEvents(); + + fetch("./api/state?docId=" + encodeURIComponent(cfg.docId)) + .then((r) => r.json()) + .then((data) => { + if (data && data.content !== undefined && data.content !== null) { + setContent(data.content); + } else { + setStatus("Enter Flex JSON or load a sample."); + } + }) + .catch(() => setStatus("Failed to load state.")); + } + + init(); +})(); diff --git a/tools/README.md b/tools/README.md index ca3af37..e852752 100644 --- a/tools/README.md +++ b/tools/README.md @@ -254,7 +254,7 @@ line mcp --allow-remote-replay # allow non-loopback destinations for webhook rep CLI commands are exposed as `line__` (except `webhook listen`). -- Read-only (available even under `--read-only`): `line_message_schema` / `line_richmenu_schema` / `line_richmenu_list` / `line_richmenu_get` / `line_richmenu_get_default` / `line_richmenu_id_of_user` / `line_insight_demographic` / `line_insight_deliveries` / `line_insight_followers` / `line_insight_events` / `line_insight_per_unit` / `line_insight_richmenu_summary` / `line_insight_richmenu_daily` / `line_audience_list` / `line_audience_get` / `line_bot_info` / `line_bot_quota` / `line_bot_quota_consumption` / `line_bot_profile` / `line_liff_list` / `line_token_verify` / `line_webhook_verify` / `line_webhook_get_endpoint` / `line_webhook_test_endpoint` / `line_ping` +- Read-only (available even under `--read-only`): `line_message_schema` / `line_richmenu_schema` / `line_richmenu_list` / `line_richmenu_get` / `line_richmenu_get_default` / `line_richmenu_id_of_user` / `line_insight_demographic` / `line_insight_deliveries` / `line_insight_followers` / `line_insight_events` / `line_insight_per_unit` / `line_insight_richmenu_summary` / `line_insight_richmenu_daily` / `line_audience_list` / `line_audience_get` / `line_bot_info` / `line_bot_quota` / `line_bot_quota_consumption` / `line_bot_profile` / `line_liff_list` / `line_token_verify` / `line_webhook_verify` / `line_webhook_get_endpoint` / `line_webhook_test_endpoint` / `line_flex_preview` / `line_flex_get_content` / `line_flex_validate` / `line_flex_open` / `line_ping` - Mutating (excluded under `--read-only`): `line_message_push` / `line_message_multicast` / `line_message_broadcast` / `line_message_reply` / `line_richmenu_create` / `line_richmenu_delete` / `line_richmenu_set_default` / `line_richmenu_cancel_default` / `line_richmenu_link` / `line_richmenu_unlink` / `line_audience_create` / `line_audience_add_users` / `line_audience_delete` / `line_shop_mission` / `line_liff_add` / `line_liff_update` / `line_liff_update_url` / `line_liff_delete` / `line_token_issue` / `line_token_revoke` / `line_webhook_replay` / `line_webhook_set_endpoint` > Audience by-file uploads (`upload-file` / `add-file`) are **CLI-only** — binary/file input is impractical over MCP, so `line_audience_create` directs you to the CLI for file uploads. @@ -273,6 +273,24 @@ A primary MCP use case is a **build → validate → send-and-see** loop: have t Typical flow: `line_message_schema type=flex` → build the Flex JSON → `line_message_push ... dryRun=true` (validate) → `line_message_push ...` (send to your own userId) → check on your device. +### Flex Message preview (`line_flex_*`) + +Preview a LINE Flex Message in a live, LINE-faithful browser view while you build it. The AI renders +your JSON with `line_flex_preview`; a loopback web page opens and updates in place as you iterate. +Adjust colors/spacing directly in the browser, then read them back with `line_flex_get_content` +before sending. No LINE API calls or credentials are involved, so these tools are available under +`--read-only`. + +- `line_flex_preview` — render Flex JSON and open/update the preview → `{ ok, url, valid, warnings, opened }` +- `line_flex_get_content` — get the JSON currently shown, including your browser edits → `{ content }` +- `line_flex_validate` — structurally validate Flex JSON → `{ valid, warnings }` +- `line_flex_open` — reopen the preview tab → `{ ok, url }` + +Env: `LINE_FLEX_MCP_NO_OPEN` (URL only, no auto-open), `LINE_FLEX_MCP_STATE_DIR` (state location). + +The same browser renderer is also available as a Copilot canvas extension, a bundled Node stdio MCP +(Claude Desktop/Code), and a no-server standalone HTML page — see `extensions/line-flex-viewer/`. + ### Security design (MCP) MCP tool results are assumed to enter the model's context (sent to the LLM provider, conversation history, logs), so the following protections are built in: diff --git a/tools/README_ja.md b/tools/README_ja.md index b14da7d..a881309 100644 --- a/tools/README_ja.md +++ b/tools/README_ja.md @@ -254,7 +254,7 @@ line mcp --allow-remote-replay # webhook replay の非ループバック宛先 CLI コマンドを `line__` の名前で公開します(`webhook listen` を除く)。 -- 読み取り系(`--read-only` でもこれらは有効): `line_message_schema` / `line_richmenu_schema` / `line_richmenu_list` / `line_richmenu_get` / `line_richmenu_get_default` / `line_richmenu_id_of_user` / `line_insight_demographic` / `line_insight_deliveries` / `line_insight_followers` / `line_insight_events` / `line_insight_per_unit` / `line_insight_richmenu_summary` / `line_insight_richmenu_daily` / `line_audience_list` / `line_audience_get` / `line_bot_info` / `line_bot_quota` / `line_bot_quota_consumption` / `line_bot_profile` / `line_liff_list` / `line_token_verify` / `line_webhook_verify` / `line_webhook_get_endpoint` / `line_webhook_test_endpoint` / `line_ping` +- 読み取り系(`--read-only` でもこれらは有効): `line_message_schema` / `line_richmenu_schema` / `line_richmenu_list` / `line_richmenu_get` / `line_richmenu_get_default` / `line_richmenu_id_of_user` / `line_insight_demographic` / `line_insight_deliveries` / `line_insight_followers` / `line_insight_events` / `line_insight_per_unit` / `line_insight_richmenu_summary` / `line_insight_richmenu_daily` / `line_audience_list` / `line_audience_get` / `line_bot_info` / `line_bot_quota` / `line_bot_quota_consumption` / `line_bot_profile` / `line_liff_list` / `line_token_verify` / `line_webhook_verify` / `line_webhook_get_endpoint` / `line_webhook_test_endpoint` / `line_flex_preview` / `line_flex_get_content` / `line_flex_validate` / `line_flex_open` / `line_ping` - 変更系(`--read-only` では除外): `line_message_push` / `line_message_multicast` / `line_message_broadcast` / `line_message_reply` / `line_richmenu_create` / `line_richmenu_delete` / `line_richmenu_set_default` / `line_richmenu_cancel_default` / `line_richmenu_link` / `line_richmenu_unlink` / `line_audience_create` / `line_audience_add_users` / `line_audience_delete` / `line_shop_mission` / `line_liff_add` / `line_liff_update` / `line_liff_update_url` / `line_liff_delete` / `line_token_issue` / `line_token_revoke` / `line_webhook_replay` / `line_webhook_set_endpoint` > オーディエンスのファイルアップロード(`upload-file` / `add-file`)は **CLI 専用**です(バイナリ/ファイルは MCP で扱いにくいため)。`line_audience_create` の説明からファイルアップロードは CLI へ誘導します。 @@ -273,6 +273,23 @@ MCP の主要ユースケースの一つが「**組み立てる → 検証する 典型的な流れ: `line_message_schema type=flex` → Flex JSON を組む → `line_message_push ... dryRun=true`(検証)→ `line_message_push ...`(自分の userId へ送信)→ 実機で確認。 +### Flex Message プレビュー(`line_flex_*`) + +LINE Flex Message を、LINE アプリに近い見た目でブラウザにライブプレビューしながら構築できます。 +AI が `line_flex_preview` で JSON をレンダリングすると、ループバックの Web ページが開き、反復のたびに +その場で更新されます。色や余白をブラウザ上で直接調整し、送信前に `line_flex_get_content` で調整結果を +取得できます。LINE API 呼び出しや認証情報は一切使わないため、`--read-only` でも利用できます。 + +- `line_flex_preview` — Flex JSON をレンダリングしてプレビューを開く/更新 → `{ ok, url, valid, warnings, opened }` +- `line_flex_get_content` — ブラウザでの編集を含む現在の JSON を取得 → `{ content }` +- `line_flex_validate` — Flex JSON を構造的に検証 → `{ valid, warnings }` +- `line_flex_open` — プレビュータブを開き直す → `{ ok, url }` + +環境変数: `LINE_FLEX_MCP_NO_OPEN`(自動で開かず URL のみ返す)、`LINE_FLEX_MCP_STATE_DIR`(状態の保存先)。 + +同じブラウザレンダラは、Copilot canvas 拡張・Node stdio MCP(Claude Desktop/Code 用の同梱サーバ)・ +サーバ不要の standalone HTML としても利用できます(`extensions/line-flex-viewer/` を参照)。 + ### セキュリティ設計(MCP) MCP ツールの戻り値はモデルのコンテキスト(LLM プロバイダへの送信・会話履歴・ログ)に載る前提で、以下の保護を組み込んでいます。 From 8d3da17d3c6bd0251f52045dee0380c34fbeeb62 Mon Sep 17 00:00:00 2001 From: Hirotada Kobayashi Date: Thu, 3 Sep 2026 15:56:37 +0900 Subject: [PATCH 02/12] docs(extensions): install from repo folder URL, not private gist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The line-flex-viewer README was carried over from the contribution bundle and still described private-gist distribution and `.github/extensions/...` paths. This repo places the extension at `extensions/line-flex-viewer/` and (per AGENT_TASK.md A2) the primary channel for a public repo is installing from the repo folder URL. Update both READMEs (EN/JA): - Lead the install/share section with `install_extension https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer`, keeping the gist path only as an alternative. - Fix `.github/extensions/line-flex-viewer/...` → `extensions/line-flex-viewer/...` in the standalone-serve, Claude Desktop, and Claude Code snippets. Co-Authored-By: Claude Opus 4.8 (1M context) --- extensions/line-flex-viewer/README.ja.md | 27 ++++++++++++++++------- extensions/line-flex-viewer/README.md | 28 +++++++++++++++++------- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/extensions/line-flex-viewer/README.ja.md b/extensions/line-flex-viewer/README.ja.md index d22ca9b..bad6511 100644 --- a/extensions/line-flex-viewer/README.ja.md +++ b/extensions/line-flex-viewer/README.ja.md @@ -19,7 +19,7 @@ Copilot CLI の **Canvas 拡張**です。 2. エージェントが **`set_content`** アクションで Canvas に push すると、パネルが即座に描画。 3. パネルのエディタで JSON を直接調整(ライブ再描画+自動保存)。 4. エージェントが **`get_content`** で編集結果を読み戻し、反復を続ける。 -5. 拡張をプライベート gist で公開/共有(下記参照)。 +5. 拡張をこのリポジトリから直接インストール/共有(下記参照)。 ## Canvas アクション(エージェント向け) @@ -73,7 +73,7 @@ standalone ページを利用します。 - **または静的配信**(推奨。一部ブラウザは `file://` を制限): ```bash - cd .github/extensions/line-flex-viewer/web + cd extensions/line-flex-viewer/web python -m http.server 8791 # → http://127.0.0.1:8791/standalone.html # または: npx serve . ``` @@ -119,7 +119,7 @@ Windows: `%APPDATA%\Claude\`)に追記: "mcpServers": { "line-flex-viewer": { "command": "node", - "args": ["/.github/extensions/line-flex-viewer/mcp/server.mjs"] + "args": ["/extensions/line-flex-viewer/mcp/server.mjs"] } } } @@ -128,7 +128,7 @@ Windows: `%APPDATA%\Claude\`)に追記: **Claude Code** — CLI で登録: ```bash -claude mcp add line-flex-viewer -- node /.github/extensions/line-flex-viewer/mcp/server.mjs +claude mcp add line-flex-viewer -- node /extensions/line-flex-viewer/mcp/server.mjs ``` 登録後、Claude に「この Flex Message をプレビューして」と頼めば `preview_flex_message` が呼ばれ、 @@ -146,8 +146,19 @@ claude mcp add line-flex-viewer -- node /.github/extensions/line-flex-view > **MCP に興味がない場合**は、上記「Copilot App 不要のブラウザプレビュー(standalone)」だけでも > ブラウザ単体で完結します。MCP サーバは「AI ↔ プレビュー」を自動連携したいとき向けです。 -## 公開/共有 +## インストール/共有 -このフォルダには `copilot-extension.json` が含まれているため、コマンドパレットの -「Share extension as gist…」または `share_extension` ツールでプライベート gist として共有でき、 -別環境で「Install extension from gist…」/ `install_extension` からインストールできます。 +これは公開リポジトリなので、拡張のインストールは**リポジトリのフォルダ URL から直接**行うのが +基本です(gist は不要): + +``` +install_extension https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer +``` + +サブディレクトリ構成(`mcp/`・`web/`)を保ったまま取得し、`copilot-extension.json` も自動で認識されます。 +Claude Desktop/Code から同梱の `mcp/server.mjs` を使う、任意のブラウザで `web/standalone.html` を開く、 +という方法もあります。 + +なお、このフォルダには `copilot-extension.json` が含まれているため、コマンドパレットの +「Share extension as gist…」または `share_extension` ツールでプライベート gist として共有し、 +別環境で「Install extension from gist…」/ `install_extension` からインストールすることも引き続き可能です。 diff --git a/extensions/line-flex-viewer/README.md b/extensions/line-flex-viewer/README.md index b83d8d5..d6be4b9 100644 --- a/extensions/line-flex-viewer/README.md +++ b/extensions/line-flex-viewer/README.md @@ -20,7 +20,7 @@ The same renderer is reused three ways: renders it instantly. 3. You tweak the JSON directly in the panel editor (live re-render + auto-save). 4. The agent reads your edits back with **`get_content`** and continues iterating. -5. Publish/share the extension via a private gist (see below). +5. Install/share the extension directly from this repo (see below). ## Canvas actions (agent-facing) @@ -74,7 +74,7 @@ which runs 100% client-side (no server, no agent) and reuses the same renderer: - **Or serve statically** (recommended; some browsers restrict `file://`): ```bash - cd .github/extensions/line-flex-viewer/web + cd extensions/line-flex-viewer/web python -m http.server 8791 # → http://127.0.0.1:8791/standalone.html # or: npx serve . ``` @@ -123,7 +123,7 @@ absolute path where you placed this repository. "mcpServers": { "line-flex-viewer": { "command": "node", - "args": ["/.github/extensions/line-flex-viewer/mcp/server.mjs"] + "args": ["/extensions/line-flex-viewer/mcp/server.mjs"] } } } @@ -132,7 +132,7 @@ absolute path where you placed this repository. **Claude Code** — register via the CLI: ```bash -claude mcp add line-flex-viewer -- node /.github/extensions/line-flex-viewer/mcp/server.mjs +claude mcp add line-flex-viewer -- node /extensions/line-flex-viewer/mcp/server.mjs ``` Once registered, ask Claude to "preview this Flex Message" and `preview_flex_message` @@ -150,8 +150,20 @@ can read your edits back with `get_flex_content`. > **Not interested in MCP?** The standalone browser preview above works entirely on > its own. The MCP server is for when you want to automate the "AI ↔ preview" loop. -## Publish / share +## Install / share -This folder includes `copilot-extension.json`, so it can be shared as a private gist -from the command palette ("Share extension as gist…") or the `share_extension` tool, -and installed elsewhere with "Install extension from gist…" / `install_extension`. +This is a public repo, so the primary way to install the extension is **directly from +the repo folder URL** — no gist required: + +``` +install_extension https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer +``` + +This preserves the subdirectory layout (`mcp/`, `web/`) and picks up +`copilot-extension.json` automatically. You can also just use the bundled +`mcp/server.mjs` from Claude Desktop/Code, or open `web/standalone.html` in any browser. + +Alternatively, because the folder includes `copilot-extension.json`, it can still be +shared as a private gist from the command palette ("Share extension as gist…") or the +`share_extension` tool, and installed elsewhere with "Install extension from gist…" / +`install_extension`. From 37e8f0e72b164ac7d9ff00458c2d912aef614123 Mon Sep 17 00:00:00 2001 From: Hirotada Kobayashi Date: Thu, 3 Sep 2026 16:08:46 +0900 Subject: [PATCH 03/12] docs(extensions): rewrite line-flex-viewer README as a user-facing guide Rework both READMEs (EN/JA) from the bundle's terse reference into a feature-and-usage guide written for the end user: what the tool is for, what you can do with it, the three ways to use it (Copilot canvas / MCP / standalone), install from the repo folder URL, and step-by-step usage for each surface. Facts verified against extension.mjs and mcp/server.mjs (canvas actions set_content/ get_content/validate; MCP tools preview_flex_message/get_flex_content/ validate_flex_message/open_preview; env vars; standalone share-link). The Japanese version is written natively in plain, natural Japanese rather than translated from the English. Co-Authored-By: Claude Opus 4.8 (1M context) --- extensions/line-flex-viewer/README.ja.md | 214 ++++++++++------------ extensions/line-flex-viewer/README.md | 216 ++++++++++------------- 2 files changed, 194 insertions(+), 236 deletions(-) diff --git a/extensions/line-flex-viewer/README.ja.md b/extensions/line-flex-viewer/README.ja.md index bad6511..a4827f7 100644 --- a/extensions/line-flex-viewer/README.ja.md +++ b/extensions/line-flex-viewer/README.ja.md @@ -1,118 +1,72 @@ -# LINE Flex Message ビューア(Canvas 拡張) +# LINE Flex Message ビューア *English version: [README.md](./README.md).* -[LINE Flex Message](https://developers.line.biz/ja/docs/messaging-api/using-flex-messages/) -の JSON を、アプリのサイドパネルで LINE 風にライブレンダリングしながらプレビュー・調整できる -Copilot CLI の **Canvas 拡張**です。 +[LINE の Flex Message](https://developers.line.biz/ja/docs/messaging-api/using-flex-messages/) +を作るとき、その JSON を LINE アプリと同じ見た目でその場に表示するツールです。Flex Message の +JSON は記述量が多く、書いただけでは仕上がりが想像しにくいもの。編集するそばからプレビューが +更新されるので、送る前にレイアウトや色をきちんと確認できます。 -同じレンダラを 3 通りの形で再利用しています。 +LINE アカウントや API キー、ネットワーク接続は必要ありません。プレビューはすべて手元の PC の +中だけで動きます。 -- **Canvas 拡張** — Copilot App のサイドパネル内でライブプレビュー。 -- **standalone ページ** — Copilot App 不要の 100% クライアントサイドなブラウザプレビュー。 -- **MCP サーバ** — 任意の MCP クライアント(Claude Desktop / Claude Code など)向けのライブブラウザプレビュー。 +## できること -## 想定ワークフロー +- **LINE と同じ見た目で確認できる** — bubble・carousel、画像、ボタンなど、主要なレイアウト/ + スタイルを、チャット風の背景(ライト/ダーク切替あり)の上に描画します。 +- **編集しながらリアルタイムに反映** — エディタで JSON を書き換えると、プレビューがすぐ更新 + されます。余白・色・文言を調整して、狙いどおりの見た目に近づけられます。 +- **AI エージェントと分担して作れる** — エージェント(例: + [`Line.OpenApi.Tools`](https://github.com/pierre3/line-openapi-dotnet) の MCP サーバ)が JSON を + 組み立て、細かい調整は手作業で行い、その結果をエージェントが読み戻して作業を続けられます。 -1. AI/エージェントが Flex Message JSON を組み立てる(例: - [`Line.OpenApi.Tools`](https://github.com/pierre3/line-openapi-dotnet) の MCP サーバ経由)。 -2. エージェントが **`set_content`** アクションで Canvas に push すると、パネルが即座に描画。 -3. パネルのエディタで JSON を直接調整(ライブ再描画+自動保存)。 -4. エージェントが **`get_content`** で編集結果を読み戻し、反復を続ける。 -5. 拡張をこのリポジトリから直接インストール/共有(下記参照)。 +## 3 通りの使い方 -## Canvas アクション(エージェント向け) +| | 向いている場面 | +| --- | --- | +| **Copilot canvas** | Copilot App のサイドパネルで、エージェントが作ったメッセージをその場で確認する。 | +| **MCP サーバ** | Claude Desktop / Claude Code など。プレビューがブラウザで開く。 | +| **standalone ページ** | アプリも設定もなしで手早く確認したいとき。HTML を開くだけ。 | -| アクション | 用途 | -| ------------- | ---- | -| `set_content` | Canvas に表示する Flex JSON を設定して再描画。flex メッセージ全体(`{type:"flex",altText,contents}`)、`bubble`/`carousel` コンテナ単体、または JSON 文字列を受け付ける。`{ ok, valid, warnings }` を返す。 | -| `get_content` | 現在 Canvas にある JSON を返す(**パネル上でのユーザー編集も反映**)。 | -| `validate` | 現在(または渡した)JSON の軽量な構造チェック。 | +3 つとも同じレンダラを使っているので、どこで見ても仕上がりは同じです。 -### open 入力 +## インストール -```jsonc -{ - "docId": "my-doc", // 任意: 安定した id。再オープン時に内容を復元 - "content": { /* ... */ }, // 任意: 初期 Flex JSON - "altText": "..." // 任意 -} -``` - -## パネル UI - -- **左**: JSON エディタ(ライブ再描画、`Ctrl/Cmd+Enter` で強制描画、`Tab` でスペース挿入)。 -- **右**: チャット背景上の LINE 風プレビュー(light/dark 切替)。 -- ツールバー: **Render**(プレビュー更新)/ **Format**(整形)/ **Copy JSON**(JSON コピー)/ - **Load sample**(サンプル読込)/ **Toggle background**(背景切替)。 - -## レンダリング対応範囲 - -コンテナ `bubble`(nano〜giga)と `carousel`、ブロック `header`/`hero`/`body`/`footer`(`styles` 対応)、 -コンポーネント `box`(horizontal/vertical/baseline)、`text`、`span`、`image`、`button`、`icon`、 -`separator`、`filler`、`video`(プレビュー)。多くのレイアウト/スタイルプロパティに対応 -(`flex`、`spacing`、`margin`、padding、borders、`cornerRadius`、`justifyContent`、`alignItems`、 -`position`/`offset`、`gravity`、`align`、`wrap`、`maxLines`、`aspectRatio`/`aspectMode` など)。 - -> プレビューは LINE レンダラの **CSS による近似**です。キーワード→px のサイズは LINE の -> ドキュメント上の尺度に従いますが、厳密なピクセル値は LINE アプリと多少異なる場合があります。 - -## 状態/ストレージ - -内容は `$COPILOT_HOME/extensions/line-flex-viewer/artifacts/.json` -(リポジトリ内には書きません)に `docId` をキーとして永続化され、再オープン時に復元されます。 - -## Copilot App 不要のブラウザプレビュー(standalone) - -Canvas プレビューの実体は **ローカル Web アプリ**で、Copilot App の実行中は開いているパネルごとに -ループバック HTTP サーバ(`127.0.0.1:<ランダムポート>`)が立ち上がります。**Copilot App なし**で -プレビューしたい場合は、100% クライアントサイド(サーバもエージェントも不要)で同じレンダラを使う -standalone ページを利用します。 - -- **直接開く**: `web/standalone.html` をダブルクリック(`file://` で動作)。 -- **または静的配信**(推奨。一部ブラウザは `file://` を制限): - - ```bash - cd extensions/line-flex-viewer/web - python -m http.server 8791 # → http://127.0.0.1:8791/standalone.html - # または: npx serve . - ``` +公開リポジトリなので、canvas 拡張はフォルダの URL から直接入れるのが一番簡単です。 -standalone の機能: +``` +install_extension https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer +``` -- ライブエディタ+ LINE 風プレビュー、**Format** / **Copy JSON** / **Load sample** / **Toggle background**。 -- **Open file / Download** — Flex JSON を `.json` ファイルとして入出力。 -- **Share link** — 現在の JSON を URL(`#json=`)にエンコードしてクリップボードにコピー。 - アプリもサーバ永続化も不要で、内容はリンク自体に含まれるため、誰とでもプレビューリンクを共有可能。 -- `localStorage` に自動保存し、リロード時に直近の編集を復元。 +Copilot canvas はこれだけで使えます。MCP サーバと standalone ページは、それぞれ以下のとおりで、 +別途インストールは不要です。 -読み込み時のシード優先順位: URL の `#json=` ハッシュ > `localStorage` > 先頭サンプル。 +## 使い方 -Copilot App が起動している間は、同じ standalone ページに -`http://127.0.0.1:<パネルポート>/standalone.html`(パネル URL + `/standalone.html`)でもアクセスできます。 +### Copilot App(canvas) -## Claude(Desktop / Code)など任意の MCP クライアントで使う +エージェントに「この Flex Message をプレビューして」と頼むと、canvas が開いて JSON が渡され、 +サイドパネルにすぐ描画されます。あとは次のように操作します。 -Claude Desktop / Claude Code は Canvas ではなく **MCP サーバ**で拡張します。本リポジトリには -同じレンダラを使う MCP サーバ(`mcp/server.mjs`、**Node 標準機能のみ・依存パッケージゼロ**)を同梱しています。 -AI が Flex JSON を組み立てて `preview_flex_message` を呼ぶと、ローカルにプレビュー用サーバを立ち上げ、 -**既定のブラウザでライブプレビュー**を開きます(以降の更新は SSE で自動反映)。 +- 左側で JSON を編集すると、右側のプレビューが入力に合わせて更新されます(`Ctrl/Cmd+Enter` で + 手動更新)。 +- ツールバーから **整形(Format)**・**JSON をコピー**・**サンプル読込**・**背景の切替**ができます。 +- 編集内容は自動で保存され、同じドキュメントを開き直すと復元されます。 -### 提供する MCP ツール +内部ではエージェントが 3 つのアクションを使います。`set_content`(JSON を表示・差し替え)、 +`get_content`(編集結果を読み戻す)、`validate`(構造の簡易チェック)です。 -| ツール | 説明 | -| ---- | ---- | -| `preview_flex_message` | Flex JSON(flex メッセージ / bubble / carousel / JSON 文字列)をブラウザでプレビュー。初回はブラウザを自動で開き、以降はライブ更新。`{ url, valid, warnings }` を返す。 | -| `get_flex_content` | プレビュー中の JSON を返す(**ブラウザ上でのユーザー編集も反映**)。 | -| `validate_flex_message` | JSON を構造検証(ブラウザ不要)。`{ valid, warnings }`。 | -| `open_preview` | 内容を変えずにプレビュータブを開き直し URL を返す。 | +### Claude Desktop / Claude Code(MCP) -### セットアップ +Claude は canvas ではなく MCP サーバで拡張します。そこで、この拡張には依存パッケージのない小さな +MCP サーバ(`mcp/server.mjs`)を同梱しています。AI がメッセージをプレビューすると、ローカルで +プレビューを立ち上げて既定のブラウザで開き、その後の変更もそのまま反映されます。 -必要なのは Node.js 18+ だけです(`npm install` 不要)。`` は本リポジトリを配置した絶対パスに置き換えてください。 +登録方法(Node.js 18 以上が必要。`npm install` は不要)。`` は、このリポジトリを配置した +パスに置き換えてください。 -**Claude Desktop** — `claude_desktop_config.json`(macOS: `~/Library/Application Support/Claude/`、 -Windows: `%APPDATA%\Claude\`)に追記: +**Claude Desktop** — `claude_desktop_config.json`(macOS: +`~/Library/Application Support/Claude/`、Windows: `%APPDATA%\Claude\`)に次を追記します。 ```jsonc { @@ -125,40 +79,66 @@ Windows: `%APPDATA%\Claude\`)に追記: } ``` -**Claude Code** — CLI で登録: +**Claude Code**: ```bash claude mcp add line-flex-viewer -- node /extensions/line-flex-viewer/mcp/server.mjs ``` -登録後、Claude に「この Flex Message をプレビューして」と頼めば `preview_flex_message` が呼ばれ、 -ブラウザにプレビューが表示されます。ブラウザ側で JSON を微調整すると、Claude は -`get_flex_content` で編集後の内容を読み戻せます。 +登録後、Claude に「この Flex Message をプレビューして」と頼むと、次のツールが使えます。 -### 環境変数(任意) +| ツール | 役割 | +| --- | --- | +| `preview_flex_message` | Flex JSON を描画し、ブラウザのプレビューを開く/更新する。 | +| `get_flex_content` | いま表示されている JSON を返す(**ブラウザで加えた編集も含む**)。 | +| `validate_flex_message` | ブラウザを開かずに JSON の構造をチェックする。 | +| `open_preview` | 閉じてしまったプレビュータブを開き直す。 | -| 変数 | 既定 | 用途 | -| ---- | ---- | ---- | -| `LINE_FLEX_MCP_NO_OPEN` | (未設定) | セットするとブラウザ自動起動を無効化(URL は返す)。 | -| `LINE_FLEX_MCP_STATE_DIR` | OS の一時ディレクトリ | プレビュー内容の保存先。 | -| `LINE_FLEX_MCP_HTML` | `viewer.html` | 配信するページ。`standalone.html` に変更も可。 | +任意の設定: -> **MCP に興味がない場合**は、上記「Copilot App 不要のブラウザプレビュー(standalone)」だけでも -> ブラウザ単体で完結します。MCP サーバは「AI ↔ プレビュー」を自動連携したいとき向けです。 +| 変数 | 既定値 | 用途 | +| --- | --- | --- | +| `LINE_FLEX_MCP_NO_OPEN` | (未設定) | ブラウザを自動で開かない(URL は返す)。 | +| `LINE_FLEX_MCP_STATE_DIR` | OS の一時ディレクトリ | 現在のプレビュー内容の保存先。 | +| `LINE_FLEX_MCP_HTML` | `viewer.html` | 配信するページ。`standalone.html` にすると client-side 版になる。 | -## インストール/共有 +### 単体で使う(standalone ページ) -これは公開リポジトリなので、拡張のインストールは**リポジトリのフォルダ URL から直接**行うのが -基本です(gist は不要): +アプリを使わず手早く見たいときは、`web/standalone.html` をブラウザで開きます。サーバもエージェント +も使わず、ブラウザだけで完結します。 -``` -install_extension https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer -``` +- ファイルをダブルクリックで開けます(`file://` で動作)。ローカルファイルの扱いが厳しいブラウザ + では、フォルダを配信してください。 + + ```bash + cd extensions/line-flex-viewer/web + python -m http.server 8791 # → http://127.0.0.1:8791/standalone.html + ``` + +- ライブ編集やツールバーに加えて、JSON ファイルの**読み込み・書き出し**や、**共有リンク**の作成が + できます。共有リンクはメッセージ全体を URL(`#json=...`)に埋め込むので、リンクを開いた人は同じ + プレビューを見られます。直前の編集はブラウザに記憶されます。 + +## プレビューの対応範囲 + +コンテナは `bubble`(nano〜giga)と `carousel`。ブロックは `header` / `hero` / `body` / `footer` +(`styles` 込み)。コンポーネントは `box`(horizontal / vertical / baseline)、`text`、`span`、 +`image`、`button`、`icon`、`separator`、`filler`、`video`。レイアウトやスタイルの多くに対応します +(`flex`、`spacing`、`margin`、padding、borders、`cornerRadius`、`justifyContent`、`alignItems`、 +`position` / `offset`、`gravity`、`align`、`wrap`、`maxLines`、`aspectRatio` / `aspectMode` など)。 + +> プレビューは LINE のレンダラを **CSS で近似**したものです。サイズは LINE の資料に沿っていますが、 +> 厳密なピクセル値は LINE アプリと多少ずれることがあります。まずここで見た目を固め、最終確認は +> 実機で行うのがおすすめです。 + +## 関連 + +この拡張ではなく、`line` コマンドライン/MCP ツールからプレビューを動かしたい場合は、同じレンダラを +[`Line.OpenApi.Tools`](https://github.com/pierre3/line-openapi-dotnet) が `line_flex_*` ツールとして +提供しています。 -サブディレクトリ構成(`mcp/`・`web/`)を保ったまま取得し、`copilot-extension.json` も自動で認識されます。 -Claude Desktop/Code から同梱の `mcp/server.mjs` を使う、任意のブラウザで `web/standalone.html` を開く、 -という方法もあります。 +## gist で共有する(任意) -なお、このフォルダには `copilot-extension.json` が含まれているため、コマンドパレットの -「Share extension as gist…」または `share_extension` ツールでプライベート gist として共有し、 -別環境で「Install extension from gist…」/ `install_extension` からインストールすることも引き続き可能です。 +フォルダには `copilot-extension.json` が含まれているので、プライベート gist としての共有 +(「Share extension as gist…」/ `share_extension`)と、他環境での `install_extension` によるインストール +も可能です。ただし公開リポジトリなら、上記のフォルダ URL からのインストールのほうが簡単です。 diff --git a/extensions/line-flex-viewer/README.md b/extensions/line-flex-viewer/README.md index d6be4b9..3adf11d 100644 --- a/extensions/line-flex-viewer/README.md +++ b/extensions/line-flex-viewer/README.md @@ -1,119 +1,71 @@ -# LINE Flex Message Viewer (Canvas Extension) +# LINE Flex Message Viewer *Read this in [日本語](./README.ja.md).* -A Copilot CLI **canvas extension** that previews and lets you tweak -[LINE Flex Message](https://developers.line.biz/en/docs/messaging-api/using-flex-messages/) -JSON with a live, LINE-style render in the app's side panel. +Build [LINE Flex Messages](https://developers.line.biz/en/docs/messaging-api/using-flex-messages/) +and see exactly how they'll look — a live, LINE-style preview that updates as you (or an +AI agent) edit the JSON. Flex Message JSON is verbose and hard to picture in your head; +this tool renders it the way the LINE app would, so you can get the layout and colors right +before you send anything. -The same renderer is reused three ways: +There's no LINE account, API key, or network access involved. The preview runs entirely on +your own machine. -- **Canvas extension** — live preview inside the Copilot App side panel. -- **Standalone page** — a 100% client-side browser preview, no Copilot App required. -- **MCP server** — a live browser preview for any MCP client (e.g. Claude Desktop / Claude Code). +## What you can do -## Intended workflow +- **See your Flex Message rendered like LINE** — bubbles, carousels, images, buttons, and + the usual layout/style properties, on a chat-style background with light/dark toggle. +- **Edit and watch it update live** — tweak the JSON in the editor and the preview re-renders + instantly. Adjust spacing, colors, and text until it looks right. +- **Work together with an AI agent** — an agent (for example, the + [`Line.OpenApi.Tools`](https://github.com/pierre3/line-openapi-dotnet) MCP server) builds the + JSON, you fine-tune it by hand, and the agent reads your changes back to keep iterating. -1. An AI/agent builds Flex Message JSON — e.g. via the - [`Line.OpenApi.Tools`](https://github.com/pierre3/line-openapi-dotnet) MCP server. -2. The agent pushes it to the canvas with the **`set_content`** action; the panel - renders it instantly. -3. You tweak the JSON directly in the panel editor (live re-render + auto-save). -4. The agent reads your edits back with **`get_content`** and continues iterating. -5. Install/share the extension directly from this repo (see below). +## Three ways to use it -## Canvas actions (agent-facing) +| | Best for | +| --- | --- | +| **Copilot canvas** | Previewing inside the Copilot App's side panel while an agent builds the message. | +| **MCP server** | Claude Desktop / Claude Code (or any MCP client) — the preview opens in your browser. | +| **Standalone page** | A quick look with no app and no setup — just open an HTML file in your browser. | -| Action | Purpose | -| ------------- | ------- | -| `set_content` | Set the Flex JSON shown in the canvas and re-render. Accepts a full flex message (`{type:"flex",altText,contents}`), a bare `bubble`/`carousel` container, or a JSON string. Returns `{ ok, valid, warnings }`. | -| `get_content` | Return the JSON currently in the canvas, **including the user's panel edits**. | -| `validate` | Lightweight structural check of the current (or supplied) JSON. | +All three share the same renderer, so a message looks the same everywhere. -### Open input +## Install -```jsonc -{ - "docId": "my-doc", // optional: stable id; reopening restores content - "content": { /* ... */ }, // optional: initial Flex JSON - "altText": "..." // optional -} -``` - -## Panel UI - -- **Left**: JSON editor (live re-render, `Ctrl/Cmd+Enter` to force render, `Tab` inserts spaces). -- **Right**: LINE-style preview on a chat background (toggle light/dark). -- Toolbar: **Render** / **Format** / **Copy JSON** / **Load sample** / **Toggle background**. - -## Rendering support - -Containers `bubble` (nano–giga) and `carousel`; blocks `header`/`hero`/`body`/`footer` -with `styles`; components `box` (horizontal/vertical/baseline), `text`, `span`, -`image`, `button`, `icon`, `separator`, `filler`, and `video` (preview). Most -layout/style properties are honored (`flex`, `spacing`, `margin`, padding, borders, -`cornerRadius`, `justifyContent`, `alignItems`, `position`/`offset`, `gravity`, -`align`, `wrap`, `maxLines`, `aspectRatio`/`aspectMode`, etc.). - -> The preview is a **CSS approximation** of LINE's renderer. Keyword→px sizes follow -> LINE's documented scale but exact pixel metrics may differ slightly from the LINE app. - -## State / storage - -Content persists under `$COPILOT_HOME/extensions/line-flex-viewer/artifacts/.json` -(never inside the repo), keyed by `docId` so reopening restores it. - -## Browser preview without the Copilot App (standalone) - -The canvas preview is really a **local web app** — the extension spins up a -loopback HTTP server (`127.0.0.1:`) per open panel while the Copilot -App is running. To preview **without the Copilot App**, use the standalone page, -which runs 100% client-side (no server, no agent) and reuses the same renderer: - -- **Open directly**: double-click `web/standalone.html` (works over `file://`). -- **Or serve statically** (recommended; some browsers restrict `file://`): - - ```bash - cd extensions/line-flex-viewer/web - python -m http.server 8791 # → http://127.0.0.1:8791/standalone.html - # or: npx serve . - ``` +This is a public repository, so the simplest way to add the canvas extension is straight from +the folder URL: -Standalone features: +``` +install_extension https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer +``` -- Live editor + LINE-style preview, **Format** / **Copy JSON** / **Load sample** / **Toggle background**. -- **Open file / Download** — import/export Flex JSON as a `.json` file. -- **Share link** — encodes the current JSON into the URL (`#json=`) and - copies it to the clipboard, so you can share a preview link with anyone (no app, - no server persistence — the content lives in the link itself). -- Auto-saves to `localStorage`, so your last edit is restored on reload. +That's all you need for the Copilot canvas. The MCP server and the standalone page are +described below and need no install step of their own. -Seed priority on load: URL `#json=` hash > `localStorage` > first sample. +## Using it -While the Copilot App *is* running, the same standalone page is also reachable at -`http://127.0.0.1:/standalone.html` (the panel URL with `/standalone.html`). +### In the Copilot App (canvas) -## Use from Claude (Desktop / Code) or any MCP client +Ask your agent to preview a Flex Message. It opens the canvas and pushes the JSON; the side +panel renders it immediately. From there: -Claude Desktop / Claude Code extend via **MCP servers**, not canvases. This repo -bundles an MCP server (`mcp/server.mjs`, **Node built-ins only — zero dependencies**) -that reuses the same renderer. When the AI builds Flex JSON and calls -`preview_flex_message`, the server starts a local preview server and opens a -**live preview in your default browser** (subsequent updates stream in over SSE). +- Edit the JSON on the left — the preview on the right updates as you type (`Ctrl/Cmd+Enter` + forces a re-render). +- Use the toolbar to **Format**, **Copy JSON**, **Load sample**, or **toggle the background**. +- Your edits are saved automatically and restored when you reopen the same document. -### MCP tools +Behind the scenes the agent uses three actions: `set_content` (show/replace the JSON), +`get_content` (read back your edits), and `validate` (a quick structural check). -| Tool | Description | -| ---- | ---- | -| `preview_flex_message` | Preview Flex JSON (flex message / bubble / carousel / JSON string) in the browser. Opens the browser on first use, then live-updates. Returns `{ url, valid, warnings }`. | -| `get_flex_content` | Return the JSON currently in the preview (**including the user's browser edits**). | -| `validate_flex_message` | Structurally validate JSON (no browser needed). Returns `{ valid, warnings }`. | -| `open_preview` | Open/reopen the preview tab and return its URL, without changing content. | +### In Claude Desktop / Claude Code (MCP) -### Setup +Claude extends through MCP servers rather than canvases, so this extension bundles a small, +zero-dependency MCP server (`mcp/server.mjs`). When the AI previews a message, the server +starts a local preview and opens it in your default browser; later changes stream in live. -You only need Node.js 18+ (`npm install` is not required). Replace `` with the -absolute path where you placed this repository. +Register it (needs Node.js 18+ — no `npm install`). Replace `` with where you cloned +this repository: **Claude Desktop** — add to `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/`, Windows: `%APPDATA%\Claude\`): @@ -129,41 +81,67 @@ absolute path where you placed this repository. } ``` -**Claude Code** — register via the CLI: +**Claude Code**: ```bash claude mcp add line-flex-viewer -- node /extensions/line-flex-viewer/mcp/server.mjs ``` -Once registered, ask Claude to "preview this Flex Message" and `preview_flex_message` -runs, showing the preview in your browser. Tweak the JSON in the browser and Claude -can read your edits back with `get_flex_content`. +Then ask Claude to "preview this Flex Message." The tools it can call: + +| Tool | What it does | +| --- | --- | +| `preview_flex_message` | Render Flex JSON and open/live-update the browser preview. | +| `get_flex_content` | Read the JSON currently shown, **including your in-browser edits**. | +| `validate_flex_message` | Check the JSON's structure without opening a browser. | +| `open_preview` | Reopen the preview tab (e.g. after you closed it). | -### Environment variables (optional) +Optional settings: | Variable | Default | Purpose | -| ---- | ---- | ---- | -| `LINE_FLEX_MCP_NO_OPEN` | (unset) | Set to disable auto-opening the browser (the URL is still returned). | -| `LINE_FLEX_MCP_STATE_DIR` | OS temp dir | Where preview content is persisted. | -| `LINE_FLEX_MCP_HTML` | `viewer.html` | Which page to serve (can be changed to `standalone.html`). | +| --- | --- | --- | +| `LINE_FLEX_MCP_NO_OPEN` | (unset) | Don't auto-open the browser; the URL is still returned. | +| `LINE_FLEX_MCP_STATE_DIR` | OS temp dir | Where the current preview content is saved. | +| `LINE_FLEX_MCP_HTML` | `viewer.html` | Which page to serve (set to `standalone.html` for the client-side viewer). | -> **Not interested in MCP?** The standalone browser preview above works entirely on -> its own. The MCP server is for when you want to automate the "AI ↔ preview" loop. +### On its own (standalone page) -## Install / share +Want a quick preview with no app at all? Open `web/standalone.html` in your browser. It runs +100% in the browser — no server, no agent: -This is a public repo, so the primary way to install the extension is **directly from -the repo folder URL** — no gist required: +- Double-click the file (works over `file://`), or serve the folder if your browser is strict + about local files: -``` -install_extension https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer -``` + ```bash + cd extensions/line-flex-viewer/web + python -m http.server 8791 # → http://127.0.0.1:8791/standalone.html + ``` + +- Besides live editing and the toolbar, it can **open/download** JSON files and create a + **share link** — the whole message is encoded into the URL (`#json=...`), so anyone who + opens the link sees the same preview. Your last edit is remembered in the browser. + +## What the preview supports + +Containers `bubble` (nano–giga) and `carousel`; the `header` / `hero` / `body` / `footer` +blocks with their `styles`; and components `box` (horizontal / vertical / baseline), `text`, +`span`, `image`, `button`, `icon`, `separator`, `filler`, and `video`. Most layout and style +properties are honored — `flex`, `spacing`, `margin`, padding, borders, `cornerRadius`, +`justifyContent`, `alignItems`, `position` / `offset`, `gravity`, `align`, `wrap`, `maxLines`, +`aspectRatio` / `aspectMode`, and so on. + +> The preview is a **CSS approximation** of LINE's renderer. Sizes follow LINE's documented +> scale, but exact pixels may differ slightly from the LINE app. Use it to get the design +> right, then confirm the final look on a real device. + +## Related + +Prefer to drive the preview from the `line` command-line / MCP tool instead of this extension? +The same renderer ships in [`Line.OpenApi.Tools`](https://github.com/pierre3/line-openapi-dotnet) +as the `line_flex_*` tools. -This preserves the subdirectory layout (`mcp/`, `web/`) and picks up -`copilot-extension.json` automatically. You can also just use the bundled -`mcp/server.mjs` from Claude Desktop/Code, or open `web/standalone.html` in any browser. +## Sharing as a gist (optional) -Alternatively, because the folder includes `copilot-extension.json`, it can still be -shared as a private gist from the command palette ("Share extension as gist…") or the -`share_extension` tool, and installed elsewhere with "Install extension from gist…" / -`install_extension`. +Because the folder includes `copilot-extension.json`, you can also share it as a private gist +("Share extension as gist…" / `share_extension`) and install it elsewhere with `install_extension`. +For a public repo, though, the folder-URL install above is simpler. From 2dad29dfd904a40c47ffefdcbce7dd0a023d3c5f Mon Sep 17 00:00:00 2001 From: Hirotada Kobayashi Date: Thu, 3 Sep 2026 17:51:14 +0900 Subject: [PATCH 04/12] docs: focus Flex viewer on Canvas + line_flex_* MCP; server.mjs as alternative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframe the Flex Message viewer docs around the two main scenarios — the Copilot App canvas and the Line.OpenApi.Tools `line_flex_*` MCP tools — and position the bundled Node `mcp/server.mjs` as an alternative for when the .NET tool isn't used. Drop the standalone HTML page and the gist-sharing sections from the extension README, and remove their mentions from the root and tools READMEs (EN/JA). Japanese written natively, not translated. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 5 +- README_ja.md | 4 +- extensions/line-flex-viewer/README.ja.md | 85 +++++++---------------- extensions/line-flex-viewer/README.md | 87 +++++++----------------- tools/README.md | 5 +- tools/README_ja.md | 5 +- 6 files changed, 58 insertions(+), 133 deletions(-) diff --git a/README.md b/README.md index c4eb35d..05c3d16 100644 --- a/README.md +++ b/README.md @@ -323,8 +323,9 @@ Copilot CLI canvas extension — install it from this repo: install_extension https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer ``` -Or use the bundled `mcp/server.mjs` from Claude Desktop/Code, or open `web/standalone.html` in any -browser. See [`extensions/line-flex-viewer/`](https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer). +A bundled zero-dependency `mcp/server.mjs` is also available as an alternative for Claude +Desktop/Code when you aren't using the `line` tool. See +[`extensions/line-flex-viewer/`](https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer). ## AI tools (`Line.OpenApi.Extensions.AI`) diff --git a/README_ja.md b/README_ja.md index 5bc9ed9..23890b0 100644 --- a/README_ja.md +++ b/README_ja.md @@ -323,8 +323,8 @@ Copilot CLI の canvas 拡張としても提供しており、このリポジト install_extension https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer ``` -あるいは Claude Desktop/Code から同梱の `mcp/server.mjs` を使う、または任意のブラウザで -`web/standalone.html` を開く方法もあります。詳細は +`line` ツールを使わない場合の代替として、依存パッケージのない `mcp/server.mjs` を Claude +Desktop/Code から利用することもできます。詳細は [`extensions/line-flex-viewer/`](https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer) を参照してください。 ## AI ツール(`Line.OpenApi.Extensions.AI`) diff --git a/extensions/line-flex-viewer/README.ja.md b/extensions/line-flex-viewer/README.ja.md index a4827f7..7ceaf1a 100644 --- a/extensions/line-flex-viewer/README.ja.md +++ b/extensions/line-flex-viewer/README.ja.md @@ -16,37 +16,23 @@ LINE アカウントや API キー、ネットワーク接続は必要ありま スタイルを、チャット風の背景(ライト/ダーク切替あり)の上に描画します。 - **編集しながらリアルタイムに反映** — エディタで JSON を書き換えると、プレビューがすぐ更新 されます。余白・色・文言を調整して、狙いどおりの見た目に近づけられます。 -- **AI エージェントと分担して作れる** — エージェント(例: - [`Line.OpenApi.Tools`](https://github.com/pierre3/line-openapi-dotnet) の MCP サーバ)が JSON を - 組み立て、細かい調整は手作業で行い、その結果をエージェントが読み戻して作業を続けられます。 +- **AI エージェントと分担して作れる** — エージェントが JSON を組み立て、細かい調整は手作業で行い、 + その結果をエージェントが読み戻して作業を続けられます。 -## 3 通りの使い方 +## 主な使い方 -| | 向いている場面 | -| --- | --- | -| **Copilot canvas** | Copilot App のサイドパネルで、エージェントが作ったメッセージをその場で確認する。 | -| **MCP サーバ** | Claude Desktop / Claude Code など。プレビューがブラウザで開く。 | -| **standalone ページ** | アプリも設定もなしで手早く確認したいとき。HTML を開くだけ。 | - -3 つとも同じレンダラを使っているので、どこで見ても仕上がりは同じです。 +使う場面は大きく 2 つです。どちらも同じレンダラを使っているので、仕上がりは変わりません。 -## インストール +### 1. Copilot App の Canvas -公開リポジトリなので、canvas 拡張はフォルダの URL から直接入れるのが一番簡単です。 +canvas 拡張を、このリポジトリのフォルダ URL から直接入れます。 ``` install_extension https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer ``` -Copilot canvas はこれだけで使えます。MCP サーバと standalone ページは、それぞれ以下のとおりで、 -別途インストールは不要です。 - -## 使い方 - -### Copilot App(canvas) - -エージェントに「この Flex Message をプレビューして」と頼むと、canvas が開いて JSON が渡され、 -サイドパネルにすぐ描画されます。あとは次のように操作します。 +あとはエージェントに「この Flex Message をプレビューして」と頼むと、canvas が開いて JSON が渡され、 +サイドパネルにすぐ描画されます。操作は次のとおりです。 - 左側で JSON を編集すると、右側のプレビューが入力に合わせて更新されます(`Ctrl/Cmd+Enter` で 手動更新)。 @@ -56,11 +42,20 @@ Copilot canvas はこれだけで使えます。MCP サーバと standalone ペ 内部ではエージェントが 3 つのアクションを使います。`set_content`(JSON を表示・差し替え)、 `get_content`(編集結果を読み戻す)、`validate`(構造の簡易チェック)です。 -### Claude Desktop / Claude Code(MCP) +### 2. Line.OpenApi.Tools の MCP(`line_flex_*`) -Claude は canvas ではなく MCP サーバで拡張します。そこで、この拡張には依存パッケージのない小さな -MCP サーバ(`mcp/server.mjs`)を同梱しています。AI がメッセージをプレビューすると、ローカルで -プレビューを立ち上げて既定のブラウザで開き、その後の変更もそのまま反映されます。 +すでに [`Line.OpenApi.Tools`](https://github.com/pierre3/line-openapi-dotnet) の `line` コマンドライン/ +MCP ツールを使っているなら、同じレンダラが `line_flex_*` ツールとして組み込まれています(別途の +インストールは不要)。エージェントが `line_flex_preview` を呼ぶとブラウザにプレビューが開き、編集の +たびにその場で更新されます。ブラウザで加えた編集は `line_flex_get_content` で読み戻せます。詳しくは +そのツールの README を参照してください。Claude Desktop / Claude Code などの MCP クライアントでは、 +これが基本の使い方になります。 + +## 代替: 同梱の Node MCP サーバ + +MCP でプレビューしたいけれど .NET の `line` ツールは使わない、という場合のために、このフォルダには +依存パッケージのない小さな MCP サーバ(`mcp/server.mjs`)も同梱しています。上記 2 の代わりに使える +手段で、.NET ツールを入れたくないときに便利です。 登録方法(Node.js 18 以上が必要。`npm install` は不要)。`` は、このリポジトリを配置した パスに置き換えてください。 @@ -94,30 +89,8 @@ claude mcp add line-flex-viewer -- node /extensions/line-flex-viewer/mcp/s | `validate_flex_message` | ブラウザを開かずに JSON の構造をチェックする。 | | `open_preview` | 閉じてしまったプレビュータブを開き直す。 | -任意の設定: - -| 変数 | 既定値 | 用途 | -| --- | --- | --- | -| `LINE_FLEX_MCP_NO_OPEN` | (未設定) | ブラウザを自動で開かない(URL は返す)。 | -| `LINE_FLEX_MCP_STATE_DIR` | OS の一時ディレクトリ | 現在のプレビュー内容の保存先。 | -| `LINE_FLEX_MCP_HTML` | `viewer.html` | 配信するページ。`standalone.html` にすると client-side 版になる。 | - -### 単体で使う(standalone ページ) - -アプリを使わず手早く見たいときは、`web/standalone.html` をブラウザで開きます。サーバもエージェント -も使わず、ブラウザだけで完結します。 - -- ファイルをダブルクリックで開けます(`file://` で動作)。ローカルファイルの扱いが厳しいブラウザ - では、フォルダを配信してください。 - - ```bash - cd extensions/line-flex-viewer/web - python -m http.server 8791 # → http://127.0.0.1:8791/standalone.html - ``` - -- ライブ編集やツールバーに加えて、JSON ファイルの**読み込み・書き出し**や、**共有リンク**の作成が - できます。共有リンクはメッセージ全体を URL(`#json=...`)に埋め込むので、リンクを開いた人は同じ - プレビューを見られます。直前の編集はブラウザに記憶されます。 +任意の設定として、`LINE_FLEX_MCP_NO_OPEN`(ブラウザを自動で開かず URL だけ返す)と +`LINE_FLEX_MCP_STATE_DIR`(現在のプレビュー内容の保存先)があります。 ## プレビューの対応範囲 @@ -130,15 +103,3 @@ claude mcp add line-flex-viewer -- node /extensions/line-flex-viewer/mcp/s > プレビューは LINE のレンダラを **CSS で近似**したものです。サイズは LINE の資料に沿っていますが、 > 厳密なピクセル値は LINE アプリと多少ずれることがあります。まずここで見た目を固め、最終確認は > 実機で行うのがおすすめです。 - -## 関連 - -この拡張ではなく、`line` コマンドライン/MCP ツールからプレビューを動かしたい場合は、同じレンダラを -[`Line.OpenApi.Tools`](https://github.com/pierre3/line-openapi-dotnet) が `line_flex_*` ツールとして -提供しています。 - -## gist で共有する(任意) - -フォルダには `copilot-extension.json` が含まれているので、プライベート gist としての共有 -(「Share extension as gist…」/ `share_extension`)と、他環境での `install_extension` によるインストール -も可能です。ただし公開リポジトリなら、上記のフォルダ URL からのインストールのほうが簡単です。 diff --git a/extensions/line-flex-viewer/README.md b/extensions/line-flex-viewer/README.md index 3adf11d..39b0866 100644 --- a/extensions/line-flex-viewer/README.md +++ b/extensions/line-flex-viewer/README.md @@ -17,38 +17,24 @@ your own machine. the usual layout/style properties, on a chat-style background with light/dark toggle. - **Edit and watch it update live** — tweak the JSON in the editor and the preview re-renders instantly. Adjust spacing, colors, and text until it looks right. -- **Work together with an AI agent** — an agent (for example, the - [`Line.OpenApi.Tools`](https://github.com/pierre3/line-openapi-dotnet) MCP server) builds the - JSON, you fine-tune it by hand, and the agent reads your changes back to keep iterating. +- **Work together with an AI agent** — an agent builds the JSON, you fine-tune it by hand, + and the agent reads your changes back to keep iterating. -## Three ways to use it +## Two main ways to use it -| | Best for | -| --- | --- | -| **Copilot canvas** | Previewing inside the Copilot App's side panel while an agent builds the message. | -| **MCP server** | Claude Desktop / Claude Code (or any MCP client) — the preview opens in your browser. | -| **Standalone page** | A quick look with no app and no setup — just open an HTML file in your browser. | - -All three share the same renderer, so a message looks the same everywhere. +The preview is used in two main scenarios. Both share the same renderer, so a message looks +the same in either one. -## Install +### 1. The Copilot App canvas -This is a public repository, so the simplest way to add the canvas extension is straight from -the folder URL: +Add the canvas extension straight from this repo's folder URL: ``` install_extension https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer ``` -That's all you need for the Copilot canvas. The MCP server and the standalone page are -described below and need no install step of their own. - -## Using it - -### In the Copilot App (canvas) - -Ask your agent to preview a Flex Message. It opens the canvas and pushes the JSON; the side -panel renders it immediately. From there: +Then ask your agent to preview a Flex Message. It opens the canvas and pushes the JSON, and +the side panel renders it immediately. From there: - Edit the JSON on the left — the preview on the right updates as you type (`Ctrl/Cmd+Enter` forces a re-render). @@ -58,11 +44,20 @@ panel renders it immediately. From there: Behind the scenes the agent uses three actions: `set_content` (show/replace the JSON), `get_content` (read back your edits), and `validate` (a quick structural check). -### In Claude Desktop / Claude Code (MCP) +### 2. The `line` MCP tool (Line.OpenApi.Tools) -Claude extends through MCP servers rather than canvases, so this extension bundles a small, -zero-dependency MCP server (`mcp/server.mjs`). When the AI previews a message, the server -starts a local preview and opens it in your default browser; later changes stream in live. +If you're already using the [`Line.OpenApi.Tools`](https://github.com/pierre3/line-openapi-dotnet) +`line` command-line / MCP tool, the same renderer is built in as the `line_flex_*` tools — +no separate install. Your AI agent calls `line_flex_preview` and the preview opens in your +browser, updating live as you iterate; `line_flex_get_content` reads your in-browser edits +back. See that tool's README for details. This is the recommended path for Claude Desktop / +Claude Code and other MCP clients. + +## Alternative: the bundled Node MCP server + +If you want an MCP preview but aren't using the .NET `line` tool, this folder also ships a +small, zero-dependency MCP server (`mcp/server.mjs`) that reuses the same renderer. It's a +self-contained alternative to option 2 above — handy when you'd rather not install the .NET tool. Register it (needs Node.js 18+ — no `npm install`). Replace `` with where you cloned this repository: @@ -96,30 +91,8 @@ Then ask Claude to "preview this Flex Message." The tools it can call: | `validate_flex_message` | Check the JSON's structure without opening a browser. | | `open_preview` | Reopen the preview tab (e.g. after you closed it). | -Optional settings: - -| Variable | Default | Purpose | -| --- | --- | --- | -| `LINE_FLEX_MCP_NO_OPEN` | (unset) | Don't auto-open the browser; the URL is still returned. | -| `LINE_FLEX_MCP_STATE_DIR` | OS temp dir | Where the current preview content is saved. | -| `LINE_FLEX_MCP_HTML` | `viewer.html` | Which page to serve (set to `standalone.html` for the client-side viewer). | - -### On its own (standalone page) - -Want a quick preview with no app at all? Open `web/standalone.html` in your browser. It runs -100% in the browser — no server, no agent: - -- Double-click the file (works over `file://`), or serve the folder if your browser is strict - about local files: - - ```bash - cd extensions/line-flex-viewer/web - python -m http.server 8791 # → http://127.0.0.1:8791/standalone.html - ``` - -- Besides live editing and the toolbar, it can **open/download** JSON files and create a - **share link** — the whole message is encoded into the URL (`#json=...`), so anyone who - opens the link sees the same preview. Your last edit is remembered in the browser. +Optional settings: `LINE_FLEX_MCP_NO_OPEN` (don't auto-open the browser; the URL is still +returned) and `LINE_FLEX_MCP_STATE_DIR` (where the current preview content is saved). ## What the preview supports @@ -133,15 +106,3 @@ properties are honored — `flex`, `spacing`, `margin`, padding, borders, `corne > The preview is a **CSS approximation** of LINE's renderer. Sizes follow LINE's documented > scale, but exact pixels may differ slightly from the LINE app. Use it to get the design > right, then confirm the final look on a real device. - -## Related - -Prefer to drive the preview from the `line` command-line / MCP tool instead of this extension? -The same renderer ships in [`Line.OpenApi.Tools`](https://github.com/pierre3/line-openapi-dotnet) -as the `line_flex_*` tools. - -## Sharing as a gist (optional) - -Because the folder includes `copilot-extension.json`, you can also share it as a private gist -("Share extension as gist…" / `share_extension`) and install it elsewhere with `install_extension`. -For a public repo, though, the folder-URL install above is simpler. diff --git a/tools/README.md b/tools/README.md index e852752..dd08e78 100644 --- a/tools/README.md +++ b/tools/README.md @@ -288,8 +288,9 @@ before sending. No LINE API calls or credentials are involved, so these tools ar Env: `LINE_FLEX_MCP_NO_OPEN` (URL only, no auto-open), `LINE_FLEX_MCP_STATE_DIR` (state location). -The same browser renderer is also available as a Copilot canvas extension, a bundled Node stdio MCP -(Claude Desktop/Code), and a no-server standalone HTML page — see `extensions/line-flex-viewer/`. +The same browser renderer is also available as a Copilot App canvas extension (with a bundled +zero-dependency Node MCP server as an alternative for Claude Desktop/Code) — see +`extensions/line-flex-viewer/`. ### Security design (MCP) diff --git a/tools/README_ja.md b/tools/README_ja.md index a881309..b29d2bb 100644 --- a/tools/README_ja.md +++ b/tools/README_ja.md @@ -287,8 +287,9 @@ AI が `line_flex_preview` で JSON をレンダリングすると、ループ 環境変数: `LINE_FLEX_MCP_NO_OPEN`(自動で開かず URL のみ返す)、`LINE_FLEX_MCP_STATE_DIR`(状態の保存先)。 -同じブラウザレンダラは、Copilot canvas 拡張・Node stdio MCP(Claude Desktop/Code 用の同梱サーバ)・ -サーバ不要の standalone HTML としても利用できます(`extensions/line-flex-viewer/` を参照)。 +同じブラウザレンダラは Copilot App の canvas 拡張としても利用できます(`line` ツールを使わない +場合の代替として、依存パッケージのない Node MCP サーバも同梱)。詳細は +`extensions/line-flex-viewer/` を参照してください。 ### セキュリティ設計(MCP) From 3cdeb77ac4a6c8f2028ee4a6f489be5f8abe091c Mon Sep 17 00:00:00 2001 From: Hirotada Kobayashi Date: Thu, 3 Sep 2026 18:12:10 +0900 Subject: [PATCH 05/12] docs(extensions): tabulate the "preview supports" section Turn the dense paragraph listing supported containers/blocks/components/props into a category table in both READMEs (EN/JA) for readability. Co-Authored-By: Claude Opus 4.8 (1M context) --- extensions/line-flex-viewer/README.ja.md | 11 ++++++----- extensions/line-flex-viewer/README.md | 12 ++++++------ 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/extensions/line-flex-viewer/README.ja.md b/extensions/line-flex-viewer/README.ja.md index 7ceaf1a..3a4c458 100644 --- a/extensions/line-flex-viewer/README.ja.md +++ b/extensions/line-flex-viewer/README.ja.md @@ -94,11 +94,12 @@ claude mcp add line-flex-viewer -- node /extensions/line-flex-viewer/mcp/s ## プレビューの対応範囲 -コンテナは `bubble`(nano〜giga)と `carousel`。ブロックは `header` / `hero` / `body` / `footer` -(`styles` 込み)。コンポーネントは `box`(horizontal / vertical / baseline)、`text`、`span`、 -`image`、`button`、`icon`、`separator`、`filler`、`video`。レイアウトやスタイルの多くに対応します -(`flex`、`spacing`、`margin`、padding、borders、`cornerRadius`、`justifyContent`、`alignItems`、 -`position` / `offset`、`gravity`、`align`、`wrap`、`maxLines`、`aspectRatio` / `aspectMode` など)。 +| 分類 | 対応するもの | +| --- | --- | +| **コンテナ** | `bubble`(サイズ `nano`〜`giga`)、`carousel` | +| **ブロック** | `header`・`hero`・`body`・`footer`(`styles` 込み) | +| **コンポーネント** | `box`(`horizontal` / `vertical` / `baseline`)、`text`、`span`、`image`、`button`、`icon`、`separator`、`filler`、`video` | +| **レイアウト/スタイル** | `flex`、`spacing`、`margin`、padding、borders、`cornerRadius`、`justifyContent`、`alignItems`、`position` / `offset`、`gravity`、`align`、`wrap`、`maxLines`、`aspectRatio` / `aspectMode` など | > プレビューは LINE のレンダラを **CSS で近似**したものです。サイズは LINE の資料に沿っていますが、 > 厳密なピクセル値は LINE アプリと多少ずれることがあります。まずここで見た目を固め、最終確認は diff --git a/extensions/line-flex-viewer/README.md b/extensions/line-flex-viewer/README.md index 39b0866..2696c23 100644 --- a/extensions/line-flex-viewer/README.md +++ b/extensions/line-flex-viewer/README.md @@ -96,12 +96,12 @@ returned) and `LINE_FLEX_MCP_STATE_DIR` (where the current preview content is sa ## What the preview supports -Containers `bubble` (nano–giga) and `carousel`; the `header` / `hero` / `body` / `footer` -blocks with their `styles`; and components `box` (horizontal / vertical / baseline), `text`, -`span`, `image`, `button`, `icon`, `separator`, `filler`, and `video`. Most layout and style -properties are honored — `flex`, `spacing`, `margin`, padding, borders, `cornerRadius`, -`justifyContent`, `alignItems`, `position` / `offset`, `gravity`, `align`, `wrap`, `maxLines`, -`aspectRatio` / `aspectMode`, and so on. +| Category | Supported | +| --- | --- | +| **Containers** | `bubble` (sizes `nano`–`giga`), `carousel` | +| **Blocks** | `header`, `hero`, `body`, `footer` — including their `styles` | +| **Components** | `box` (`horizontal` / `vertical` / `baseline`), `text`, `span`, `image`, `button`, `icon`, `separator`, `filler`, `video` | +| **Layout & style props** | `flex`, `spacing`, `margin`, padding, borders, `cornerRadius`, `justifyContent`, `alignItems`, `position` / `offset`, `gravity`, `align`, `wrap`, `maxLines`, `aspectRatio` / `aspectMode`, and more | > The preview is a **CSS approximation** of LINE's renderer. Sizes follow LINE's documented > scale, but exact pixels may differ slightly from the LINE app. Use it to get the design From b97101bc34ee93101f1b97362e65a6dc02758dde Mon Sep 17 00:00:00 2001 From: Hirotada Kobayashi Date: Thu, 3 Sep 2026 18:22:11 +0900 Subject: [PATCH 06/12] docs(extensions): point Line.OpenApi.Tools link at the tools folder The section-2 link went to the repo root; point it at tools/Line.OpenApi.Tools instead (EN/JA). Co-Authored-By: Claude Opus 4.8 (1M context) --- extensions/line-flex-viewer/README.ja.md | 2 +- extensions/line-flex-viewer/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/line-flex-viewer/README.ja.md b/extensions/line-flex-viewer/README.ja.md index 3a4c458..8053e67 100644 --- a/extensions/line-flex-viewer/README.ja.md +++ b/extensions/line-flex-viewer/README.ja.md @@ -44,7 +44,7 @@ install_extension https://github.com/pierre3/line-openapi-dotnet/tree/main/exten ### 2. Line.OpenApi.Tools の MCP(`line_flex_*`) -すでに [`Line.OpenApi.Tools`](https://github.com/pierre3/line-openapi-dotnet) の `line` コマンドライン/ +すでに [`Line.OpenApi.Tools`](https://github.com/pierre3/line-openapi-dotnet/tree/main/tools/Line.OpenApi.Tools) の `line` コマンドライン/ MCP ツールを使っているなら、同じレンダラが `line_flex_*` ツールとして組み込まれています(別途の インストールは不要)。エージェントが `line_flex_preview` を呼ぶとブラウザにプレビューが開き、編集の たびにその場で更新されます。ブラウザで加えた編集は `line_flex_get_content` で読み戻せます。詳しくは diff --git a/extensions/line-flex-viewer/README.md b/extensions/line-flex-viewer/README.md index 2696c23..66006d4 100644 --- a/extensions/line-flex-viewer/README.md +++ b/extensions/line-flex-viewer/README.md @@ -46,7 +46,7 @@ Behind the scenes the agent uses three actions: `set_content` (show/replace the ### 2. The `line` MCP tool (Line.OpenApi.Tools) -If you're already using the [`Line.OpenApi.Tools`](https://github.com/pierre3/line-openapi-dotnet) +If you're already using the [`Line.OpenApi.Tools`](https://github.com/pierre3/line-openapi-dotnet/tree/main/tools/Line.OpenApi.Tools) `line` command-line / MCP tool, the same renderer is built in as the `line_flex_*` tools — no separate install. Your AI agent calls `line_flex_preview` and the preview opens in your browser, updating live as you iterate; `line_flex_get_content` reads your in-browser edits From 02398d85ef568023767f951a66dccad9bb398ec2 Mon Sep 17 00:00:00 2001 From: Hirotada Kobayashi Date: Thu, 3 Sep 2026 18:33:23 +0900 Subject: [PATCH 07/12] docs(extensions): link Line.OpenApi.Tools to tools/ (README-visible) Point the section-2 link at tools/ so the tool README is shown directly (EN/JA). Co-Authored-By: Claude Opus 4.8 (1M context) --- extensions/line-flex-viewer/README.ja.md | 2 +- extensions/line-flex-viewer/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/line-flex-viewer/README.ja.md b/extensions/line-flex-viewer/README.ja.md index 8053e67..927d3a4 100644 --- a/extensions/line-flex-viewer/README.ja.md +++ b/extensions/line-flex-viewer/README.ja.md @@ -44,7 +44,7 @@ install_extension https://github.com/pierre3/line-openapi-dotnet/tree/main/exten ### 2. Line.OpenApi.Tools の MCP(`line_flex_*`) -すでに [`Line.OpenApi.Tools`](https://github.com/pierre3/line-openapi-dotnet/tree/main/tools/Line.OpenApi.Tools) の `line` コマンドライン/ +すでに [`Line.OpenApi.Tools`](https://github.com/pierre3/line-openapi-dotnet/tree/main/tools) の `line` コマンドライン/ MCP ツールを使っているなら、同じレンダラが `line_flex_*` ツールとして組み込まれています(別途の インストールは不要)。エージェントが `line_flex_preview` を呼ぶとブラウザにプレビューが開き、編集の たびにその場で更新されます。ブラウザで加えた編集は `line_flex_get_content` で読み戻せます。詳しくは diff --git a/extensions/line-flex-viewer/README.md b/extensions/line-flex-viewer/README.md index 66006d4..fed8d1c 100644 --- a/extensions/line-flex-viewer/README.md +++ b/extensions/line-flex-viewer/README.md @@ -46,7 +46,7 @@ Behind the scenes the agent uses three actions: `set_content` (show/replace the ### 2. The `line` MCP tool (Line.OpenApi.Tools) -If you're already using the [`Line.OpenApi.Tools`](https://github.com/pierre3/line-openapi-dotnet/tree/main/tools/Line.OpenApi.Tools) +If you're already using the [`Line.OpenApi.Tools`](https://github.com/pierre3/line-openapi-dotnet/tree/main/tools) `line` command-line / MCP tool, the same renderer is built in as the `line_flex_*` tools — no separate install. Your AI agent calls `line_flex_preview` and the preview opens in your browser, updating live as you iterate; `line_flex_get_content` reads your in-browser edits From 833e808dd8a98bab2093779ed80797246a32b2ae Mon Sep 17 00:00:00 2001 From: Hirotada Kobayashi Date: Thu, 3 Sep 2026 18:42:37 +0900 Subject: [PATCH 08/12] docs(tools): tabulate the MCP tool list by category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the two dense read-only/mutating bullets with per-category tables (General, Messaging, Flex, Bot, Rich menu, Insight, Manage Audience, Shop, LIFF, Token, Webhook). Columns: tool name, summary, Read-only (✓ = available under --read-only, ✗ = mutating). Summaries taken from each tool's [Description]. Fold the audience by-file and rich-menu image-upload CLI-only notes into their tables. EN and JA. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/README.md | 119 ++++++++++++++++++++++++++++++++++++++++++--- tools/README_ja.md | 119 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 226 insertions(+), 12 deletions(-) diff --git a/tools/README.md b/tools/README.md index dd08e78..66976a9 100644 --- a/tools/README.md +++ b/tools/README.md @@ -252,12 +252,119 @@ line mcp --allow-remote-replay # allow non-loopback destinations for webhook rep ### Tool list -CLI commands are exposed as `line__` (except `webhook listen`). - -- Read-only (available even under `--read-only`): `line_message_schema` / `line_richmenu_schema` / `line_richmenu_list` / `line_richmenu_get` / `line_richmenu_get_default` / `line_richmenu_id_of_user` / `line_insight_demographic` / `line_insight_deliveries` / `line_insight_followers` / `line_insight_events` / `line_insight_per_unit` / `line_insight_richmenu_summary` / `line_insight_richmenu_daily` / `line_audience_list` / `line_audience_get` / `line_bot_info` / `line_bot_quota` / `line_bot_quota_consumption` / `line_bot_profile` / `line_liff_list` / `line_token_verify` / `line_webhook_verify` / `line_webhook_get_endpoint` / `line_webhook_test_endpoint` / `line_flex_preview` / `line_flex_get_content` / `line_flex_validate` / `line_flex_open` / `line_ping` -- Mutating (excluded under `--read-only`): `line_message_push` / `line_message_multicast` / `line_message_broadcast` / `line_message_reply` / `line_richmenu_create` / `line_richmenu_delete` / `line_richmenu_set_default` / `line_richmenu_cancel_default` / `line_richmenu_link` / `line_richmenu_unlink` / `line_audience_create` / `line_audience_add_users` / `line_audience_delete` / `line_shop_mission` / `line_liff_add` / `line_liff_update` / `line_liff_update_url` / `line_liff_delete` / `line_token_issue` / `line_token_revoke` / `line_webhook_replay` / `line_webhook_set_endpoint` - -> Audience by-file uploads (`upload-file` / `add-file`) are **CLI-only** — binary/file input is impractical over MCP, so `line_audience_create` directs you to the CLI for file uploads. +CLI commands are exposed as `line__` (except `webhook listen`). In the tables below, a +**✓** in *Read-only* means the tool is available even under `--read-only`; **✗** means it mutates +state and is excluded under `--read-only`. + +#### General + +| Tool | Summary | Read-only | +| --- | --- | :---: | +| `line_ping` | Health check; returns `"pong"`. | ✓ | + +#### Messaging + +| Tool | Summary | Read-only | +| --- | --- | :---: | +| `line_message_schema` | JSON Schema for message objects (build flex/template). | ✓ | +| `line_message_push` | Send a push message to a user/group/room. | ✗ | +| `line_message_multicast` | Send a message to multiple users. | ✗ | +| `line_message_broadcast` | Send a message to all friends of the bot. | ✗ | +| `line_message_reply` | Send a reply using a reply token. | ✗ | + +#### Flex Message preview + +| Tool | Summary | Read-only | +| --- | --- | :---: | +| `line_flex_preview` | Render Flex JSON in a live browser preview. | ✓ | +| `line_flex_get_content` | Read the JSON shown, including your browser edits. | ✓ | +| `line_flex_validate` | Structurally validate Flex JSON. | ✓ | +| `line_flex_open` | Reopen the preview tab. | ✓ | + +#### Bot + +| Tool | Summary | Read-only | +| --- | --- | :---: | +| `line_bot_info` | Bot info (userId, basicId, displayName, chat mode). | ✓ | +| `line_bot_quota` | Monthly message quota limit. | ✓ | +| `line_bot_quota_consumption` | This month's message consumption count. | ✓ | +| `line_bot_profile` | A user's profile by user id. | ✓ | + +#### Rich menu + +| Tool | Summary | Read-only | +| --- | --- | :---: | +| `line_richmenu_schema` | JSON Schema for a rich menu object. | ✓ | +| `line_richmenu_list` | List the channel's rich menus. | ✓ | +| `line_richmenu_get` | Get a rich menu by id. | ✓ | +| `line_richmenu_get_default` | Get the default rich menu id. | ✓ | +| `line_richmenu_id_of_user` | Get the rich menu linked to a user. | ✓ | +| `line_richmenu_create` | Create a rich menu (`dryRun` validates only). | ✗ | +| `line_richmenu_delete` | Delete a rich menu. | ✗ | +| `line_richmenu_set_default` | Set the default rich menu for all users. | ✗ | +| `line_richmenu_cancel_default` | Cancel the default rich menu. | ✗ | +| `line_richmenu_link` | Link a rich menu to a user. | ✗ | +| `line_richmenu_unlink` | Unlink the rich menu from a user. | ✗ | + +> Image upload is **CLI-only** (`line richmenu image --file menu.png`); binary input is impractical over MCP. + +#### Insight + +| Tool | Summary | Read-only | +| --- | --- | :---: | +| `line_insight_demographic` | Friends' demographic attributes. | ✓ | +| `line_insight_deliveries` | Number of messages sent on a date. | ✓ | +| `line_insight_followers` | Number of followers as of a date. | ✓ | +| `line_insight_events` | Open/click stats of a message by request id. | ✓ | +| `line_insight_per_unit` | Stats for a custom aggregation unit over a period. | ✓ | +| `line_insight_richmenu_summary` | Aggregate rich-menu display/click stats. | ✓ | +| `line_insight_richmenu_daily` | Daily rich-menu display/click stats. | ✓ | + +#### Manage Audience + +| Tool | Summary | Read-only | +| --- | --- | :---: | +| `line_audience_list` | List audience groups (paginated). | ✓ | +| `line_audience_get` | Get an audience group and its jobs. | ✓ | +| `line_audience_create` | Create an audience group with initial user IDs. | ✗ | +| `line_audience_add_users` | Add user IDs to an audience group. | ✗ | +| `line_audience_delete` | Delete an audience group. | ✗ | + +> By-file uploads (`upload-file` / `add-file`) are **CLI-only**; binary/file input is impractical over MCP. + +#### Shop + +| Tool | Summary | Read-only | +| --- | --- | :---: | +| `line_shop_mission` | Send a mission sticker to a user. | ✗ | + +#### LIFF + +| Tool | Summary | Read-only | +| --- | --- | :---: | +| `line_liff_list` | List registered LIFF apps. | ✓ | +| `line_liff_add` | Add a LIFF app. | ✗ | +| `line_liff_update` | Update a LIFF app. | ✗ | +| `line_liff_update_url` | Update only a LIFF app's endpoint URL. | ✗ | +| `line_liff_delete` | Delete a LIFF app. | ✗ | + +#### Token + +| Tool | Summary | Read-only | +| --- | --- | :---: | +| `line_token_verify` | Verify a token's validity/lifetime (does not return the token). | ✓ | +| `line_token_issue` | Issue a token and store it in the profile. | ✗ | +| `line_token_revoke` | Revoke a token. | ✗ | + +#### Webhook + +| Tool | Summary | Read-only | +| --- | --- | :---: | +| `line_webhook_verify` | Verify a payload signature and summarize its events. | ✓ | +| `line_webhook_get_endpoint` | Get the configured webhook endpoint URL. | ✓ | +| `line_webhook_test_endpoint` | Ask LINE to send a test event; report reachability. | ✓ | +| `line_webhook_replay` | POST a payload to a local URL for debugging. | ✗ | +| `line_webhook_set_endpoint` | Set the channel's webhook endpoint URL. | ✗ | > **Rich menu dev cycle across MCP + CLI:** assemble the menu with the agent (`line_richmenu_schema` → build JSON → `line_richmenu_create` with `dryRun=true` to validate, then create), then upload the image with the **CLI** (`line richmenu image --file menu.png`) — binary upload is impractical over MCP, so it is intentionally CLI-only — and finally `line_richmenu_set_default` / `line_richmenu_link` and check on your device. diff --git a/tools/README_ja.md b/tools/README_ja.md index b29d2bb..6019676 100644 --- a/tools/README_ja.md +++ b/tools/README_ja.md @@ -252,12 +252,119 @@ line mcp --allow-remote-replay # webhook replay の非ループバック宛先 ### ツール一覧 -CLI コマンドを `line__` の名前で公開します(`webhook listen` を除く)。 - -- 読み取り系(`--read-only` でもこれらは有効): `line_message_schema` / `line_richmenu_schema` / `line_richmenu_list` / `line_richmenu_get` / `line_richmenu_get_default` / `line_richmenu_id_of_user` / `line_insight_demographic` / `line_insight_deliveries` / `line_insight_followers` / `line_insight_events` / `line_insight_per_unit` / `line_insight_richmenu_summary` / `line_insight_richmenu_daily` / `line_audience_list` / `line_audience_get` / `line_bot_info` / `line_bot_quota` / `line_bot_quota_consumption` / `line_bot_profile` / `line_liff_list` / `line_token_verify` / `line_webhook_verify` / `line_webhook_get_endpoint` / `line_webhook_test_endpoint` / `line_flex_preview` / `line_flex_get_content` / `line_flex_validate` / `line_flex_open` / `line_ping` -- 変更系(`--read-only` では除外): `line_message_push` / `line_message_multicast` / `line_message_broadcast` / `line_message_reply` / `line_richmenu_create` / `line_richmenu_delete` / `line_richmenu_set_default` / `line_richmenu_cancel_default` / `line_richmenu_link` / `line_richmenu_unlink` / `line_audience_create` / `line_audience_add_users` / `line_audience_delete` / `line_shop_mission` / `line_liff_add` / `line_liff_update` / `line_liff_update_url` / `line_liff_delete` / `line_token_issue` / `line_token_revoke` / `line_webhook_replay` / `line_webhook_set_endpoint` - -> オーディエンスのファイルアップロード(`upload-file` / `add-file`)は **CLI 専用**です(バイナリ/ファイルは MCP で扱いにくいため)。`line_audience_create` の説明からファイルアップロードは CLI へ誘導します。 +CLI コマンドを `line__` の名前で公開します(`webhook listen` を除く)。下表の +*Read-only* 列は、**✓** = `--read-only` でも使えるツール、**✗** = 状態を変更するため `--read-only` +では除外されるツールを表します。 + +#### 全般 + +| ツール名 | 概要 | Read-only | +| --- | --- | :---: | +| `line_ping` | 疎通確認。`"pong"` を返す。 | ✓ | + +#### メッセージ + +| ツール名 | 概要 | Read-only | +| --- | --- | :---: | +| `line_message_schema` | メッセージオブジェクトの JSON Schema(flex/template の組み立て用)。 | ✓ | +| `line_message_push` | ユーザー/グループ/ルームへプッシュ送信。 | ✗ | +| `line_message_multicast` | 複数ユーザーへ送信。 | ✗ | +| `line_message_broadcast` | 友だち全員へ送信。 | ✗ | +| `line_message_reply` | リプライトークンで応答メッセージを送信。 | ✗ | + +#### Flex Message プレビュー + +| ツール名 | 概要 | Read-only | +| --- | --- | :---: | +| `line_flex_preview` | Flex JSON をブラウザにライブプレビュー。 | ✓ | +| `line_flex_get_content` | 表示中の JSON を取得(ブラウザでの編集も含む)。 | ✓ | +| `line_flex_validate` | Flex JSON を構造的に検証。 | ✓ | +| `line_flex_open` | プレビュータブを開き直す。 | ✓ | + +#### Bot + +| ツール名 | 概要 | Read-only | +| --- | --- | :---: | +| `line_bot_info` | Bot の情報(userId・basicId・表示名・チャットモード)。 | ✓ | +| `line_bot_quota` | 月間メッセージ上限。 | ✓ | +| `line_bot_quota_consumption` | 当月のメッセージ消費数。 | ✓ | +| `line_bot_profile` | user id からユーザープロフィールを取得。 | ✓ | + +#### リッチメニュー + +| ツール名 | 概要 | Read-only | +| --- | --- | :---: | +| `line_richmenu_schema` | リッチメニューオブジェクトの JSON Schema。 | ✓ | +| `line_richmenu_list` | チャネルのリッチメニュー一覧。 | ✓ | +| `line_richmenu_get` | id を指定してリッチメニューを取得。 | ✓ | +| `line_richmenu_get_default` | 既定リッチメニューの id を取得。 | ✓ | +| `line_richmenu_id_of_user` | ユーザーに紐づくリッチメニュー id を取得。 | ✓ | +| `line_richmenu_create` | リッチメニューを作成(`dryRun` は検証のみ)。 | ✗ | +| `line_richmenu_delete` | リッチメニューを削除。 | ✗ | +| `line_richmenu_set_default` | 全ユーザーの既定リッチメニューに設定。 | ✗ | +| `line_richmenu_cancel_default` | 既定リッチメニューを解除。 | ✗ | +| `line_richmenu_link` | ユーザーにリッチメニューを紐づけ。 | ✗ | +| `line_richmenu_unlink` | ユーザーからリッチメニューの紐づけを解除。 | ✗ | + +> 画像アップロードは **CLI 専用**です(`line richmenu image --file menu.png`)。バイナリは MCP で扱いにくいため。 + +#### Insight + +| ツール名 | 概要 | Read-only | +| --- | --- | :---: | +| `line_insight_demographic` | 友だちの属性(性別・年代・地域など)。 | ✓ | +| `line_insight_deliveries` | 指定日に送信したメッセージ数。 | ✓ | +| `line_insight_followers` | 指定日時点の友だち数。 | ✓ | +| `line_insight_events` | request id 指定でメッセージの開封/クリック統計。 | ✓ | +| `line_insight_per_unit` | 集計ユニット単位・期間の統計。 | ✓ | +| `line_insight_richmenu_summary` | リッチメニューの表示/クリック統計(期間集計)。 | ✓ | +| `line_insight_richmenu_daily` | リッチメニューの表示/クリック統計(日別)。 | ✓ | + +#### Manage Audience(オーディエンス) + +| ツール名 | 概要 | Read-only | +| --- | --- | :---: | +| `line_audience_list` | オーディエンスグループ一覧(ページング)。 | ✓ | +| `line_audience_get` | オーディエンスグループとジョブを取得。 | ✓ | +| `line_audience_create` | 初期ユーザー ID を含めてオーディエンスグループを作成。 | ✗ | +| `line_audience_add_users` | オーディエンスグループにユーザー ID を追加。 | ✗ | +| `line_audience_delete` | オーディエンスグループを削除。 | ✗ | + +> ファイルアップロード(`upload-file` / `add-file`)は **CLI 専用**です。バイナリ/ファイルは MCP で扱いにくいため。 + +#### Shop + +| ツール名 | 概要 | Read-only | +| --- | --- | :---: | +| `line_shop_mission` | ユーザーにミッションスタンプを送る。 | ✗ | + +#### LIFF + +| ツール名 | 概要 | Read-only | +| --- | --- | :---: | +| `line_liff_list` | 登録済みの LIFF アプリ一覧。 | ✓ | +| `line_liff_add` | LIFF アプリを追加。 | ✗ | +| `line_liff_update` | LIFF アプリを更新。 | ✗ | +| `line_liff_update_url` | LIFF アプリのエンドポイント URL のみ更新。 | ✗ | +| `line_liff_delete` | LIFF アプリを削除。 | ✗ | + +#### トークン + +| ツール名 | 概要 | Read-only | +| --- | --- | :---: | +| `line_token_verify` | トークンの有効性/残存期間を検証(トークンは返さない)。 | ✓ | +| `line_token_issue` | トークンを発行しプロファイルに保存。 | ✗ | +| `line_token_revoke` | トークンを失効させる。 | ✗ | + +#### Webhook + +| ツール名 | 概要 | Read-only | +| --- | --- | :---: | +| `line_webhook_verify` | ペイロードの署名を検証しイベントを要約。 | ✓ | +| `line_webhook_get_endpoint` | 設定済みの Webhook エンドポイント URL を取得。 | ✓ | +| `line_webhook_test_endpoint` | LINE にテストイベント送信を依頼し到達性を報告。 | ✓ | +| `line_webhook_replay` | デバッグ用にペイロードをローカル URL へ POST。 | ✗ | +| `line_webhook_set_endpoint` | チャネルの Webhook エンドポイント URL を設定。 | ✗ | > **MCP + CLI をまたぐリッチメニュー開発サイクル:** エージェントで組み立て(`line_richmenu_schema` → JSON 生成 → `line_richmenu_create` に `dryRun=true` で検証してから作成)、画像は **CLI** でアップロード(`line richmenu image --file menu.png`。バイナリは MCP で扱いにくいため意図的に CLI 専用)、最後に `line_richmenu_set_default` / `line_richmenu_link` して実機で確認。 From 8fa48b2160ab29184b0e534925e6918d8ff8e6a5 Mon Sep 17 00:00:00 2001 From: Hirotada Kobayashi Date: Thu, 3 Sep 2026 18:50:04 +0900 Subject: [PATCH 09/12] docs(tools): rebuild the AI message-assembly section around line_flex_preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the "Building messages with an AI agent" section to after the Flex preview section and rewrite it as a build → preview → adjust → send loop: schema builds a shape-valid message, line_flex_preview shows it like the LINE app for in-browser tweaks (read back via line_flex_get_content), and dryRun is the final pre-send check. EN and JA. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/README.md | 21 +++++++++++---------- tools/README_ja.md | 21 +++++++++++---------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/tools/README.md b/tools/README.md index 66976a9..34b122f 100644 --- a/tools/README.md +++ b/tools/README.md @@ -370,16 +370,6 @@ state and is excluded under `--read-only`. Each tool accepts an optional `profile` argument; credentials are resolved from the profile. -### Building messages with an AI agent (flex / template) - -A primary MCP use case is a **build → validate → send-and-see** loop: have the agent assemble a rich message, type-check it, then push it to your own device to check the appearance, and iterate. Two aids make this reliable: - -- **`line_message_schema(type)`** returns the JSON Schema for LINE message objects so the agent can build a *shape-valid* `messagesJson`. `type` is one of `all` / `flex` / `template` / `imagemap` / `quickReply` / `action` (default `flex`). It is a read-only tool (available under `--read-only`) and returns no secrets. The schema is extracted from the same OpenAPI spec Kiota generates from, so it never drifts from the models; references are kept (`$ref` + `$defs`) rather than inlined because `FlexBox` is self-recursive. - - Simple messages (text / image / video / audio / location / sticker) are trivial and shown inline in the send-tool descriptions — you usually only need the schema for **flex** or **template**. -- **`dryRun: true`** on the send tools (`line_message_push` / `multicast` / `broadcast` / `reply`) parses and shape-checks the messages and returns their parsed types **without sending** (no API call, no credentials required). Use it as a safety check before an actual send. - -Typical flow: `line_message_schema type=flex` → build the Flex JSON → `line_message_push ... dryRun=true` (validate) → `line_message_push ...` (send to your own userId) → check on your device. - ### Flex Message preview (`line_flex_*`) Preview a LINE Flex Message in a live, LINE-faithful browser view while you build it. The AI renders @@ -399,6 +389,17 @@ The same browser renderer is also available as a Copilot App canvas extension (w zero-dependency Node MCP server as an alternative for Claude Desktop/Code) — see `extensions/line-flex-viewer/`. +### Building messages with an AI agent (flex / template) + +A primary MCP use case is a **build → preview → adjust → send** loop: the agent assembles a rich message, you see it rendered exactly like the LINE app, tweak it, and send once it looks right. Three tools make this reliable: + +- **`line_message_schema(type)`** returns the JSON Schema for LINE message objects so the agent builds a *shape-valid* message. `type` is one of `all` / `flex` / `template` / `imagemap` / `quickReply` / `action` (default `flex`); it is read-only and returns no secrets. The schema comes from the same OpenAPI spec Kiota generates from, so it never drifts from the models; references are kept (`$ref` + `$defs`) rather than inlined because `FlexBox` is self-recursive. + - Simple messages (text / image / video / audio / location / sticker) are trivial and shown inline in the send-tool descriptions — you usually only need the schema for **flex** or **template**. +- **`line_flex_preview`** renders the Flex JSON in a live, LINE-faithful browser view (see the section above). Rather than guessing from raw JSON, you *see* the bubble/carousel, adjust colors and spacing right in the browser, and the agent reads your changes back with **`line_flex_get_content`**. No credentials involved. +- **`dryRun: true`** on the send tools (`line_message_push` / `multicast` / `broadcast` / `reply`) parses and shape-checks the messages and returns their parsed types **without sending** (no API call, no credentials required). A final safety check before the real send. + +Typical flow: `line_message_schema type=flex` → build the Flex JSON → `line_flex_preview` (see it, tweak in the browser) → `line_flex_get_content` (pick up your edits) → `line_message_push ... dryRun=true` (validate) → `line_message_push ...` (send to your own userId). + ### Security design (MCP) MCP tool results are assumed to enter the model's context (sent to the LLM provider, conversation history, logs), so the following protections are built in: diff --git a/tools/README_ja.md b/tools/README_ja.md index 6019676..f3bea71 100644 --- a/tools/README_ja.md +++ b/tools/README_ja.md @@ -370,16 +370,6 @@ CLI コマンドを `line__` の名前で公開します(`webhook 各ツールは任意で `profile` 引数を受け取り、資格情報はプロファイルから解決します。 -### AI エージェントによるメッセージ組立(flex / template) - -MCP の主要ユースケースの一つが「**組み立てる → 検証する → 送って実機で確認**」のループです。エージェントにリッチメッセージを組ませ、型検証し、自分の端末に push して見た目を確認し、直す——これを確実にする 2 つの補助があります。 - -- **`line_message_schema(type)`** は LINE メッセージオブジェクトの JSON Schema を返し、エージェントが**形として妥当な** `messagesJson` を組めるようにします。`type` は `all` / `flex` / `template` / `imagemap` / `quickReply` / `action` のいずれか(既定 `flex`)。読み取り系ツール(`--read-only` でも有効)でシークレットは返しません。スキーマは Kiota が生成に使う OpenAPI 仕様と同一物から抽出するためモデルとドリフトせず、`FlexBox` が自己再帰のため参照はインライン展開せず `$ref` + `$defs` で保持します。 - - 単純メッセージ(text / image / video / audio / location / sticker)は軽量で、送信ツールの説明文に例が載っています。スキーマが必要なのは主に **flex** / **template** です。 -- **送信ツールの `dryRun: true`**(`line_message_push` / `multicast` / `broadcast` / `reply`)は、メッセージをパース・形状チェックして種別を返すだけで**送信しません**(API 呼び出しなし・資格情報不要)。実送信前の安全チェックに使います。 - -典型的な流れ: `line_message_schema type=flex` → Flex JSON を組む → `line_message_push ... dryRun=true`(検証)→ `line_message_push ...`(自分の userId へ送信)→ 実機で確認。 - ### Flex Message プレビュー(`line_flex_*`) LINE Flex Message を、LINE アプリに近い見た目でブラウザにライブプレビューしながら構築できます。 @@ -398,6 +388,17 @@ AI が `line_flex_preview` で JSON をレンダリングすると、ループ 場合の代替として、依存パッケージのない Node MCP サーバも同梱)。詳細は `extensions/line-flex-viewer/` を参照してください。 +### AI エージェントによるメッセージ組立(flex / template) + +MCP の主要な使い方の一つが「**組み立てる → プレビュー → 調整 → 送信**」のループです。エージェントがリッチメッセージを組み立て、LINE アプリと同じ見た目で確認しながら手直しし、納得できたら送る——この流れを支える 3 つのツールがあります。 + +- **`line_message_schema(type)`** は LINE メッセージオブジェクトの JSON Schema を返し、エージェントが**形として妥当な**メッセージを組めるようにします。`type` は `all` / `flex` / `template` / `imagemap` / `quickReply` / `action` のいずれか(既定 `flex`)。読み取り系でシークレットは返しません。スキーマは Kiota が生成に使う OpenAPI 仕様と同じものから抽出するのでモデルとずれず、`FlexBox` が自己再帰のため参照は展開せず `$ref` + `$defs` のまま保持します。 + - 単純なメッセージ(text / image / video / audio / location / sticker)は軽量で、送信ツールの説明文に例が載っています。スキーマが要るのは主に **flex** / **template** です。 +- **`line_flex_preview`** は Flex JSON を LINE アプリに近い見た目でブラウザにライブ描画します(前節参照)。JSON から想像するのではなく、bubble / carousel を**実際に見ながら**色や余白をブラウザ上で調整でき、その結果は **`line_flex_get_content`** でエージェントが読み戻せます。資格情報は不要です。 +- **送信ツールの `dryRun: true`**(`line_message_push` / `multicast` / `broadcast` / `reply`)は、メッセージをパース・形状チェックして種別を返すだけで**送信しません**(API 呼び出しなし・資格情報不要)。実送信前の最終チェックに使います。 + +典型的な流れ: `line_message_schema type=flex` → Flex JSON を組む → `line_flex_preview`(見ながらブラウザで調整)→ `line_flex_get_content`(調整結果を取り込む)→ `line_message_push ... dryRun=true`(検証)→ `line_message_push ...`(自分の userId へ送信)。 + ### セキュリティ設計(MCP) MCP ツールの戻り値はモデルのコンテキスト(LLM プロバイダへの送信・会話履歴・ログ)に載る前提で、以下の保護を組み込んでいます。 From f94b7da56f07f4af1d93f57de0491bab3f05f0e6 Mon Sep 17 00:00:00 2001 From: Hirotada Kobayashi Date: Thu, 3 Sep 2026 18:55:38 +0900 Subject: [PATCH 10/12] docs(tools): explain $ref/$defs in the message-schema note The line_message_schema note referenced "$ref + $defs" with no explanation. Reword it to say the schema uses named definitions ($defs) referenced by pointer ($ref) instead of inlining, and why (a Flex box is self-recursive, so inlining would nest forever). EN and JA. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/README.md | 2 +- tools/README_ja.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/README.md b/tools/README.md index 34b122f..dad44e7 100644 --- a/tools/README.md +++ b/tools/README.md @@ -393,7 +393,7 @@ zero-dependency Node MCP server as an alternative for Claude Desktop/Code) — s A primary MCP use case is a **build → preview → adjust → send** loop: the agent assembles a rich message, you see it rendered exactly like the LINE app, tweak it, and send once it looks right. Three tools make this reliable: -- **`line_message_schema(type)`** returns the JSON Schema for LINE message objects so the agent builds a *shape-valid* message. `type` is one of `all` / `flex` / `template` / `imagemap` / `quickReply` / `action` (default `flex`); it is read-only and returns no secrets. The schema comes from the same OpenAPI spec Kiota generates from, so it never drifts from the models; references are kept (`$ref` + `$defs`) rather than inlined because `FlexBox` is self-recursive. +- **`line_message_schema(type)`** returns the JSON Schema for LINE message objects so the agent builds a *shape-valid* message. `type` is one of `all` / `flex` / `template` / `imagemap` / `quickReply` / `action` (default `flex`); it is read-only and returns no secrets. The schema is built from the same OpenAPI spec Kiota generates the client from, so it always matches the models. Types are emitted as named definitions (JSON Schema's `$defs`) that reference each other by pointer (`$ref`) instead of being expanded inline — necessary because a Flex `box` can contain other `box`es (it is self-recursive), so inlining every reference would nest forever. - Simple messages (text / image / video / audio / location / sticker) are trivial and shown inline in the send-tool descriptions — you usually only need the schema for **flex** or **template**. - **`line_flex_preview`** renders the Flex JSON in a live, LINE-faithful browser view (see the section above). Rather than guessing from raw JSON, you *see* the bubble/carousel, adjust colors and spacing right in the browser, and the agent reads your changes back with **`line_flex_get_content`**. No credentials involved. - **`dryRun: true`** on the send tools (`line_message_push` / `multicast` / `broadcast` / `reply`) parses and shape-checks the messages and returns their parsed types **without sending** (no API call, no credentials required). A final safety check before the real send. diff --git a/tools/README_ja.md b/tools/README_ja.md index f3bea71..bf8f1eb 100644 --- a/tools/README_ja.md +++ b/tools/README_ja.md @@ -392,7 +392,7 @@ AI が `line_flex_preview` で JSON をレンダリングすると、ループ MCP の主要な使い方の一つが「**組み立てる → プレビュー → 調整 → 送信**」のループです。エージェントがリッチメッセージを組み立て、LINE アプリと同じ見た目で確認しながら手直しし、納得できたら送る——この流れを支える 3 つのツールがあります。 -- **`line_message_schema(type)`** は LINE メッセージオブジェクトの JSON Schema を返し、エージェントが**形として妥当な**メッセージを組めるようにします。`type` は `all` / `flex` / `template` / `imagemap` / `quickReply` / `action` のいずれか(既定 `flex`)。読み取り系でシークレットは返しません。スキーマは Kiota が生成に使う OpenAPI 仕様と同じものから抽出するのでモデルとずれず、`FlexBox` が自己再帰のため参照は展開せず `$ref` + `$defs` のまま保持します。 +- **`line_message_schema(type)`** は LINE メッセージオブジェクトの JSON Schema を返し、エージェントが**形として妥当な**メッセージを組めるようにします。`type` は `all` / `flex` / `template` / `imagemap` / `quickReply` / `action` のいずれか(既定 `flex`)。読み取り系でシークレットは返しません。スキーマは Kiota がクライアント生成に使う OpenAPI 仕様と同じものから作るので、生成されるモデルと必ず一致します。各型は定義集(JSON Schema の `$defs`)にまとめ、型どうしは参照ポインタ(`$ref`)で指し合う形にしています(中身をその場に展開しません)。これは Flex の `box` が中に別の `box` を持てる=自己再帰する型のためで、参照をすべて展開すると入れ子が無限に広がってしまうのを避けるためです。 - 単純なメッセージ(text / image / video / audio / location / sticker)は軽量で、送信ツールの説明文に例が載っています。スキーマが要るのは主に **flex** / **template** です。 - **`line_flex_preview`** は Flex JSON を LINE アプリに近い見た目でブラウザにライブ描画します(前節参照)。JSON から想像するのではなく、bubble / carousel を**実際に見ながら**色や余白をブラウザ上で調整でき、その結果は **`line_flex_get_content`** でエージェントが読み戻せます。資格情報は不要です。 - **送信ツールの `dryRun: true`**(`line_message_push` / `multicast` / `broadcast` / `reply`)は、メッセージをパース・形状チェックして種別を返すだけで**送信しません**(API 呼び出しなし・資格情報不要)。実送信前の最終チェックに使います。 From 7e10cc8ece4d3085526826db3f216d12727a5328 Mon Sep 17 00:00:00 2001 From: Hirotada Kobayashi Date: Thu, 3 Sep 2026 19:00:33 +0900 Subject: [PATCH 11/12] docs: promote Flex viewer to a top-level section in the root README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Flex viewer was a nested subsection under "CLI / MCP tool". Promote it to its own top-level "LINE Flex Message viewer" section and describe the two ways to use it side by side — the line_flex_* MCP tools and the Copilot App canvas extension (with the bundled Node MCP server as an alternative). EN and JA. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 22 ++++++++++++---------- README_ja.md | 21 +++++++++++---------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 05c3d16..e559ca7 100644 --- a/README.md +++ b/README.md @@ -313,19 +313,21 @@ line mcp # start as an MCP server See [`tools/README.md`](https://github.com/pierre3/line-openapi-dotnet/blob/main/tools/README.md) ([日本語](https://github.com/pierre3/line-openapi-dotnet/blob/main/tools/README_ja.md)) for details. -### LINE Flex Message viewer (Copilot canvas extension) +## LINE Flex Message viewer -Preview a LINE Flex Message in a live, LINE-faithful browser view. The `line` MCP tool exposes this -as `line_flex_*` (read-only-safe, no LINE API / secrets). The **same** renderer also ships as a -Copilot CLI canvas extension — install it from this repo: +Preview a LINE Flex Message in a live, LINE-faithful browser view while you build it, adjusting +colors and spacing in the browser and reading the result back before you send. No LINE account, +API key, or network access is involved — the preview renders entirely on your machine. The same +renderer is available two ways: -``` -install_extension https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer -``` +- **From the `line` MCP tool** — the `line_flex_*` tools (`line_flex_preview` / `line_flex_get_content` / `line_flex_validate` / `line_flex_open`). They touch no LINE API and no secrets, so they work even under `--read-only`. This is the recommended path for Claude Desktop / Claude Code and other MCP clients. See [`tools/README.md`](https://github.com/pierre3/line-openapi-dotnet/blob/main/tools/README.md). +- **As a Copilot App canvas extension** — install it straight from this repo: + + ``` + install_extension https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer + ``` -A bundled zero-dependency `mcp/server.mjs` is also available as an alternative for Claude -Desktop/Code when you aren't using the `line` tool. See -[`extensions/line-flex-viewer/`](https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer). + A bundled zero-dependency Node MCP server is also included as an alternative for MCP clients when you aren't using the `line` tool. See [`extensions/line-flex-viewer/`](https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer). ## AI tools (`Line.OpenApi.Extensions.AI`) diff --git a/README_ja.md b/README_ja.md index 23890b0..6780298 100644 --- a/README_ja.md +++ b/README_ja.md @@ -313,19 +313,20 @@ line mcp # MCP サーバとして起動 詳細は [`tools/README.md`](https://github.com/pierre3/line-openapi-dotnet/blob/main/tools/README.md)([日本語](https://github.com/pierre3/line-openapi-dotnet/blob/main/tools/README_ja.md))を参照してください。 -### LINE Flex Message ビューア(Copilot canvas 拡張) +## LINE Flex Message ビューア -LINE Flex Message を、LINE アプリに近い見た目でブラウザにライブプレビューできます。`line` MCP ツールは -これを `line_flex_*`(read-only 安全・LINE API/シークレット非使用)として公開します。**同じ**レンダラは -Copilot CLI の canvas 拡張としても提供しており、このリポジトリのフォルダ URL から導入できます。 +LINE Flex Message を、LINE アプリに近い見た目でブラウザにライブプレビューしながら構築できます。色や +余白をブラウザ上で調整し、その結果を送信前に読み戻せます。LINE アカウントや API キー、ネットワーク +接続は不要で、プレビューはすべて手元の PC で動きます。同じレンダラを 2 通りの方法で使えます。 -``` -install_extension https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer -``` +- **`line` MCP ツールから** — `line_flex_*` ツール(`line_flex_preview` / `line_flex_get_content` / `line_flex_validate` / `line_flex_open`)。LINE API もシークレットも使わないため `--read-only` でも利用できます。Claude Desktop / Claude Code などの MCP クライアントではこれが基本の使い方です。詳細は [`tools/README.md`](https://github.com/pierre3/line-openapi-dotnet/blob/main/tools/README_ja.md) を参照してください。 +- **Copilot App の canvas 拡張として** — このリポジトリのフォルダ URL から直接導入できます。 + + ``` + install_extension https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer + ``` -`line` ツールを使わない場合の代替として、依存パッケージのない `mcp/server.mjs` を Claude -Desktop/Code から利用することもできます。詳細は -[`extensions/line-flex-viewer/`](https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer) を参照してください。 + `line` ツールを使わない場合の代替として、依存パッケージのない Node MCP サーバも同梱しています。詳細は [`extensions/line-flex-viewer/`](https://github.com/pierre3/line-openapi-dotnet/tree/main/extensions/line-flex-viewer) を参照してください。 ## AI ツール(`Line.OpenApi.Extensions.AI`) From 169fec0803d89103ce3bb7ad2dc3efb16e971046 Mon Sep 17 00:00:00 2001 From: Hirotada Kobayashi Date: Thu, 3 Sep 2026 19:34:52 +0900 Subject: [PATCH 12/12] release(tools): prepare Line.OpenApi.Tools 1.2.0 (Flex preview) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump the tool package version 1.1.0 → 1.2.0. - CHANGELOG (EN/JA): add the 1.2.0 Tools entry (line_flex_* live preview) and its compare link. - release.yml: fix the publish-tool version desync — build/test the test project (which references the tool) with -p:Version, test --no-build, then pack --no-build, so the packaged assembly's AssemblyVersion matches the .nupkg version (mirrors the AI job). Previously the Test step rebuilt the tool without -p:Version and pack --no-build shipped a mis-stamped assembly. Verified locally: pack produces Line.OpenApi.Tools.1.2.0.nupkg with a 1.2.0.0 assembly and all five web assets embedded. Tag push (tools-v1.2.0) is deferred. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 11 ++++++++--- CHANGELOG.md | 9 +++++++++ CHANGELOG_ja.md | 9 +++++++++ tools/Line.OpenApi.Tools/Line.OpenApi.Tools.csproj | 2 +- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4712533..dc84338 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -183,16 +183,21 @@ jobs: id: ver run: echo "value=${GITHUB_REF_NAME#tools-v}" >> "$GITHUB_OUTPUT" + # Build the test project (which references the tool project) with the resolved version, so the + # tool assembly's AssemblyVersion/FileVersion match the package version. Then test and pack + # --no-build to package exactly that stamped assembly (mirrors the AI job). Building/testing + # the tool project directly would rebuild it without -p:Version during test and desync the + # stamp from the .nupkg version. - name: Restore - run: dotnet restore "$TOOL_PROJECT" + run: dotnet restore tests/Line.OpenApi.Tools.Tests/Line.OpenApi.Tools.Tests.csproj - name: Build env: VERSION: ${{ steps.ver.outputs.value }} - run: dotnet build "$TOOL_PROJECT" --configuration Release --no-restore -p:Version="${VERSION}" + run: dotnet build tests/Line.OpenApi.Tools.Tests/Line.OpenApi.Tools.Tests.csproj --configuration Release --no-restore -p:Version="${VERSION}" - name: Test - run: dotnet test tests/Line.OpenApi.Tools.Tests/Line.OpenApi.Tools.Tests.csproj --configuration Release --verbosity normal + run: dotnet test tests/Line.OpenApi.Tools.Tests/Line.OpenApi.Tools.Tests.csproj --configuration Release --no-build --verbosity normal - name: Pack env: diff --git a/CHANGELOG.md b/CHANGELOG.md index aa47928..7e1445c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,14 @@ They evolve on separate cadences, so each has its own version history below. ## Tools — `Line.OpenApi.Tools` +### [1.2.0] - 2026-09-03 + +Adds a live, LINE-faithful Flex Message preview to the tool. + +#### Added + +- **Flex Message live preview (`line_flex_*`).** New read-only MCP tools `line_flex_preview` / `line_flex_get_content` / `line_flex_validate` / `line_flex_open` render Flex JSON in a loopback browser view that hot-updates as you iterate, and read back edits made in the browser. No LINE API calls or secrets are involved, so the tools are available under `--read-only`. The same renderer also ships as a Copilot App canvas extension under `extensions/line-flex-viewer/` (with a bundled zero-dependency Node MCP server as an alternative). + ### [1.1.0] - 2026-08-13 Automates repointing a dev tunnel without visiting the LINE Developers console. @@ -88,6 +96,7 @@ First stable release of the in-process AI tools package: the LINE Messaging use --- +[1.2.0]: https://github.com/pierre3/line-openapi-dotnet/compare/tools-v1.1.0...tools-v1.2.0 [1.1.0]: https://github.com/pierre3/line-openapi-dotnet/compare/tools-v1.0.0...tools-v1.1.0 [1.0.0]: https://github.com/pierre3/line-openapi-dotnet/compare/tools-v0.2.0-preview...tools-v1.0.0 [0.2.0-preview]: https://github.com/pierre3/line-openapi-dotnet/compare/tools-v0.1.0-preview...tools-v0.2.0-preview diff --git a/CHANGELOG_ja.md b/CHANGELOG_ja.md index 45ded08..7361ab9 100644 --- a/CHANGELOG_ja.md +++ b/CHANGELOG_ja.md @@ -19,6 +19,14 @@ English version: [`CHANGELOG.md`](CHANGELOG.md) ## ツール — `Line.OpenApi.Tools` +### [1.2.0] - 2026-09-03 + +LINE アプリに近い見た目の Flex Message ライブプレビューを追加します。 + +#### 追加 + +- **Flex Message ライブプレビュー(`line_flex_*`)。** 読み取り系の MCP ツール `line_flex_preview`・`line_flex_get_content`・`line_flex_validate`・`line_flex_open` を追加。Flex JSON をループバックのブラウザビューに描画し、反復のたびにその場で更新します。ブラウザ上で加えた編集は読み戻せます。LINE API 呼び出しやシークレットは使わないため `--read-only` でも利用できます。同じレンダラは `extensions/line-flex-viewer/` の Copilot App canvas 拡張としても提供します(依存パッケージのない Node MCP サーバも代替として同梱)。 + ### [1.1.0] - 2026-08-13 dev トンネルの再起動時に LINE Developers コンソールへ URL を貼り替える手間を自動化します。 @@ -88,6 +96,7 @@ dev トンネルの再起動時に LINE Developers コンソールへ URL を貼 --- +[1.2.0]: https://github.com/pierre3/line-openapi-dotnet/compare/tools-v1.1.0...tools-v1.2.0 [1.1.0]: https://github.com/pierre3/line-openapi-dotnet/compare/tools-v1.0.0...tools-v1.1.0 [1.0.0]: https://github.com/pierre3/line-openapi-dotnet/compare/tools-v0.2.0-preview...tools-v1.0.0 [0.2.0-preview]: https://github.com/pierre3/line-openapi-dotnet/compare/tools-v0.1.0-preview...tools-v0.2.0-preview diff --git a/tools/Line.OpenApi.Tools/Line.OpenApi.Tools.csproj b/tools/Line.OpenApi.Tools/Line.OpenApi.Tools.csproj index 729d524..586dab2 100644 --- a/tools/Line.OpenApi.Tools/Line.OpenApi.Tools.csproj +++ b/tools/Line.OpenApi.Tools/Line.OpenApi.Tools.csproj @@ -14,7 +14,7 @@ - 1.1.0 + 1.2.0 false