@mawaru/sdk 0.39.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/_schemas/graph.test.ts +15 -9
  2. package/_schemas/graph.ts +15 -6
  3. package/_schemas/human-manifest.test.ts +67 -0
  4. package/_schemas/human-manifest.ts +31 -0
  5. package/_schemas/port-spec.test.ts +33 -92
  6. package/_schemas/port-spec.ts +36 -59
  7. package/dist/_cli/previewCommand.js +3 -3
  8. package/dist/_cli/previewFiles.js +2 -3
  9. package/dist/_cli/previewSlack.d.ts +1 -1
  10. package/dist/_cli/previewSlack.js +1 -1
  11. package/dist/_schemas/graph.d.ts +6 -3
  12. package/dist/_schemas/graph.js +12 -6
  13. package/dist/_schemas/human-manifest.d.ts +9 -0
  14. package/dist/_schemas/human-manifest.js +29 -0
  15. package/dist/_schemas/port-spec.d.ts +5 -11
  16. package/dist/_schemas/port-spec.js +28 -37
  17. package/dist/cli.js +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +1 -0
  20. package/dist/init.js +22 -3
  21. package/dist/typegen.d.ts +3 -1
  22. package/dist/typegen.js +96 -14
  23. package/dist/validate.js +57 -0
  24. package/dist/views/_bridge.d.ts +12 -0
  25. package/dist/views/_bridge.js +10 -0
  26. package/dist/views/_humanProps.d.ts +10 -0
  27. package/dist/views/_humanProps.js +11 -0
  28. package/dist/views/_parts/DocumentCard.d.ts +2 -0
  29. package/dist/views/_parts/DocumentCard.js +16 -0
  30. package/dist/views/_parts/FilePreview.d.ts +5 -0
  31. package/dist/views/_parts/FilePreview.js +101 -0
  32. package/dist/views/_parts/Linkify.d.ts +7 -0
  33. package/dist/views/_parts/Linkify.js +28 -0
  34. package/dist/views/_parts/Markdown.d.ts +44 -0
  35. package/dist/views/_parts/Markdown.js +313 -0
  36. package/dist/views/_parts/SchemaForm.d.ts +19 -0
  37. package/dist/views/_parts/SchemaForm.js +310 -0
  38. package/dist/views/_parts/fileRef.d.ts +7 -0
  39. package/dist/views/_parts/fileRef.js +11 -0
  40. package/dist/views/_parts/useNarrow.d.ts +5 -0
  41. package/dist/views/_parts/useNarrow.js +23 -0
  42. package/dist/views/components.d.ts +7 -0
  43. package/dist/views/components.js +12 -0
  44. package/dist/views/host.d.ts +23 -0
  45. package/dist/views/host.js +20 -0
  46. package/dist/views/human.d.ts +54 -0
  47. package/dist/views/human.js +5 -0
  48. package/dist/views/protocol.d.ts +44 -0
  49. package/dist/views/protocol.js +5 -0
  50. package/dist/views/runtime.d.ts +2 -0
  51. package/dist/views/runtime.js +86 -0
  52. package/dist/views/sample.d.ts +22 -0
  53. package/dist/views/sample.js +38 -0
  54. package/dist/{slack-view.d.ts → views/slack.d.ts} +1 -1
  55. package/docs/development.md +39 -2
  56. package/index.ts +1 -0
  57. package/package.json +40 -4
  58. package/skills/create-loop/SKILL.md +7 -3
  59. package/templates/CLAUDE.md +2 -0
  60. package/views/_bridge.tsx +36 -0
  61. package/views/_humanProps.ts +27 -0
  62. package/views/_parts/DocumentCard.ts +18 -0
  63. package/views/_parts/FilePreview.tsx +180 -0
  64. package/views/_parts/Linkify.test.ts +40 -0
  65. package/views/_parts/Linkify.tsx +40 -0
  66. package/views/_parts/Markdown.test.ts +131 -0
  67. package/views/_parts/Markdown.tsx +472 -0
  68. package/views/_parts/SchemaForm.tsx +742 -0
  69. package/views/_parts/fileRef.ts +21 -0
  70. package/views/_parts/useNarrow.ts +24 -0
  71. package/views/components.ts +16 -0
  72. package/views/host.ts +27 -0
  73. package/views/human.ts +83 -0
  74. package/views/protocol.ts +63 -0
  75. package/views/runtime.tsx +123 -0
  76. package/views/sample.tsx +94 -0
  77. package/views/slack.test.ts +40 -0
  78. package/{slack-view.ts → views/slack.ts} +3 -3
  79. /package/dist/{slack-view.js → views/slack.js} +0 -0
