@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,21 @@
|
|
|
1
|
+
// ノード間を流れる file の値(storage path の参照。バイトや署名 URL は載らない)。
|
|
2
|
+
// @mawaru/common の fileRefSchema(zod)と同じ形で、部品キットは依存を増やさないよう型と判定だけ持つ
|
|
3
|
+
export type FileRef = {
|
|
4
|
+
path: string
|
|
5
|
+
name: string
|
|
6
|
+
mime_type?: string
|
|
7
|
+
size?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const isFileRef = (value: unknown): value is FileRef => {
|
|
11
|
+
if (typeof value !== "object" || value === null) return false
|
|
12
|
+
const v = value as Record<string, unknown>
|
|
13
|
+
return (
|
|
14
|
+
typeof v.path === "string" &&
|
|
15
|
+
v.path !== "" &&
|
|
16
|
+
typeof v.name === "string" &&
|
|
17
|
+
v.name !== "" &&
|
|
18
|
+
(v.mime_type === undefined || typeof v.mime_type === "string") &&
|
|
19
|
+
(v.size === undefined || typeof v.size === "number")
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react"
|
|
2
|
+
|
|
3
|
+
// 承認ビューの幅対応。判定は**ビューポートではなくコンテナの幅**で行う。
|
|
4
|
+
//
|
|
5
|
+
// 開発プレビュー(/dev/views/approval)は幅の確認を「狭いコンテナ」で再現するので、
|
|
6
|
+
// media query で判定すると実機とプレビューで挙動がズレる(プレビューでは常に
|
|
7
|
+
// デスクトップ扱いになる)。ResizeObserver でコンテナ自身を測れば両方で同じになる。
|
|
8
|
+
|
|
9
|
+
export const NARROW_WIDTH = 560
|
|
10
|
+
|
|
11
|
+
export const useNarrow = <T extends HTMLElement>() => {
|
|
12
|
+
const ref = useRef<T>(null)
|
|
13
|
+
const [narrow, setNarrow] = useState(false)
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const element = ref.current
|
|
16
|
+
if (!element) return
|
|
17
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
18
|
+
if (entry) setNarrow(entry.contentRect.width < NARROW_WIDTH)
|
|
19
|
+
})
|
|
20
|
+
observer.observe(element)
|
|
21
|
+
return () => observer.disconnect()
|
|
22
|
+
}, [])
|
|
23
|
+
return { ref, narrow }
|
|
24
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// 共通の部品(`@mawaru/sdk/components`)。human ノードの約束(`@mawaru/sdk/human`)には依存しない。
|
|
2
|
+
// mawaru の画面と顧客 repo の human ノードの実装が同じ部品を使う(mawaru が顧客 repo の実装をまとめるとき、
|
|
3
|
+
// mawaru の今の版のこの部品を同梱する)。ファイルの表示・アップロードは、mawaru の画面ではホストの口、
|
|
4
|
+
// sandbox iframe の中では mawaru の承認画面への頼みごととして裏で差し込まれる(_bridge.tsx)。
|
|
5
|
+
// **公開 API**:部品の props は後方互換でしか変えない
|
|
6
|
+
export { documentCardStyle } from "./_parts/DocumentCard.js"
|
|
7
|
+
export { FilePreview } from "./_parts/FilePreview.js"
|
|
8
|
+
export { type FileRef, isFileRef } from "./_parts/fileRef.js"
|
|
9
|
+
export { isUrl, Linkify, linkify } from "./_parts/Linkify.js"
|
|
10
|
+
export { isLongText, Markdown, useAutoGrow } from "./_parts/Markdown.js"
|
|
11
|
+
export {
|
|
12
|
+
asFormSchema,
|
|
13
|
+
type JsonSchema as FormSchema,
|
|
14
|
+
SchemaForm,
|
|
15
|
+
} from "./_parts/SchemaForm.js"
|
|
16
|
+
export { NARROW_WIDTH, useNarrow } from "./_parts/useNarrow.js"
|
package/views/host.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
import { VIEW_MESSAGE_TAG } from "./protocol.js"
|
|
3
|
+
|
|
4
|
+
// ホスト(mawaru の画面)だけが使うもの(公開 API ではない):sandbox iframe からのメッセージを確かめる形
|
|
5
|
+
// (protocol.ts の型と同じ。iframe の中は信用できないコードなので、形を確かめてから使う)、
|
|
6
|
+
// 部品が裏で使う口を差す提供部品、human ノードの実装に渡す props の組み立て(標準の承認画面も同じものを使う)
|
|
7
|
+
export {
|
|
8
|
+
useViewBridge,
|
|
9
|
+
type ViewBridge,
|
|
10
|
+
ViewBridgeProvider,
|
|
11
|
+
} from "./_bridge.js"
|
|
12
|
+
export { type HumanOps, humanPropsOf } from "./_humanProps.js"
|
|
13
|
+
export * from "./protocol.js"
|
|
14
|
+
|
|
15
|
+
const tag = z.literal(VIEW_MESSAGE_TAG)
|
|
16
|
+
|
|
17
|
+
export const viewToHostSchema = z.discriminatedUnion("type", [
|
|
18
|
+
z.object({ tag, type: z.literal("ready") }),
|
|
19
|
+
z.object({ tag, type: z.literal("error"), message: z.string() }),
|
|
20
|
+
z.object({
|
|
21
|
+
tag,
|
|
22
|
+
type: z.literal("request"),
|
|
23
|
+
id: z.number().int(),
|
|
24
|
+
method: z.enum(["out", "fetchFileUrl", "uploadFile", "writeText"]),
|
|
25
|
+
args: z.unknown(),
|
|
26
|
+
}),
|
|
27
|
+
])
|
package/views/human.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
import type { JsonSchema } from "../_schemas/node.js"
|
|
3
|
+
import type { FileRef } from "./_parts/fileRef.js"
|
|
4
|
+
|
|
5
|
+
// human ノードの実装(顧客 repo の nodes/human/<dir>/index.tsx)の約束(`@mawaru/sdk/human`)。
|
|
6
|
+
// human ノードは「input を受け取り、人の操作で出口(approve・reject・追加の出口)に値を出す」。
|
|
7
|
+
// program の run(input) が { <出口ポートのkey>: <データ> } を返すのと同じ形で、human はそれを人が画面で選ぶ。
|
|
8
|
+
//
|
|
9
|
+
// 実装は React のコンポーネント1つで、承認画面の上の帯より下を全部描く。mawaru が esbuild でまとめ、
|
|
10
|
+
// sandbox iframe の中で動かす(mawaru のログイン・API・fetch には届かない)。
|
|
11
|
+
// 局面・担当・全員承認の進み具合は mawaru の承認画面の帯が出し、実装が描かれるのは承認待ちのときだけ。
|
|
12
|
+
//
|
|
13
|
+
// 型は mawaru typegen が config.json から作る types.d.ts(Input / Outputs / Output / Refs。program と同じ)を使う:
|
|
14
|
+
// export default defineHuman<Input, Output, Refs>(({ input, out, ctx }) => …)
|
|
15
|
+
|
|
16
|
+
export type HumanProps<
|
|
17
|
+
TInput = unknown,
|
|
18
|
+
TOutput = Record<string, unknown>,
|
|
19
|
+
TRefs = Record<string, unknown>,
|
|
20
|
+
> = {
|
|
21
|
+
/** 届いた値(in ポートの値= step の input) */
|
|
22
|
+
input: TInput
|
|
23
|
+
/**
|
|
24
|
+
* 人の操作を mawaru に渡す。`{ <出口ポートのkey>: <データ> }`(program の run の戻り値と同じ1キーのオブジェクト)。
|
|
25
|
+
* 人がボタンを押した直後の呼び出しだけを受け付ける(開いただけで決定する画面は書けない)。
|
|
26
|
+
* 送れたら承認画面は run の画面へ戻る。断られたら(担当でない・もう決まっている など)理由つきで失敗する
|
|
27
|
+
*/
|
|
28
|
+
out: (output: TOutput) => Promise<void>
|
|
29
|
+
/** input 以外の材料(program の run の ctx と同じ位置づけ) */
|
|
30
|
+
ctx: {
|
|
31
|
+
input: {
|
|
32
|
+
/** input の型(in ポートの JSON Schema) */
|
|
33
|
+
schema: JsonSchema
|
|
34
|
+
/** input に含まれるファイルを表示する URL を取る(sandbox の中からは mawaru のファイルを直接読めない) */
|
|
35
|
+
fetchFileUrl: (file: FileRef) => Promise<string>
|
|
36
|
+
}
|
|
37
|
+
output: {
|
|
38
|
+
/** 出口ごとの型(out ポートの key → JSON Schema。approve・reject・追加の出口) */
|
|
39
|
+
schema: Record<string, JsonSchema>
|
|
40
|
+
/** 出す値に入れるファイル(差し戻しの参考ファイルなど)を上げて、参照を受け取る */
|
|
41
|
+
uploadFile: (file: File) => Promise<FileRef>
|
|
42
|
+
}
|
|
43
|
+
clipboard: {
|
|
44
|
+
/** クリップボードに書く(sandbox の中では使えない navigator.clipboard.writeText の代わり) */
|
|
45
|
+
writeText: (text: string) => Promise<void>
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* ループ変数(この step が始まった時点の値。program の ctx.refs と同じ)。
|
|
49
|
+
* 予約キー `input` が run の起動入力
|
|
50
|
+
*/
|
|
51
|
+
refs: TRefs
|
|
52
|
+
step: {
|
|
53
|
+
/** 見ている人がこの step の担当か(担当でなければ out は断られる) */
|
|
54
|
+
isAssignee: boolean
|
|
55
|
+
/**
|
|
56
|
+
* 値を直してよいか:担当で、全員承認のノードでないとき true
|
|
57
|
+
* (全員承認は全員が同じ値を承認するので、届いた値のまま決める)
|
|
58
|
+
*/
|
|
59
|
+
editable: boolean
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** mawaru が承認画面に描く、human ノードの画面(React のコンポーネント) */
|
|
65
|
+
export type HumanView<
|
|
66
|
+
TInput = unknown,
|
|
67
|
+
TOutput = Record<string, unknown>,
|
|
68
|
+
TRefs = Record<string, unknown>,
|
|
69
|
+
> = (props: HumanProps<TInput, TOutput, TRefs>) => ReactNode
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* human ノードの実装を定義する:input と out を受けて描く関数を渡すと、画面を返す。
|
|
73
|
+
* React では props を受けて描く関数がそのままコンポーネントなので、型を付けて返すだけ
|
|
74
|
+
*/
|
|
75
|
+
export const defineHuman = <
|
|
76
|
+
TInput = unknown,
|
|
77
|
+
TOutput = Record<string, unknown>,
|
|
78
|
+
TRefs = Record<string, unknown>,
|
|
79
|
+
>(
|
|
80
|
+
render: (props: HumanProps<TInput, TOutput, TRefs>) => ReactNode,
|
|
81
|
+
): HumanView<TInput, TOutput, TRefs> => render
|
|
82
|
+
|
|
83
|
+
export type { FileRef }
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { JsonSchema } from "../_schemas/node.js"
|
|
2
|
+
|
|
3
|
+
// sandbox iframe の中の実行部分(runtime.tsx)とホスト(mawaru の承認画面)とのメッセージの形。
|
|
4
|
+
// **公開 API ではない**:iframe の中の実行部分とホストは同じデプロイの mawaru のコード(まとめるときに
|
|
5
|
+
// その時点の版を同梱する)なので、いつ変えてもよい。
|
|
6
|
+
// iframe に入る JS を小さく保つため、ここには型と定数だけを置く(ホストの検査は host.ts)
|
|
7
|
+
|
|
8
|
+
export const VIEW_MESSAGE_TAG = "mawaru:view"
|
|
9
|
+
|
|
10
|
+
export type ThemeMode = "light" | "dark"
|
|
11
|
+
|
|
12
|
+
// human ノードの実装に渡す材料のうち、JSON で送れるもの(関数はホストへの頼みごととして iframe の中で組み立てる)
|
|
13
|
+
export type HumanViewData = {
|
|
14
|
+
input: unknown
|
|
15
|
+
inputSchema: JsonSchema
|
|
16
|
+
outputSchema: Record<string, JsonSchema>
|
|
17
|
+
refs: unknown
|
|
18
|
+
isAssignee: boolean
|
|
19
|
+
editable: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ビューからホストへの頼みごと(HumanProps の関数と、部品が裏で使う口)
|
|
23
|
+
export type HumanRequestMethod =
|
|
24
|
+
| "out"
|
|
25
|
+
| "fetchFileUrl"
|
|
26
|
+
| "uploadFile"
|
|
27
|
+
| "writeText"
|
|
28
|
+
|
|
29
|
+
// ビュー → ホスト
|
|
30
|
+
export type ViewToHost =
|
|
31
|
+
| { tag: typeof VIEW_MESSAGE_TAG; type: "ready" }
|
|
32
|
+
// 描画の例外(ホストはエラーと理由を出す)
|
|
33
|
+
| { tag: typeof VIEW_MESSAGE_TAG; type: "error"; message: string }
|
|
34
|
+
| {
|
|
35
|
+
tag: typeof VIEW_MESSAGE_TAG
|
|
36
|
+
type: "request"
|
|
37
|
+
id: number
|
|
38
|
+
method: HumanRequestMethod
|
|
39
|
+
args: unknown
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ホスト → ビュー
|
|
43
|
+
export type HostToView =
|
|
44
|
+
| {
|
|
45
|
+
tag: typeof VIEW_MESSAGE_TAG
|
|
46
|
+
type: "data"
|
|
47
|
+
data: HumanViewData
|
|
48
|
+
mode: ThemeMode
|
|
49
|
+
}
|
|
50
|
+
| {
|
|
51
|
+
tag: typeof VIEW_MESSAGE_TAG
|
|
52
|
+
type: "response"
|
|
53
|
+
id: number
|
|
54
|
+
ok: true
|
|
55
|
+
value: unknown
|
|
56
|
+
}
|
|
57
|
+
| {
|
|
58
|
+
tag: typeof VIEW_MESSAGE_TAG
|
|
59
|
+
type: "response"
|
|
60
|
+
id: number
|
|
61
|
+
ok: false
|
|
62
|
+
error: string
|
|
63
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Component, type ReactNode } from "react"
|
|
2
|
+
import { createRoot } from "react-dom/client"
|
|
3
|
+
import { ViewBridgeProvider } from "./_bridge.js"
|
|
4
|
+
import { type HumanOps, humanPropsOf } from "./_humanProps.js"
|
|
5
|
+
import type { FileRef } from "./_parts/fileRef.js"
|
|
6
|
+
import type { HumanView } from "./human.js"
|
|
7
|
+
import {
|
|
8
|
+
type HostToView,
|
|
9
|
+
type HumanRequestMethod,
|
|
10
|
+
type HumanViewData,
|
|
11
|
+
VIEW_MESSAGE_TAG,
|
|
12
|
+
type ViewToHost,
|
|
13
|
+
} from "./protocol.js"
|
|
14
|
+
|
|
15
|
+
// sandbox iframe の中の実行部分。mawaru が顧客 repo の human ノードの実装をまとめるとき、
|
|
16
|
+
// 入口から runHumanView を呼ぶ。ホストとのメッセージの形は protocol.ts(公開 API ではない)
|
|
17
|
+
|
|
18
|
+
// union のまま tag だけを外す(Omit は union を潰す)
|
|
19
|
+
type WithoutTag<T> = T extends unknown ? Omit<T, "tag"> : never
|
|
20
|
+
|
|
21
|
+
const post = (message: WithoutTag<ViewToHost>) =>
|
|
22
|
+
// 受け手は親(ホスト)の window だけ。不透明オリジンの iframe からは親のオリジンを指定できない
|
|
23
|
+
window.parent.postMessage({ tag: VIEW_MESSAGE_TAG, ...message }, "*")
|
|
24
|
+
|
|
25
|
+
const messageOf = (err: unknown) =>
|
|
26
|
+
err instanceof Error ? err.message : String(err)
|
|
27
|
+
|
|
28
|
+
let nextRequestId = 1
|
|
29
|
+
const pending = new Map<
|
|
30
|
+
number,
|
|
31
|
+
{ resolve: (value: unknown) => void; reject: (err: Error) => void }
|
|
32
|
+
>()
|
|
33
|
+
const dataListeners = new Set<(data: HumanViewData) => void>()
|
|
34
|
+
|
|
35
|
+
const onHostMessage = (event: MessageEvent) => {
|
|
36
|
+
if (event.source !== window.parent) return
|
|
37
|
+
const message = event.data as HostToView | undefined
|
|
38
|
+
if (message?.tag !== VIEW_MESSAGE_TAG) return
|
|
39
|
+
if (message.type === "response") {
|
|
40
|
+
const waiting = pending.get(message.id)
|
|
41
|
+
if (!waiting) return
|
|
42
|
+
pending.delete(message.id)
|
|
43
|
+
if (message.ok) waiting.resolve(message.value)
|
|
44
|
+
else waiting.reject(new Error(message.error))
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
if (message.type === "data") {
|
|
48
|
+
document.documentElement.dataset.mode = message.mode
|
|
49
|
+
for (const listener of dataListeners) listener(message.data)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ホストに頼む(返事が来るまで待つ)
|
|
54
|
+
const requestHost = <T,>(
|
|
55
|
+
method: HumanRequestMethod,
|
|
56
|
+
args: unknown,
|
|
57
|
+
): Promise<T> =>
|
|
58
|
+
new Promise<T>((resolve, reject) => {
|
|
59
|
+
const id = nextRequestId++
|
|
60
|
+
pending.set(id, {
|
|
61
|
+
resolve: resolve as (value: unknown) => void,
|
|
62
|
+
reject,
|
|
63
|
+
})
|
|
64
|
+
post({ type: "request", id, method, args })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const ops: HumanOps = {
|
|
68
|
+
out: (output) => requestHost<void>("out", { output }),
|
|
69
|
+
fetchFileUrl: (file: FileRef) =>
|
|
70
|
+
requestHost<string>("fetchFileUrl", { file }),
|
|
71
|
+
uploadFile: (file: File) => requestHost<FileRef>("uploadFile", { file }),
|
|
72
|
+
writeText: (text: string) => requestHost<void>("writeText", { text }),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 部品(FilePreview・SchemaForm のファイル欄)が裏で使う口も、同じ頼みごとにつなぐ
|
|
76
|
+
const bridge = {
|
|
77
|
+
fileUrl: ops.fetchFileUrl,
|
|
78
|
+
uploadFile: ops.uploadFile,
|
|
79
|
+
copyText: ops.writeText,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 描画の例外をホストへ知らせる(ホストはエラーと理由を出す)
|
|
83
|
+
class ReportErrors extends Component<
|
|
84
|
+
{ children: ReactNode },
|
|
85
|
+
{ failed: boolean }
|
|
86
|
+
> {
|
|
87
|
+
state = { failed: false }
|
|
88
|
+
static getDerivedStateFromError() {
|
|
89
|
+
return { failed: true }
|
|
90
|
+
}
|
|
91
|
+
componentDidCatch(error: unknown) {
|
|
92
|
+
post({ type: "error", message: messageOf(error) })
|
|
93
|
+
}
|
|
94
|
+
render() {
|
|
95
|
+
return this.state.failed ? null : this.props.children
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 実装はコンポーネント1つ。状態(直した値など)は実装が useState で持ち、材料が届き直しても
|
|
100
|
+
// 同じコンポーネントとして描き直すので保たれる
|
|
101
|
+
export const runHumanView = (View: HumanView) => {
|
|
102
|
+
window.addEventListener("message", onHostMessage)
|
|
103
|
+
window.addEventListener("error", (event) =>
|
|
104
|
+
post({ type: "error", message: event.message }),
|
|
105
|
+
)
|
|
106
|
+
window.addEventListener("unhandledrejection", (event) =>
|
|
107
|
+
post({ type: "error", message: messageOf(event.reason) }),
|
|
108
|
+
)
|
|
109
|
+
const container = document.getElementById("root")
|
|
110
|
+
if (!container) throw new Error("#root がありません")
|
|
111
|
+
const root = createRoot(container)
|
|
112
|
+
dataListeners.add((data) => {
|
|
113
|
+
const props = humanPropsOf(data, ops)
|
|
114
|
+
root.render(
|
|
115
|
+
<ViewBridgeProvider bridge={bridge}>
|
|
116
|
+
<ReportErrors>
|
|
117
|
+
<View {...props} />
|
|
118
|
+
</ReportErrors>
|
|
119
|
+
</ViewBridgeProvider>,
|
|
120
|
+
)
|
|
121
|
+
})
|
|
122
|
+
post({ type: "ready" })
|
|
123
|
+
}
|
package/views/sample.tsx
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { useState } from "react"
|
|
2
|
+
import { defineHuman } from "./human.js"
|
|
3
|
+
|
|
4
|
+
// サンプル:AI が下書きした返信を、人が直してから送る(approve)か、理由を書いて差し戻す(reject)。
|
|
5
|
+
// 顧客 repo では Input / Outputs / Output は typegen が config.json から作る(nodes/human/<dir>/types.d.ts)。
|
|
6
|
+
// ここでは同じものを手で書いている
|
|
7
|
+
|
|
8
|
+
type Input = { subject: string; body: string }
|
|
9
|
+
|
|
10
|
+
type Outputs = {
|
|
11
|
+
approve: { subject: string; body: string }
|
|
12
|
+
reject: { prompt: string }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type Output =
|
|
16
|
+
| { approve: Outputs["approve"]; reject?: never }
|
|
17
|
+
| { reject: Outputs["reject"]; approve?: never }
|
|
18
|
+
|
|
19
|
+
export default defineHuman<Input, Output>(({ input, out }) => {
|
|
20
|
+
const [reply, setReply] = useState(input)
|
|
21
|
+
const [prompt, setPrompt] = useState("")
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
style={{
|
|
25
|
+
maxWidth: "720px",
|
|
26
|
+
margin: "0 auto",
|
|
27
|
+
padding: "var(--space-8) var(--space-6)",
|
|
28
|
+
display: "flex",
|
|
29
|
+
flexDirection: "column",
|
|
30
|
+
gap: "var(--space-3)",
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
<input
|
|
34
|
+
value={reply.subject}
|
|
35
|
+
onChange={(e) => setReply({ ...reply, subject: e.target.value })}
|
|
36
|
+
style={{
|
|
37
|
+
padding: "var(--space-2) var(--space-3)",
|
|
38
|
+
border: "var(--border-w) solid var(--border)",
|
|
39
|
+
borderRadius: "var(--radius-s)",
|
|
40
|
+
background: "var(--surface)",
|
|
41
|
+
color: "var(--ink)",
|
|
42
|
+
font: "inherit",
|
|
43
|
+
}}
|
|
44
|
+
/>
|
|
45
|
+
<textarea
|
|
46
|
+
value={reply.body}
|
|
47
|
+
onChange={(e) => setReply({ ...reply, body: e.target.value })}
|
|
48
|
+
style={{
|
|
49
|
+
minHeight: "16em",
|
|
50
|
+
padding: "var(--space-2) var(--space-3)",
|
|
51
|
+
border: "var(--border-w) solid var(--border)",
|
|
52
|
+
borderRadius: "var(--radius-s)",
|
|
53
|
+
background: "var(--surface)",
|
|
54
|
+
color: "var(--ink)",
|
|
55
|
+
font: "inherit",
|
|
56
|
+
}}
|
|
57
|
+
/>
|
|
58
|
+
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
className="primary"
|
|
62
|
+
disabled={reply.body.trim() === ""}
|
|
63
|
+
onClick={() => out({ approve: reply })}
|
|
64
|
+
>
|
|
65
|
+
この内容で送信
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
<div style={{ display: "flex", gap: "var(--space-2)" }}>
|
|
69
|
+
<input
|
|
70
|
+
value={prompt}
|
|
71
|
+
placeholder="差し戻しの理由"
|
|
72
|
+
onChange={(e) => setPrompt(e.target.value)}
|
|
73
|
+
style={{
|
|
74
|
+
flex: 1,
|
|
75
|
+
padding: "var(--space-2) var(--space-3)",
|
|
76
|
+
border: "var(--border-w) solid var(--border)",
|
|
77
|
+
borderRadius: "var(--radius-s)",
|
|
78
|
+
background: "var(--surface)",
|
|
79
|
+
color: "var(--ink)",
|
|
80
|
+
font: "inherit",
|
|
81
|
+
}}
|
|
82
|
+
/>
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
className="ghost"
|
|
86
|
+
disabled={prompt.trim() === ""}
|
|
87
|
+
onClick={() => out({ reject: { prompt } })}
|
|
88
|
+
>
|
|
89
|
+
差し戻す
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
decisionActionId,
|
|
4
|
+
decisionFormActionId,
|
|
5
|
+
parseDecisionActionId,
|
|
6
|
+
} from "./slack.js"
|
|
7
|
+
|
|
8
|
+
// 決定ボタンのプロトコル:ビューは action_id だけで「どの出口ポートへ流すか」を宣言し、
|
|
9
|
+
// ホストは押下を action_id から読み戻す。両者が同じ関数を使うので、往復で一致することを固定する
|
|
10
|
+
|
|
11
|
+
describe("決定ボタンの action_id", () => {
|
|
12
|
+
it("そのまま確定は decision:<port_key>", () => {
|
|
13
|
+
expect(decisionActionId("approve")).toBe("decision:approve")
|
|
14
|
+
expect(parseDecisionActionId("decision:approve")).toEqual({
|
|
15
|
+
port_key: "approve",
|
|
16
|
+
form: false,
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("フォームで確定は decision-form:<port_key>", () => {
|
|
21
|
+
expect(decisionFormActionId("reject")).toBe("decision-form:reject")
|
|
22
|
+
expect(parseDecisionActionId("decision-form:reject")).toEqual({
|
|
23
|
+
port_key: "reject",
|
|
24
|
+
form: true,
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("port_key に区切り文字を含んでも往復で一致する", () => {
|
|
29
|
+
expect(parseDecisionActionId(decisionActionId("a:b"))).toEqual({
|
|
30
|
+
port_key: "a:b",
|
|
31
|
+
form: false,
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("決定ボタン以外の action_id は null", () => {
|
|
36
|
+
expect(parseDecisionActionId("approve")).toBeNull()
|
|
37
|
+
expect(parseDecisionActionId(undefined)).toBeNull()
|
|
38
|
+
expect(parseDecisionActionId("other:approve")).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { JsonSchema, NodeKind } from "
|
|
1
|
+
import type { JsonSchema, NodeKind } from "../_schemas/node.js"
|
|
2
2
|
|
|
3
|
-
// Slack ビューの契約(`@mawaru/sdk/slack
|
|
3
|
+
// Slack ビューの契約(`@mawaru/sdk/views/slack`)。ノードの Slack ビュー
|
|
4
4
|
// (nodes.slack_view_key)が実装する関数と、ホスト(mawaru)が渡す ctx の形の正。
|
|
5
5
|
// mawaru 内蔵のビュー(backend の _helpers/slackViews/)と、顧客の実行 repo の
|
|
6
6
|
// views/slack/<name>/ のビューが同じ契約で書く
|
|
@@ -43,7 +43,7 @@ export type SlackViewMaterial<TIn = unknown, TOut = unknown> = {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
// 実行中メッセージの ctx(全 kind 共通)。TIn は in ポートの型
|
|
46
|
-
// (human
|
|
46
|
+
// (human なら実装(nodes/human/)の宣言が転写されたポートの型)
|
|
47
47
|
export type SlackViewContext<TIn = unknown> = {
|
|
48
48
|
// ノードの kind。決定ボタン(承認・差し戻し)は human のときだけ意味を持つので、
|
|
49
49
|
// 複数の kind で使うビューはこれを見てボタンを描くか決める
|
|
File without changes
|