@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.
- package/_schemas/graph.test.ts +15 -9
- package/_schemas/graph.ts +15 -6
- package/_schemas/human-manifest.test.ts +67 -0
- package/_schemas/human-manifest.ts +31 -0
- package/_schemas/port-spec.test.ts +33 -92
- package/_schemas/port-spec.ts +36 -59
- package/dist/_cli/previewCommand.js +3 -3
- package/dist/_cli/previewFiles.js +2 -3
- package/dist/_cli/previewSlack.d.ts +1 -1
- package/dist/_cli/previewSlack.js +1 -1
- package/dist/_schemas/graph.d.ts +6 -3
- package/dist/_schemas/graph.js +12 -6
- package/dist/_schemas/human-manifest.d.ts +9 -0
- package/dist/_schemas/human-manifest.js +29 -0
- package/dist/_schemas/port-spec.d.ts +5 -11
- package/dist/_schemas/port-spec.js +28 -37
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/init.js +22 -3
- package/dist/typegen.d.ts +3 -1
- package/dist/typegen.js +96 -14
- package/dist/validate.js +57 -0
- package/dist/views/_bridge.d.ts +12 -0
- package/dist/views/_bridge.js +10 -0
- package/dist/views/_humanProps.d.ts +10 -0
- package/dist/views/_humanProps.js +11 -0
- package/dist/views/_parts/DocumentCard.d.ts +2 -0
- package/dist/views/_parts/DocumentCard.js +16 -0
- package/dist/views/_parts/FilePreview.d.ts +5 -0
- package/dist/views/_parts/FilePreview.js +101 -0
- package/dist/views/_parts/Linkify.d.ts +7 -0
- package/dist/views/_parts/Linkify.js +28 -0
- package/dist/views/_parts/Markdown.d.ts +44 -0
- package/dist/views/_parts/Markdown.js +313 -0
- package/dist/views/_parts/SchemaForm.d.ts +19 -0
- package/dist/views/_parts/SchemaForm.js +310 -0
- package/dist/views/_parts/fileRef.d.ts +7 -0
- package/dist/views/_parts/fileRef.js +11 -0
- package/dist/views/_parts/useNarrow.d.ts +5 -0
- package/dist/views/_parts/useNarrow.js +23 -0
- package/dist/views/components.d.ts +7 -0
- package/dist/views/components.js +12 -0
- package/dist/views/host.d.ts +23 -0
- package/dist/views/host.js +20 -0
- package/dist/views/human.d.ts +54 -0
- package/dist/views/human.js +5 -0
- package/dist/views/protocol.d.ts +44 -0
- package/dist/views/protocol.js +5 -0
- package/dist/views/runtime.d.ts +2 -0
- package/dist/views/runtime.js +86 -0
- package/dist/views/sample.d.ts +22 -0
- package/dist/views/sample.js +38 -0
- package/dist/{slack-view.d.ts → views/slack.d.ts} +1 -1
- package/docs/development.md +39 -2
- package/index.ts +1 -0
- package/package.json +40 -4
- package/skills/create-loop/SKILL.md +7 -3
- package/templates/CLAUDE.md +2 -0
- package/views/_bridge.tsx +36 -0
- package/views/_humanProps.ts +27 -0
- package/views/_parts/DocumentCard.ts +18 -0
- package/views/_parts/FilePreview.tsx +180 -0
- package/views/_parts/Linkify.test.ts +40 -0
- package/views/_parts/Linkify.tsx +40 -0
- package/views/_parts/Markdown.test.ts +131 -0
- package/views/_parts/Markdown.tsx +472 -0
- package/views/_parts/SchemaForm.tsx +742 -0
- package/views/_parts/fileRef.ts +21 -0
- package/views/_parts/useNarrow.ts +24 -0
- package/views/components.ts +16 -0
- package/views/host.ts +27 -0
- package/views/human.ts +83 -0
- package/views/protocol.ts +63 -0
- package/views/runtime.tsx +123 -0
- package/views/sample.tsx +94 -0
- package/views/slack.test.ts +40 -0
- package/{slack-view.ts → views/slack.ts} +3 -3
- /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
|
+
});
|
package/docs/development.md
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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
|
|
16
|
-
"default": "./dist/slack
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
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 キー起動では必須)を
|
package/templates/CLAUDE.md
CHANGED
|
@@ -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)}</>
|