@@ -0,0 +1,22 @@
1
+ type Input = {
2
+ subject: string;
3
+ body: string;
4
+ };
5
+ type Outputs = {
6
+ approve: {
7
+ subject: string;
8
+ body: string;
9
+ };
10
+ reject: {
11
+ prompt: string;
12
+ };
13
+ };
14
+ type Output = {
15
+ approve: Outputs["approve"];
16
+ reject?: never;
17
+ } | {
18
+ reject: Outputs["reject"];
19
+ approve?: never;
20
+ };
21
+ declare const _default: import("./human.js").HumanView<Input, Output, Record<string, unknown>>;
22
+ export default _default;
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { defineHuman } from "./human.js";
4
+ export default defineHuman(({ input, out }) => {
5
+ const [reply, setReply] = useState(input);
6
+ const [prompt, setPrompt] = useState("");
7
+ return (_jsxs("div", { style: {
8
+ maxWidth: "720px",
9
+ margin: "0 auto",
10
+ padding: "var(--space-8) var(--space-6)",
11
+ display: "flex",
12
+ flexDirection: "column",
13
+ gap: "var(--space-3)",
14
+ }, children: [_jsx("input", { value: reply.subject, onChange: (e) => setReply({ ...reply, subject: e.target.value }), style: {
15
+ padding: "var(--space-2) var(--space-3)",
16
+ border: "var(--border-w) solid var(--border)",
17
+ borderRadius: "var(--radius-s)",
18
+ background: "var(--surface)",
19
+ color: "var(--ink)",
20
+ font: "inherit",
21
+ } }), _jsx("textarea", { value: reply.body, onChange: (e) => setReply({ ...reply, body: e.target.value }), style: {
22
+ minHeight: "16em",
23
+ padding: "var(--space-2) var(--space-3)",
24
+ border: "var(--border-w) solid var(--border)",
25
+ borderRadius: "var(--radius-s)",
26
+ background: "var(--surface)",
27
+ color: "var(--ink)",
28
+ font: "inherit",
29
+ } }), _jsx("div", { style: { display: "flex", justifyContent: "flex-end" }, children: _jsx("button", { type: "button", className: "primary", disabled: reply.body.trim() === "", onClick: () => out({ approve: reply }), children: "\u3053\u306E\u5185\u5BB9\u3067\u9001\u4FE1" }) }), _jsxs("div", { style: { display: "flex", gap: "var(--space-2)" }, children: [_jsx("input", { value: prompt, placeholder: "\u5DEE\u3057\u623B\u3057\u306E\u7406\u7531", onChange: (e) => setPrompt(e.target.value), style: {
30
+ flex: 1,
31
+ padding: "var(--space-2) var(--space-3)",
32
+ border: "var(--border-w) solid var(--border)",
33
+ borderRadius: "var(--radius-s)",
34
+ background: "var(--surface)",
35
+ color: "var(--ink)",
36
+ font: "inherit",
37
+ } }), _jsx("button", { type: "button", className: "ghost", disabled: prompt.trim() === "", onClick: () => out({ reject: { prompt } }), children: "\u5DEE\u3057\u623B\u3059" })] })] }));
38
+ });
@@ -1,4 +1,4 @@
1
- import type { JsonSchema, NodeKind } from "./_schemas/node.js";
1
+ import type { JsonSchema, NodeKind } from "../_schemas/node.js";
2
2
  export type SlackBlocks = unknown[];
3
3
  export type FileRef = {
4
4
  path: string;
@@ -19,6 +19,7 @@ nodes/
19
19
  program/<dir>/ # Program ノード:config.json + main.ts(+任意で package.json)
20
20
  ai/<dir>/ # AI ノード定義:config.json
21
21
  hook/<dir>/ # NodeIOHook:config.json + main.ts(ノードに付く観測フック)
22
+ human/<dir>/ # Human ノードの実装(承認画面):config.json + index.tsx
22
23
  skills/<dir>/ # AI ノードが参照する skill(SKILL.md)
23
24
  views/
24
25
  slack/<name>/ # Slack ビュー:index.ts(+手元プレビュー用の cases.ts)
@@ -81,10 +82,10 @@ views/
81
82
  ディレクトリ名で出る)。タイトル行・追加出口のボタン・投稿は mawaru が描く
82
83
  - `index.ts` — `export default { files?, renderRunning?, renderCompleted? } satisfies SlackView<入力の型>`。
83
84
  契約(型・ctx の中身・決定ボタンの `decisionActionId` / `decisionFormActionId`)の正は
84
- `@mawaru/sdk/slack-view`。実装した関数の面だけが変わる
85
+ `@mawaru/sdk/views/slack`。実装した関数の面だけが変わる
85
86
  - **純関数**にする:mawaru が隔離環境(QuickJS)で動かすので、`fetch` / `process` / `require` / タイマー /
86
87
  `console` は無い。1回の呼び出しは 200ms・32MB まで。ctx も戻り値も JSON で受け渡す
87
- - import できるのは同じディレクトリ内の相対パスと `@mawaru/sdk/slack-view` だけ(npm パッケージは不可)。
88
+ - import できるのは同じディレクトリ内の相対パスと `@mawaru/sdk/views/slack` だけ(npm パッケージは不可)。
88
89
  ビルドしない(mawaru が TypeScript の型を外して読む。`enum` など型を外すだけで JS にならない書き方は不可)
89
90
  - 入力は崩れて届くことがある(`input` が `null` 等)。例外を投げない書き方にする。失敗すると、承認待ちは
90
91
  承認ボタンだけ・完了は1行のフォールバックになる
@@ -99,6 +100,42 @@ views/
99
100
  - 端末に1通ごとの結果が出る(`✔` 成功・`⚠` 描画に失敗して理由つき・`–` 未実装の面)。失敗が1つでもあれば exit 1。
100
101
  ビューを直したら同じコマンドで送り直して確かめる
101
102
 
103
+ ## Human ノード(`nodes/human/<dir>/`)
104
+
105
+ - human ノードの実装=「input を受け取り、人の操作で出口(approve・reject・追加の出口)に値を出す」画面。
106
+ program の `run(input, ctx)` が `{ <出口ポートのkey>: <データ> }` を返すのと同じ形で、human はそれを人が画面で選ぶ。
107
+ ノードの「承認画面」にディレクトリ名を選ぶと使われる(program / AI と同じく、ブランチ(ref)も指せる)。
108
+ 選ばなければ mawaru の標準の承認画面
109
+ - `config.json` — 入出力スキーマの宣言。正は `_schemas/human-manifest.ts`。形は AI の config.json に揃える:
110
+ `{ "inputs": { "in": { "schema": … } }, "outputs": { "approve": …, "reject": …, <追加の出口>: … } }`。
111
+ in・approve・reject は全部書く(program と同じく必須)。program / AI と同じく、エディタでディレクトリを選ぶとこの宣言から
112
+ ポートができ、エディタでポートの型を直すと保存時に config.json へ書き戻される(`refs` もエディタが転写する)
113
+ - `index.tsx` — `export default defineHuman<Input, Output, Refs>(({ input, out, ctx }) => …)`(`@mawaru/sdk/human`)。
114
+ React のコンポーネント1つで、状態(直した値・選択など)は `useState` で持つ
115
+ - `input`:届いた値(in ポートの値)
116
+ - `out({ <出口key>: 値 })`:人の操作を mawaru に渡す(出口は1つだけ)。人がボタンを押した直後の呼び出しだけを受け付ける
117
+ (開いただけで決定する画面は書けない)。送れたら承認画面は run の画面へ戻る。断られたら(担当でない・もう決まっている など)
118
+ Promise が理由つきで失敗する
119
+ - `ctx.input.schema`(in の型)・`ctx.input.fetchFileUrl(file)`(input のファイルを表示する URL)・
120
+ `ctx.output.schema`(出口ごとの型)・`ctx.output.uploadFile(file)`(出すファイルを上げて参照を受け取る。差し戻しの参考ファイルなど)・
121
+ `ctx.clipboard.writeText(text)`(コピー)・`ctx.refs`(ループ変数。`ctx.refs.input` が run の起動入力)・
122
+ `ctx.step.isAssignee`(見ている人が担当か)・`ctx.step.editable`(値を直してよいか。担当で、全員承認のノードでないとき true)
123
+ - 承認・差し戻し・追加の出口のボタンも実装が置く。差し戻しの理由や参考ファイルを入れる欄も実装が作る
124
+ - 型は `npx mawaru typegen` が config.json から `types.d.ts`(program と同じ `Input` / `Outputs` / `Output` / `Refs`。file は `FileRef`)を作る
125
+ - 共通の部品(`SchemaForm`・`Markdown`・`Linkify`・`FilePreview`・`useNarrow` など)は `@mawaru/sdk/components` から import する
126
+ (`FilePreview` と `SchemaForm` のファイル欄は、承認画面の中では自動的に mawaru に頼む口につながる)
127
+ - エディタの型検査には repo ルートの `tsconfig.json`(`jsx: react-jsx`・`moduleResolution: Bundler`・`include: ["nodes/human", "views"]`)と
128
+ devDependencies の `@types/react` が要る。`npx mawaru init` が作る(init より前からある repo は自分で足す)
129
+ - 承認画面の上の帯より下を余白なしで全部使える。余白・最大幅・スクロールは実装が決める
130
+ (縦いっぱいに使うなら `100vh`)。右上の角には mawaru の閉じるボタンが重なるので、大事なものを置かない。
131
+ 局面・担当・全員承認の進み具合は mawaru の帯が出し、実装が描かれるのは承認待ちのときだけ
132
+ - mawaru がまとめて(ビルドして)**sandbox iframe の中で動かす**:mawaru のログインや API には届かない。`fetch` も CSP で止まる。
133
+ 要るものは `ctx` の口に頼む
134
+ - import できるのは同じディレクトリの中の相対パス・`react`・`@mawaru/sdk/human`・`@mawaru/sdk/components` だけ(npm パッケージは不可)
135
+ - 例外を投げる・まとめられないと、承認画面にエラーと理由が出る
136
+ - 版:program / AI と同じ。公開版は publish 時の commit に固定され、テスト実行は push だけで変わる
137
+ - 試し方:ブランチに push し、ノードの ref にそのブランチを入れてテスト実行する
138
+
102
139
  ## loop(回路)の作成・更新
103
140
 
104
141
  - `.claude/skills/create-loop` スキルに従う(graph API の契約は `_schemas/graph.ts` が正)
package/index.ts CHANGED
@@ -6,6 +6,7 @@ export * from "./_schemas/duplicate-selection.js"
6
6
  export * from "./_schemas/extract-component.js"
7
7
  export * from "./_schemas/graph.js"
8
8
  export * from "./_schemas/hook-manifest.js"
9
+ export * from "./_schemas/human-manifest.js"
9
10
  export * from "./_schemas/node.js"
10
11
  export * from "./_schemas/port-spec.js"
11
12
  export * from "./_schemas/program-manifest.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mawaru/sdk",
3
- "version": "0.39.0",
3
+ "version": "0.41.0",
4
4
  "description": "mawaru 実行 repo の開発 SDK。契約スキーマ(config.json 規約・ループ graph API)の正 + typegen / validate CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,9 +11,29 @@
11
11
  "types": "./dist/index.d.ts",
12
12
  "default": "./dist/index.js"
13
13
  },
14
+ "./human": {
15
+ "types": "./dist/views/human.d.ts",
16
+ "default": "./dist/views/human.js"
17
+ },
18
+ "./components": {
19
+ "types": "./dist/views/components.d.ts",
20
+ "default": "./dist/views/components.js"
21
+ },
22
+ "./views/slack": {
23
+ "types": "./dist/views/slack.d.ts",
24
+ "default": "./dist/views/slack.js"
25
+ },
14
26
  "./slack-view": {
15
- "types": "./dist/slack-view.d.ts",
16
- "default": "./dist/slack-view.js"
27
+ "types": "./dist/views/slack.d.ts",
28
+ "default": "./dist/views/slack.js"
29
+ },
30
+ "./views/runtime": {
31
+ "types": "./dist/views/runtime.d.ts",
32
+ "default": "./dist/views/runtime.js"
33
+ },
34
+ "./views/host": {
35
+ "types": "./dist/views/host.d.ts",
36
+ "default": "./dist/views/host.js"
17
37
  }
18
38
  },
19
39
  "files": [
@@ -23,7 +43,7 @@
23
43
  "docs",
24
44
  "_schemas",
25
45
  "index.ts",
26
- "slack-view.ts"
46
+ "views"
27
47
  ],
28
48
  "engines": {
29
49
  "node": ">=20"
@@ -32,8 +52,24 @@
32
52
  "zod": "^4.4.3",
33
53
  "zod-from-json-schema": "^0.5.3"
34
54
  },
55
+ "peerDependencies": {
56
+ "react": "^19.2.7",
57
+ "react-dom": "^19.2.7"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "react": {
61
+ "optional": true
62
+ },
63
+ "react-dom": {
64
+ "optional": true
65
+ }
66
+ },
35
67
  "devDependencies": {
36
68
  "@types/node": "^26.1.0",
69
+ "@types/react": "^19.2.17",
70
+ "@types/react-dom": "^19.2.3",
71
+ "react": "^19.2.7",
72
+ "react-dom": "^19.2.7",
37
73
  "typescript": "^6.0.3",
38
74
  "vitest": "^4.1.9"
39
75
  },
@@ -49,8 +49,8 @@ secret)でも、**足りない値を推測・生成・空文字で代用しな
49
49
  ノード・接続・refs(ループ変数)・hooks の形と、superRefine 内の全整合性ルール(日本語コメント付き)
50
50
  - `node_modules/@mawaru/sdk/_schemas/node.ts` — ポートの形(`graphPortSchema`)と key の規則
51
51
  - `node_modules/@mawaru/sdk/_schemas/port-spec.ts` — **kind 別の必須ポート構成**(`kindPortsViolation`)と
52
- 接続の型規則(human の in は承認ビューの宣言型、program の in はスキーマ包含 等)
53
- - `node_modules/@mawaru/sdk/_schemas/program-manifest.ts` / `ai-manifest.ts` — repo 側 config.json の規約
52
+ 接続の型規則(実装(nodes/human/)を指す human は program と同じ宣言型、program の in はスキーマ包含 等)
53
+ - `node_modules/@mawaru/sdk/_schemas/program-manifest.ts` / `ai-manifest.ts` / `human-manifest.ts` — repo 側 config.json の規約
54
54
  - 同ディレクトリの `*.test.ts` — 通る graph / 弾かれる graph の実例集として読める
55
55
 
56
56
  ## API(パスとボディは openapi.json が正)
@@ -122,7 +122,11 @@ graph の保存(`PUT loops/{loopId}/graph`)は常に下書き(draft)に
122
122
  graph 保存の書き戻しと食い違って 409 になり、テスト実行も古い中身で走る)。
123
123
  2. **ノードに結びつける**:graph の該当ノードに `program.program_dir` / `ai.ai_dir` を設定し、
124
124
  ports を config.json の inputs / outputs の写しにして保存する(in ポート key は `in`、
125
- out ポートは outputs の各 key)。human は承認ビュー、wait は待ち方もここで設定する。
125
+ out ポートは outputs の各 key)。human は承認画面、wait は待ち方もここで設定する。
126
+ human は標準の承認画面なら `human.human_dir` を省略する。この repo に専用の承認画面が要るなら
127
+ `nodes/human/<dir>/` に作り(`docs/development.md` の「Human ノード」)、push してから `human.human_dir`(+任意で `ref`)に
128
+ ディレクトリ名を入れる(`GET github/humans` が候補を返す)。ポートは program と同じく `GET github/humans/:dir/config` の
129
+ manifest(inputs / outputs)から起こす。
126
130
  3. **単体で確かめる**:そのノードだけをテスト実行する。`POST runs/start` に `kind: "test"`・
127
131
  `test_scope: "only"`・`start_node_id`(そのノード)・`payload`(サンプル入力。上流を確かめ済みなら
128
132
  その output を使う)・`start_by`(ユーザーの user_id。`GET members` で引く。API キー起動では必須)を
@@ -9,6 +9,8 @@
9
9
  - `nodes/program/<dir>/` — Program ノード(`config.json` + `main.ts`)。`main.ts` は
10
10
  `run(input, ctx)` を export し、戻り値は `{ <出口ポートのkey>: <データ> }` の単一キーのオブジェクト
11
11
  - `nodes/ai/<dir>/` — AI ノード定義(`config.json`。prompt / model / skills / 入出力スキーマ / env)
12
+ - `nodes/human/<dir>/` — Human ノードの実装(承認画面。`config.json` + `index.tsx`)。`index.tsx` は
13
+ `export default defineHuman(({ input, out, ctx }) => …)` で、人の操作を `out({ <出口ポートのkey>: <データ> })` で渡す
12
14
  - `views/slack/<name>/` — Slack ビュー(実行中・承認待ち・完了メッセージの本文)。`index.ts` + 手元プレビュー用の `cases.ts`
13
15
  - `.github/workflows/mawaru-runner.yml` — mawaru 所有の実行シム。**編集しない**
14
16
  (`npx @mawaru/sdk init` が再生成する)
@@ -0,0 +1,36 @@
1
+ import { createContext, type ReactNode, useContext } from "react"
2
+ import type { FileRef } from "./_parts/fileRef.js"
3
+
4
+ // ビューがホスト(mawaru の画面)に頼めること。顧客 repo のビューは sandbox iframe の中で動き、
5
+ // API を叩けない(認証が付かない)ので、要るものはここ経由でホストに頼む
6
+ // (docs/tasks/wip/承認ビューの顧客repo配置.md の「ビューがホストに頼めること」)。
7
+ // ホストの画面の中では API を直接呼ぶ実装を、iframe の中では postMessage の実装を差す。
8
+ // 部品(FilePreview・SchemaForm のファイル欄)もこれを使うので、どちらで描いても同じに動く
9
+ export type ViewBridge = {
10
+ // Storage の署名付き URL(private バケットなので描画のたびに取る)
11
+ fileUrl: (file: FileRef) => Promise<string>
12
+ // ファイルを上げて参照を返す(SchemaForm のファイル欄と同じアップロード先)
13
+ uploadFile: (file: File) => Promise<FileRef>
14
+ // クリップボードに書く(sandbox の中からは書けない)
15
+ copyText: (text: string) => Promise<void>
16
+ }
17
+
18
+ const ViewBridgeContext = createContext<ViewBridge | null>(null)
19
+
20
+ export const ViewBridgeProvider = ({
21
+ bridge,
22
+ children,
23
+ }: {
24
+ bridge: ViewBridge
25
+ children: ReactNode
26
+ }) => (
27
+ <ViewBridgeContext.Provider value={bridge}>
28
+ {children}
29
+ </ViewBridgeContext.Provider>
30
+ )
31
+
32
+ export const useViewBridge = (): ViewBridge => {
33
+ const bridge = useContext(ViewBridgeContext)
34
+ if (!bridge) throw new Error("ViewBridgeProvider の中で使ってください")
35
+ return bridge
36
+ }
@@ -0,0 +1,27 @@
1
+ import type { FileRef } from "./_parts/fileRef.js"
2
+ import type { HumanProps } from "./human.js"
3
+ import type { HumanViewData } from "./protocol.js"
4
+
5
+ // human ノードの実装に渡す props を組み立てる(iframe の中の実行部分と、mawaru の中の標準の承認画面で同じものを使う)。
6
+ // 関数の中身は呼ぶ側が差す:iframe の中はホストへの頼みごと、ホストの画面は API を直接呼ぶ
7
+ export type HumanOps = {
8
+ out: (output: Record<string, unknown>) => Promise<void>
9
+ fetchFileUrl: (file: FileRef) => Promise<string>
10
+ uploadFile: (file: File) => Promise<FileRef>
11
+ writeText: (text: string) => Promise<void>
12
+ }
13
+
14
+ export const humanPropsOf = (
15
+ data: HumanViewData,
16
+ ops: HumanOps,
17
+ ): HumanProps => ({
18
+ input: data.input,
19
+ out: ops.out,
20
+ ctx: {
21
+ input: { schema: data.inputSchema, fetchFileUrl: ops.fetchFileUrl },
22
+ output: { schema: data.outputSchema, uploadFile: ops.uploadFile },
23
+ clipboard: { writeText: ops.writeText },
24
+ refs: (data.refs ?? {}) as Record<string, unknown>,
25
+ step: { isAssignee: data.isAssignee, editable: data.editable },
26
+ },
27
+ })
@@ -0,0 +1,18 @@
1
+ import type { CSSProperties } from "react"
2
+
3
+ // 承認画面の「書類」カードの枠(標準ビュー・回答レビュー・お問い合わせ回答で同じ形)。
4
+ // 狭い幅(useNarrow)では左右の余白を詰めて本文の幅を確保する
5
+ export const documentCardStyle = (narrow: boolean): CSSProperties => ({
6
+ maxWidth: "720px",
7
+ margin: "0 auto",
8
+ display: "flex",
9
+ flexDirection: "column",
10
+ gap: "var(--space-4)",
11
+ background: "var(--surface)",
12
+ border: "var(--border-w) solid var(--border)",
13
+ borderRadius: "var(--radius-m)",
14
+ boxShadow: "var(--shadow)",
15
+ padding: narrow
16
+ ? "var(--space-5) var(--space-4) var(--space-6)"
17
+ : "var(--space-6) var(--space-10) var(--space-8)",
18
+ })
@@ -0,0 +1,180 @@
1
+ import { type CSSProperties, useEffect, useState } from "react"
2
+ import { useViewBridge } from "../_bridge.js"
3
+ import type { FileRef } from "./fileRef.js"
4
+
5
+ // file 参照の閲覧表示:private バケットなので描画のたびに閲覧用署名 URL を(ホストに頼んで)取り、
6
+ // 画像(GIF 含む)と PDF はそのままインラインで見せる。それ以外は名前チップのリンクにする。
7
+ // NodeIoCards の IoFileItem と同じ経路だが、こちらは「見て判断する」ための大きさで出す。
8
+ // PDF は請求書・契約書のように「現物を見て承認する」書類で必要になる(承認画面の中で
9
+ // 完結させたいので、別タブへ飛ばさない)
10
+
11
+ const chipStyle: CSSProperties = {
12
+ display: "inline-flex",
13
+ alignItems: "center",
14
+ gap: "4px",
15
+ maxWidth: "100%",
16
+ border: "var(--border-w) solid var(--border)",
17
+ borderRadius: "var(--radius-s)",
18
+ background: "var(--surface-2)",
19
+ padding: "2px 8px",
20
+ fontSize: "var(--text-xs)",
21
+ color: "var(--ink)",
22
+ textDecoration: "none",
23
+ }
24
+
25
+ // mime_type は上流が入れてくれないことがあるので拡張子でも判定する
26
+ const isImageFile = (file: FileRef): boolean =>
27
+ file.mime_type?.startsWith("image/") ??
28
+ /\.(gif|png|jpe?g|webp|avif|svg)$/i.test(file.name)
29
+
30
+ const isPdfFile = (file: FileRef): boolean =>
31
+ file.mime_type === "application/pdf" || /\.pdf$/i.test(file.name)
32
+
33
+ export const FilePreview = ({
34
+ file,
35
+ maxHeight = "420px",
36
+ }: {
37
+ file: FileRef
38
+ maxHeight?: string
39
+ }) => {
40
+ const bridge = useViewBridge()
41
+ const [url, setUrl] = useState<string | null>(null)
42
+ // 署名 URL を取れなかった(ファイルが消えた・パスが違う)ことを「読み込み中」と
43
+ // 区別する:失敗を黙って読み込み中のまま固めると、原因が画面から分からない
44
+ const [failed, setFailed] = useState(false)
45
+ useEffect(() => {
46
+ let alive = true
47
+ setUrl(null)
48
+ setFailed(false)
49
+ bridge
50
+ .fileUrl(file)
51
+ .then((signed) => {
52
+ if (alive) setUrl(signed)
53
+ })
54
+ .catch(() => {
55
+ if (alive) setFailed(true)
56
+ })
57
+ return () => {
58
+ alive = false
59
+ }
60
+ }, [bridge, file])
61
+
62
+ const caption = (
63
+ <span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>
64
+ {file.name}
65
+ </span>
66
+ )
67
+
68
+ // PDF はブラウザ内蔵ビューアに任せる(依存を増やさない)。maxHeight は画像では
69
+ // 上限だが、PDF では枠の高さそのものになる(内側でスクロールさせるため)
70
+ if (isPdfFile(file)) {
71
+ return (
72
+ <figure style={{ margin: 0, display: "grid", gap: "var(--space-1)" }}>
73
+ {url ? (
74
+ <iframe
75
+ src={url}
76
+ title={file.name}
77
+ style={{
78
+ display: "block",
79
+ width: "100%",
80
+ height: maxHeight,
81
+ background: "var(--surface-2)",
82
+ borderRadius: "var(--radius-s)",
83
+ border: "var(--border-w) solid var(--border)",
84
+ }}
85
+ />
86
+ ) : (
87
+ <div
88
+ style={{
89
+ display: "grid",
90
+ placeItems: "center",
91
+ height: maxHeight,
92
+ background: "var(--surface-2)",
93
+ borderRadius: "var(--radius-s)",
94
+ border: "var(--border-w) dashed var(--border)",
95
+ fontSize: "var(--text-xs)",
96
+ color: "var(--muted)",
97
+ textAlign: "center",
98
+ padding: "0 var(--space-4)",
99
+ }}
100
+ >
101
+ {failed ? "ファイルを読み込めませんでした" : "読み込み中…"}
102
+ </div>
103
+ )}
104
+ <figcaption
105
+ style={{
106
+ display: "flex",
107
+ justifyContent: "space-between",
108
+ gap: "var(--space-2)",
109
+ }}
110
+ >
111
+ {caption}
112
+ {url && (
113
+ <a
114
+ href={url}
115
+ target="_blank"
116
+ rel="noreferrer"
117
+ style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}
118
+ >
119
+ 別タブで開く
120
+ </a>
121
+ )}
122
+ </figcaption>
123
+ </figure>
124
+ )
125
+ }
126
+
127
+ if (!isImageFile(file)) {
128
+ return url ? (
129
+ <a href={url} target="_blank" rel="noreferrer" style={chipStyle}>
130
+ <span aria-hidden="true">📎</span>
131
+ {file.name}
132
+ </a>
133
+ ) : (
134
+ <span style={chipStyle}>
135
+ <span aria-hidden="true">📎</span>
136
+ {file.name}
137
+ </span>
138
+ )
139
+ }
140
+
141
+ return (
142
+ <figure style={{ margin: 0, display: "grid", gap: "var(--space-1)" }}>
143
+ {url ? (
144
+ <a href={url} target="_blank" rel="noreferrer">
145
+ <img
146
+ src={url}
147
+ alt={file.name}
148
+ style={{
149
+ display: "block",
150
+ width: "100%",
151
+ maxHeight,
152
+ objectFit: "contain",
153
+ background: "var(--surface-2)",
154
+ borderRadius: "var(--radius-s)",
155
+ border: "var(--border-w) solid var(--border)",
156
+ }}
157
+ />
158
+ </a>
159
+ ) : (
160
+ <div
161
+ style={{
162
+ display: "grid",
163
+ placeItems: "center",
164
+ height: "160px",
165
+ background: "var(--surface-2)",
166
+ borderRadius: "var(--radius-s)",
167
+ border: "var(--border-w) dashed var(--border)",
168
+ fontSize: "var(--text-xs)",
169
+ color: "var(--muted)",
170
+ textAlign: "center",
171
+ padding: "0 var(--space-4)",
172
+ }}
173
+ >
174
+ {failed ? "画像を読み込めませんでした" : "読み込み中…"}
175
+ </div>
176
+ )}
177
+ <figcaption>{caption}</figcaption>
178
+ </figure>
179
+ )
180
+ }
@@ -0,0 +1,40 @@
1
+ import { isValidElement } from "react"
2
+ import { describe, expect, it } from "vitest"
3
+ import { isUrl, linkify } from "./Linkify.js"
4
+
5
+ const hrefs = (text: string) =>
6
+ linkify(text).flatMap((node) =>
7
+ isValidElement<{ href: string }>(node) ? [node.props.href] : [],
8
+ )
9
+
10
+ describe("linkify", () => {
11
+ it("素の URL をリンクにし、前後の文字はそのまま残す", () => {
12
+ const nodes = linkify("PR: https://github.com/a/b/pull/1 を見て")
13
+ expect(nodes[0]).toBe("PR: ")
14
+ expect(hrefs("PR: https://github.com/a/b/pull/1 を見て")).toEqual([
15
+ "https://github.com/a/b/pull/1",
16
+ ])
17
+ expect(nodes[2]).toBe(" を見て")
18
+ })
19
+
20
+ it("引用符・括弧・末尾の句読点は URL に含めない", () => {
21
+ expect(hrefs('"https://example.com/x"')).toEqual(["https://example.com/x"])
22
+ expect(hrefs("(https://example.com/y)")).toEqual([
23
+ "https://example.com/y",
24
+ ])
25
+ expect(hrefs("see https://example.com/z.")).toEqual([
26
+ "https://example.com/z",
27
+ ])
28
+ })
29
+
30
+ it("URL が無ければ元の文字列だけ", () => {
31
+ expect(linkify("ただの文")).toEqual(["ただの文"])
32
+ })
33
+ })
34
+
35
+ describe("isUrl", () => {
36
+ it("文字列全体が URL のときだけ true", () => {
37
+ expect(isUrl("https://github.com/PathLight-jp/x/pull/12")).toBe(true)
38
+ expect(isUrl("詳細は https://example.com")).toBe(false)
39
+ })
40
+ })
@@ -0,0 +1,40 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ // テキスト中の素の URL(http/https)をクリックできるリンクにする。
4
+ // 値の表示(I/O・タイムライン・承認ビュー等)はどこでもこれを通す。
5
+ // 引用符・括弧・全角の区切りは URL に含めず、末尾の句読点も外す
6
+ const urlPattern = /https?:\/\/[^\s"'<>()[\]{}()「」『』【】、。]+/g
7
+ const trailingPunct = /[.,;:!?]+$/
8
+
9
+ /** 文字列全体が1つの URL か */
10
+ export const isUrl = (value: string): boolean =>
11
+ /^https?:\/\/\S+$/.test(value.trim())
12
+
13
+ export const linkify = (text: string, prefix = ""): ReactNode[] => {
14
+ const nodes: ReactNode[] = []
15
+ let last = 0
16
+ for (const match of text.matchAll(urlPattern)) {
17
+ const start = match.index ?? 0
18
+ const url = match[0].replace(trailingPunct, "")
19
+ if (url.length <= "https://".length) continue
20
+ if (start > last) nodes.push(text.slice(last, start))
21
+ nodes.push(
22
+ <a
23
+ key={`${prefix}${start}`}
24
+ href={url}
25
+ target="_blank"
26
+ rel="noreferrer"
27
+ // 行クリックで選択・開閉する親の中でも、リンクはリンクとして開く
28
+ onClick={(e) => e.stopPropagation()}
29
+ style={{ overflowWrap: "anywhere" }}
30
+ >
31
+ {url}
32
+ </a>,
33
+ )
34
+ last = start + url.length
35
+ }
36
+ if (last < text.length) nodes.push(text.slice(last))
37
+ return nodes
38
+ }
39
+
40
+ export const Linkify = ({ text }: { text: string }) => <>{linkify(text)}</>