@mawaru/sdk 0.5.0 → 0.9.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/ai-manifest.test.ts +17 -2
- package/_schemas/ai-manifest.ts +9 -9
- package/_schemas/extract-component.test.ts +220 -0
- package/_schemas/extract-component.ts +224 -0
- package/_schemas/graph.test.ts +232 -12
- package/_schemas/graph.ts +121 -13
- package/_schemas/node.ts +3 -0
- package/_schemas/port-spec.test.ts +190 -37
- package/_schemas/port-spec.ts +165 -58
- package/_schemas/program-manifest.ts +9 -8
- package/dist/_schemas/ai-manifest.js +6 -9
- package/dist/_schemas/extract-component.d.ts +35 -0
- package/dist/_schemas/extract-component.js +146 -0
- package/dist/_schemas/graph.d.ts +25 -3
- package/dist/_schemas/graph.js +100 -14
- package/dist/_schemas/node.d.ts +1 -0
- package/dist/_schemas/node.js +3 -0
- package/dist/_schemas/port-spec.d.ts +10 -2
- package/dist/_schemas/port-spec.js +136 -54
- package/dist/_schemas/program-manifest.js +6 -8
- package/dist/cli.js +0 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/init.js +3 -0
- package/dist/typegen.js +8 -1
- package/docs/development.md +9 -2
- package/index.ts +1 -0
- package/package.json +11 -17
- package/templates/gitignore +4 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
2
|
import { aiManifestSchema } from "./ai-manifest.js"
|
|
3
|
-
import { envelopeSchemaOf } from "./port-spec.js"
|
|
3
|
+
import { envelopeSchemaOf, instructionSchema } from "./port-spec.js"
|
|
4
4
|
|
|
5
5
|
const envelope = envelopeSchemaOf({ type: "string" })
|
|
6
6
|
|
|
@@ -49,11 +49,26 @@ describe("aiManifestSchema", () => {
|
|
|
49
49
|
).toThrow()
|
|
50
50
|
})
|
|
51
51
|
|
|
52
|
-
it('inputs は "in"
|
|
52
|
+
it('inputs は "in" 必須・instruction は任意(kind が固定する2口)', () => {
|
|
53
|
+
// in は必須
|
|
53
54
|
expect(() => aiManifestSchema.parse({ ...valid, inputs: {} })).toThrow()
|
|
54
55
|
expect(() =>
|
|
55
56
|
aiManifestSchema.parse({ ...valid, inputs: { data: {} } }),
|
|
56
57
|
).toThrow()
|
|
58
|
+
// 固定ポートの instruction も宣言に載る(ノードの現在の姿を書き出すため)
|
|
59
|
+
expect(
|
|
60
|
+
aiManifestSchema.parse({
|
|
61
|
+
...valid,
|
|
62
|
+
inputs: { in: {}, instruction: instructionSchema() },
|
|
63
|
+
}).inputs.instruction,
|
|
64
|
+
).toEqual(instructionSchema())
|
|
65
|
+
// in を2口に分ける前に同期された config.json(in だけ)もそのまま読める
|
|
66
|
+
expect(
|
|
67
|
+
Object.keys(
|
|
68
|
+
aiManifestSchema.parse({ ...valid, inputs: { in: {} } }).inputs,
|
|
69
|
+
),
|
|
70
|
+
).toEqual(["in"])
|
|
71
|
+
// kind が固定しない key は宣言できない
|
|
57
72
|
expect(() =>
|
|
58
73
|
aiManifestSchema.parse({ ...valid, inputs: { in: {}, extra: {} } }),
|
|
59
74
|
).toThrow()
|
package/_schemas/ai-manifest.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod"
|
|
2
2
|
import { jsonSchemaSchema } from "./node.js"
|
|
3
|
-
import { isAiEnvelopeSchema } from "./port-spec.js"
|
|
3
|
+
import { isAiEnvelopeSchema, manifestInKeysViolation } from "./port-spec.js"
|
|
4
4
|
|
|
5
5
|
// AI ノードのモデル選択肢(既定は Opus:下書き品質が価値の中心。費用はユーザー持ち)
|
|
6
6
|
export const AI_MODELS = [
|
|
@@ -43,14 +43,14 @@ export const aiManifestSchema = z
|
|
|
43
43
|
outputs: z.record(z.string(), jsonSchemaSchema),
|
|
44
44
|
})
|
|
45
45
|
.superRefine((manifest, ctx) => {
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
})
|
|
46
|
+
// in の構成は kind が固定する(ai は上流データの in + 差し戻しの instruction の2口)。
|
|
47
|
+
// ルールの正は port-spec 側で、ここは参照するだけ
|
|
48
|
+
const inViolation = manifestInKeysViolation(
|
|
49
|
+
"ai",
|
|
50
|
+
Object.keys(manifest.inputs),
|
|
51
|
+
)
|
|
52
|
+
if (inViolation) {
|
|
53
|
+
ctx.addIssue({ code: "custom", path: ["inputs"], message: inViolation })
|
|
54
54
|
}
|
|
55
55
|
// outputs は1件以上・key 自由(複数出口=分岐。AIノードの複数出口(分岐).md)
|
|
56
56
|
if (Object.keys(manifest.outputs).length < 1) {
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
analyzeExtractSelection,
|
|
4
|
+
buildExtractPlan,
|
|
5
|
+
} from "./extract-component.js"
|
|
6
|
+
import type { GraphNode } from "./graph.js"
|
|
7
|
+
import type { GraphPort } from "./node.js"
|
|
8
|
+
|
|
9
|
+
// テスト用の決定的な uuid(graph.test.ts と同じ流儀)
|
|
10
|
+
let seq = 0
|
|
11
|
+
const uuid = () => `00000000-0000-4000-8000-${String(++seq).padStart(12, "0")}`
|
|
12
|
+
|
|
13
|
+
const port = (io: "in" | "out", key: string): GraphPort => ({
|
|
14
|
+
id: uuid(),
|
|
15
|
+
io,
|
|
16
|
+
key,
|
|
17
|
+
name: key,
|
|
18
|
+
schema: { type: "object" },
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const makeNode = (
|
|
22
|
+
kind: GraphNode["kind"],
|
|
23
|
+
name: string,
|
|
24
|
+
x = 0,
|
|
25
|
+
y = 0,
|
|
26
|
+
): GraphNode => ({
|
|
27
|
+
id: uuid(),
|
|
28
|
+
kind,
|
|
29
|
+
name,
|
|
30
|
+
ports: [port("in", "in"), port("out", "main")],
|
|
31
|
+
hooks: [],
|
|
32
|
+
position_x: x,
|
|
33
|
+
position_y: y,
|
|
34
|
+
...(kind === "wait"
|
|
35
|
+
? { wait: { wait_type: "duration" as const, duration_seconds: 90 } }
|
|
36
|
+
: {}),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const inOf = (n: GraphNode) => {
|
|
40
|
+
const p = n.ports.find((p) => p.io === "in")
|
|
41
|
+
if (!p) throw new Error("in がない")
|
|
42
|
+
return p
|
|
43
|
+
}
|
|
44
|
+
const outOf = (n: GraphNode, key = "main") => {
|
|
45
|
+
const p = n.ports.find((p) => p.io === "out" && p.key === key)
|
|
46
|
+
if (!p) throw new Error("out がない")
|
|
47
|
+
return p
|
|
48
|
+
}
|
|
49
|
+
const connect = (from: GraphNode, to: GraphNode) => ({
|
|
50
|
+
from_port_id: outOf(from).id,
|
|
51
|
+
to_port_id: inOf(to).id,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// 直列 A → B → C → D(B, C を選択するのが基本ケース)
|
|
55
|
+
const chain = () => {
|
|
56
|
+
const a = makeNode("program", "A", 0, 0)
|
|
57
|
+
const b = makeNode("wait", "B", 100, 0)
|
|
58
|
+
const c = makeNode("program", "C", 200, 100)
|
|
59
|
+
const d = makeNode("program", "D", 300, 0)
|
|
60
|
+
const nodes = [a, b, c, d]
|
|
61
|
+
const connections = [connect(a, b), connect(b, c), connect(c, d)]
|
|
62
|
+
return { a, b, c, d, nodes, connections }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("analyzeExtractSelection(境界の分類と V1 制約)", () => {
|
|
66
|
+
it("直列の中間2ノード:入口・出口と内部/入口/出口線を分類する", () => {
|
|
67
|
+
const { a, b, c, d, nodes, connections } = chain()
|
|
68
|
+
const result = analyzeExtractSelection(nodes, connections, [b.id, c.id])
|
|
69
|
+
if (!result.ok) throw new Error(result.reason)
|
|
70
|
+
expect(result.entryNodeId).toBe(b.id)
|
|
71
|
+
expect(result.exitPortId).toBe(outOf(c).id)
|
|
72
|
+
expect(result.internal).toEqual([connect(b, c)])
|
|
73
|
+
expect(result.inbound).toEqual([connect(a, b)])
|
|
74
|
+
expect(result.outbound).toEqual([connect(c, d)])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("孤立した島(入出力なし)も抽出できる(entry/exit は null)", () => {
|
|
78
|
+
const { b, c, nodes } = chain()
|
|
79
|
+
const internalOnly = [connect(b, c)]
|
|
80
|
+
const result = analyzeExtractSelection(nodes, internalOnly, [b.id, c.id])
|
|
81
|
+
if (!result.ok) throw new Error(result.reason)
|
|
82
|
+
expect(result.entryNodeId).toBeNull()
|
|
83
|
+
expect(result.exitPortId).toBeNull()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("入口が2ノードになる選択は拒否する", () => {
|
|
87
|
+
const { a, b, c, d, nodes, connections } = chain()
|
|
88
|
+
// A→B と D→C を追加:選択 {B, C} への外部入力が B と C の2ノードに入る
|
|
89
|
+
const withSecondEntry = [...connections, connect(d, c)]
|
|
90
|
+
const result = analyzeExtractSelection(nodes, withSecondEntry, [b.id, c.id])
|
|
91
|
+
expect(result.ok).toBe(false)
|
|
92
|
+
if (!result.ok) expect(result.reason).toContain("入口")
|
|
93
|
+
void a
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("出口が2ポートになる選択は拒否する(同一ポートからの fan-out は許す)", () => {
|
|
97
|
+
const { a, b, c, d, nodes, connections } = chain()
|
|
98
|
+
// B→D を追加:選択 {B, C} から外への出力が B.main と C.main の2ポート
|
|
99
|
+
const twoExits = [...connections, connect(b, d)]
|
|
100
|
+
const result = analyzeExtractSelection(nodes, twoExits, [b.id, c.id])
|
|
101
|
+
expect(result.ok).toBe(false)
|
|
102
|
+
if (!result.ok) expect(result.reason).toContain("出口")
|
|
103
|
+
|
|
104
|
+
// C.main → D に加えて C.main → A(同一ポートの fan-out)は出口1つ扱い
|
|
105
|
+
const fanOut = [
|
|
106
|
+
...connections,
|
|
107
|
+
{ from_port_id: outOf(c).id, to_port_id: inOf(a).id },
|
|
108
|
+
]
|
|
109
|
+
const ok = analyzeExtractSelection(nodes, fanOut, [b.id, c.id])
|
|
110
|
+
expect(ok.ok).toBe(true)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("空・1ノードのみ・start/end 含みは拒否する", () => {
|
|
114
|
+
const { b, nodes, connections } = chain()
|
|
115
|
+
expect(analyzeExtractSelection(nodes, connections, []).ok).toBe(false)
|
|
116
|
+
expect(analyzeExtractSelection(nodes, connections, [b.id]).ok).toBe(false)
|
|
117
|
+
|
|
118
|
+
const start = makeNode("start", "開始")
|
|
119
|
+
const withStart = [...nodes, start]
|
|
120
|
+
const result = analyzeExtractSelection(withStart, connections, [
|
|
121
|
+
b.id,
|
|
122
|
+
start.id,
|
|
123
|
+
])
|
|
124
|
+
expect(result.ok).toBe(false)
|
|
125
|
+
if (!result.ok) expect(result.reason).toContain("開始")
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe("buildExtractPlan(子グラフと親置換の生成)", () => {
|
|
130
|
+
const build = () => {
|
|
131
|
+
const { a, b, c, d, nodes, connections } = chain()
|
|
132
|
+
const childStartOutPortId = uuid()
|
|
133
|
+
const childEndInPortId = uuid()
|
|
134
|
+
const componentLoopId = uuid()
|
|
135
|
+
const plan = buildExtractPlan({
|
|
136
|
+
nodes,
|
|
137
|
+
connections,
|
|
138
|
+
selectedIds: [b.id, c.id],
|
|
139
|
+
name: "PRレビュー",
|
|
140
|
+
componentLoopId,
|
|
141
|
+
childStartOutPortId,
|
|
142
|
+
childEndInPortId,
|
|
143
|
+
newId: uuid,
|
|
144
|
+
})
|
|
145
|
+
if (!plan.ok) throw new Error(plan.reason)
|
|
146
|
+
return {
|
|
147
|
+
a,
|
|
148
|
+
b,
|
|
149
|
+
c,
|
|
150
|
+
d,
|
|
151
|
+
nodes,
|
|
152
|
+
connections,
|
|
153
|
+
plan,
|
|
154
|
+
childStartOutPortId,
|
|
155
|
+
childEndInPortId,
|
|
156
|
+
componentLoopId,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
it("子:ノードは新 id でコピーされ、kind 設定を引き継ぎ、内部接続の両端が新 id に揃う", () => {
|
|
161
|
+
const { b, plan } = build()
|
|
162
|
+
expect(plan.childNodes).toHaveLength(2)
|
|
163
|
+
const [nb, nc] = plan.childNodes
|
|
164
|
+
if (!nb || !nc) throw new Error("コピーがない")
|
|
165
|
+
// 新 id(元と重複しない)・名前と kind 設定のコピー
|
|
166
|
+
expect(nb.id).not.toBe(b.id)
|
|
167
|
+
expect(nb.name).toBe("B")
|
|
168
|
+
expect(nb.wait).toEqual({ wait_type: "duration", duration_seconds: 90 })
|
|
169
|
+
expect(nc.name).toBe("C")
|
|
170
|
+
// 内部接続はコピー後のポート id を指す
|
|
171
|
+
const internal = plan.childConnections.find(
|
|
172
|
+
(conn) =>
|
|
173
|
+
conn.from_port_id === outOf(nb).id && conn.to_port_id === inOf(nc).id,
|
|
174
|
+
)
|
|
175
|
+
expect(internal).toBeDefined()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it("子:start.main → 入口ノード、出口ポート → end.in が配線される", () => {
|
|
179
|
+
const { plan, childStartOutPortId, childEndInPortId } = build()
|
|
180
|
+
const [nb, nc] = plan.childNodes
|
|
181
|
+
if (!nb || !nc) throw new Error("コピーがない")
|
|
182
|
+
expect(plan.childConnections).toContainEqual({
|
|
183
|
+
from_port_id: childStartOutPortId,
|
|
184
|
+
to_port_id: inOf(nb).id,
|
|
185
|
+
})
|
|
186
|
+
expect(plan.childConnections).toContainEqual({
|
|
187
|
+
from_port_id: outOf(nc).id,
|
|
188
|
+
to_port_id: childEndInPortId,
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it("親:選択が groupNode に置き換わり、外部との線が in/main へ繋ぎ直される", () => {
|
|
193
|
+
const { a, b, c, d, plan, componentLoopId } = build()
|
|
194
|
+
// groupNode:component 参照・in/main・選択ノードの重心
|
|
195
|
+
expect(plan.groupNode.kind).toBe("component")
|
|
196
|
+
expect(plan.groupNode.component).toEqual({
|
|
197
|
+
component_loop_id: componentLoopId,
|
|
198
|
+
})
|
|
199
|
+
expect(plan.groupNode.name).toBe("PRレビュー")
|
|
200
|
+
expect(plan.groupNode.position_x).toBe(150) // (100+200)/2
|
|
201
|
+
expect(plan.groupNode.position_y).toBe(50) // (0+100)/2
|
|
202
|
+
|
|
203
|
+
// 親 nodes:A, D, groupNode(B, C は消える)
|
|
204
|
+
expect(plan.parentNodes.map((n) => n.id).sort()).toEqual(
|
|
205
|
+
[a.id, d.id, plan.groupNode.id].sort(),
|
|
206
|
+
)
|
|
207
|
+
// 親 connections:A.main → group.in/group.main → D.in の2本だけ
|
|
208
|
+
expect(plan.parentConnections).toHaveLength(2)
|
|
209
|
+
expect(plan.parentConnections).toContainEqual({
|
|
210
|
+
from_port_id: outOf(a).id,
|
|
211
|
+
to_port_id: inOf(plan.groupNode).id,
|
|
212
|
+
})
|
|
213
|
+
expect(plan.parentConnections).toContainEqual({
|
|
214
|
+
from_port_id: outOf(plan.groupNode).id,
|
|
215
|
+
to_port_id: inOf(d).id,
|
|
216
|
+
})
|
|
217
|
+
void b
|
|
218
|
+
void c
|
|
219
|
+
})
|
|
220
|
+
})
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import type { GraphConnection, GraphNode } from "./graph.js"
|
|
2
|
+
|
|
3
|
+
// 矩形選択からのグループ化・コンポーネント化(ノードのグループ化・コンポーネント化.md)。
|
|
4
|
+
// 選択境界を分類・検証し(analyze)、「子(部品)loop の graph」と「親の置換後グラフ」を
|
|
5
|
+
// 生成する(build)。純関数(id 採番は newId で注入)。エディタとテストの両方から使う。
|
|
6
|
+
//
|
|
7
|
+
// V1 制約:component の口は in 1つ・out main 1つなので、
|
|
8
|
+
// 入口(外→選択内の線の到達先ノード)は1つ・出口(選択内→外の線の出発ポート)は1つまで。
|
|
9
|
+
|
|
10
|
+
export type ExtractAnalysis =
|
|
11
|
+
| { ok: false; reason: string }
|
|
12
|
+
| {
|
|
13
|
+
ok: true
|
|
14
|
+
// 外部からの入力を受けるノード(入出力の無い孤立した島は null)
|
|
15
|
+
entryNodeId: string | null
|
|
16
|
+
// 外部への出力の出発ポート(同一ポートからの fan-out は1出口扱い)
|
|
17
|
+
exitPortId: string | null
|
|
18
|
+
internal: GraphConnection[]
|
|
19
|
+
inbound: GraphConnection[]
|
|
20
|
+
outbound: GraphConnection[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const analyzeExtractSelection = (
|
|
24
|
+
nodes: GraphNode[],
|
|
25
|
+
connections: GraphConnection[],
|
|
26
|
+
selectedIds: readonly string[],
|
|
27
|
+
): ExtractAnalysis => {
|
|
28
|
+
const selected = new Set(selectedIds)
|
|
29
|
+
if (selected.size < 2) {
|
|
30
|
+
return { ok: false, reason: "2つ以上のノードを選択してください" }
|
|
31
|
+
}
|
|
32
|
+
const selectedNodes = nodes.filter((n) => selected.has(n.id))
|
|
33
|
+
const flow = selectedNodes.find((n) => n.kind === "start" || n.kind === "end")
|
|
34
|
+
if (flow) {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
reason: "開始・終了ノードはグループ化できません",
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const nodeIdByPortId = new Map(
|
|
42
|
+
nodes.flatMap((n) => n.ports.map((p) => [p.id, n.id] as const)),
|
|
43
|
+
)
|
|
44
|
+
const internal: GraphConnection[] = []
|
|
45
|
+
const inbound: GraphConnection[] = []
|
|
46
|
+
const outbound: GraphConnection[] = []
|
|
47
|
+
for (const conn of connections) {
|
|
48
|
+
const fromIn = selected.has(nodeIdByPortId.get(conn.from_port_id) ?? "")
|
|
49
|
+
const toIn = selected.has(nodeIdByPortId.get(conn.to_port_id) ?? "")
|
|
50
|
+
if (fromIn && toIn) internal.push(conn)
|
|
51
|
+
else if (!fromIn && toIn) inbound.push(conn)
|
|
52
|
+
else if (fromIn && !toIn) outbound.push(conn)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const entryNodeIds = new Set(
|
|
56
|
+
inbound.map((c) => nodeIdByPortId.get(c.to_port_id) ?? ""),
|
|
57
|
+
)
|
|
58
|
+
if (entryNodeIds.size > 1) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
reason:
|
|
62
|
+
"外部からの入力が複数のノードに入っています(入口は1つにしてください)",
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const exitPortIds = new Set(outbound.map((c) => c.from_port_id))
|
|
66
|
+
if (exitPortIds.size > 1) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
reason:
|
|
70
|
+
"外部への出力が複数のポートから出ています(出口は1つにしてください)",
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
ok: true,
|
|
76
|
+
entryNodeId: [...entryNodeIds][0] ?? null,
|
|
77
|
+
exitPortId: [...exitPortIds][0] ?? null,
|
|
78
|
+
internal,
|
|
79
|
+
inbound,
|
|
80
|
+
outbound,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type ExtractPlan = {
|
|
85
|
+
// 子 loop に入るノード(新 id でコピー。seed 済み start/end は含まない=呼び出し側が足す)
|
|
86
|
+
childNodes: GraphNode[]
|
|
87
|
+
// 内部接続(新 id)+ start.main → 入口/出口 → end.in
|
|
88
|
+
childConnections: GraphConnection[]
|
|
89
|
+
// 親に置く GROUP / COMPONENT ノード(in/main・参照=componentLoopId・選択の重心)
|
|
90
|
+
groupNode: GraphNode
|
|
91
|
+
// 置換後の親グラフ(選択ノードを除き groupNode を足した全量。線は繋ぎ直し済み)
|
|
92
|
+
parentNodes: GraphNode[]
|
|
93
|
+
parentConnections: GraphConnection[]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const buildExtractPlan = (params: {
|
|
97
|
+
nodes: GraphNode[]
|
|
98
|
+
connections: GraphConnection[]
|
|
99
|
+
selectedIds: string[]
|
|
100
|
+
name: string
|
|
101
|
+
componentLoopId: string
|
|
102
|
+
// 子 loop(作成済み・seed 済み)の構造ノードのポート
|
|
103
|
+
childStartOutPortId: string
|
|
104
|
+
childEndInPortId: string
|
|
105
|
+
newId: () => string
|
|
106
|
+
}): { ok: false; reason: string } | ({ ok: true } & ExtractPlan) => {
|
|
107
|
+
const {
|
|
108
|
+
nodes,
|
|
109
|
+
connections,
|
|
110
|
+
selectedIds,
|
|
111
|
+
name,
|
|
112
|
+
componentLoopId,
|
|
113
|
+
childStartOutPortId,
|
|
114
|
+
childEndInPortId,
|
|
115
|
+
newId,
|
|
116
|
+
} = params
|
|
117
|
+
const analysis = analyzeExtractSelection(nodes, connections, selectedIds)
|
|
118
|
+
if (!analysis.ok) return analysis
|
|
119
|
+
|
|
120
|
+
const selected = new Set(selectedIds)
|
|
121
|
+
// ノード・ポートとも新 id を採番してコピー(kind 別設定・hooks は共有参照で問題ない
|
|
122
|
+
// =この後 JSON 化して保存 API に渡すだけで、エディタ状態には子側を持たない)
|
|
123
|
+
const nodeIdMap = new Map<string, string>()
|
|
124
|
+
const portIdMap = new Map<string, string>()
|
|
125
|
+
const childNodes = nodes
|
|
126
|
+
.filter((n) => selected.has(n.id))
|
|
127
|
+
.map((n) => {
|
|
128
|
+
const id = newId()
|
|
129
|
+
nodeIdMap.set(n.id, id)
|
|
130
|
+
return {
|
|
131
|
+
...n,
|
|
132
|
+
id,
|
|
133
|
+
ports: n.ports.map((p) => {
|
|
134
|
+
const portId = newId()
|
|
135
|
+
portIdMap.set(p.id, portId)
|
|
136
|
+
return { ...p, id: portId }
|
|
137
|
+
}),
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const mapConn = (conn: GraphConnection): GraphConnection => ({
|
|
142
|
+
from_port_id: portIdMap.get(conn.from_port_id) ?? conn.from_port_id,
|
|
143
|
+
to_port_id: portIdMap.get(conn.to_port_id) ?? conn.to_port_id,
|
|
144
|
+
})
|
|
145
|
+
const childConnections = analysis.internal.map(mapConn)
|
|
146
|
+
// 境界の配線:start.main → 入口ノードの in/出口ポート → end.in
|
|
147
|
+
if (analysis.entryNodeId !== null) {
|
|
148
|
+
const entry = childNodes.find(
|
|
149
|
+
(n) => n.id === nodeIdMap.get(analysis.entryNodeId ?? ""),
|
|
150
|
+
)
|
|
151
|
+
const entryIn = entry?.ports.find((p) => p.io === "in")
|
|
152
|
+
if (entryIn) {
|
|
153
|
+
childConnections.push({
|
|
154
|
+
from_port_id: childStartOutPortId,
|
|
155
|
+
to_port_id: entryIn.id,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (analysis.exitPortId !== null) {
|
|
160
|
+
childConnections.push({
|
|
161
|
+
from_port_id: portIdMap.get(analysis.exitPortId) ?? analysis.exitPortId,
|
|
162
|
+
to_port_id: childEndInPortId,
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 親に置くノード(位置は選択の重心。ポートスキーマは保存時にサーバが契約から焼き込む)
|
|
167
|
+
const selectedNodes = nodes.filter((n) => selected.has(n.id))
|
|
168
|
+
const centroid = {
|
|
169
|
+
x:
|
|
170
|
+
selectedNodes.reduce((sum, n) => sum + n.position_x, 0) /
|
|
171
|
+
selectedNodes.length,
|
|
172
|
+
y:
|
|
173
|
+
selectedNodes.reduce((sum, n) => sum + n.position_y, 0) /
|
|
174
|
+
selectedNodes.length,
|
|
175
|
+
}
|
|
176
|
+
const groupNode: GraphNode = {
|
|
177
|
+
id: newId(),
|
|
178
|
+
kind: "component",
|
|
179
|
+
name,
|
|
180
|
+
ports: [
|
|
181
|
+
{ id: newId(), io: "in", key: "in", name: "入力", schema: {} },
|
|
182
|
+
{ id: newId(), io: "out", key: "main", name: "出力", schema: {} },
|
|
183
|
+
],
|
|
184
|
+
hooks: [],
|
|
185
|
+
position_x: centroid.x,
|
|
186
|
+
position_y: centroid.y,
|
|
187
|
+
component: { component_loop_id: componentLoopId },
|
|
188
|
+
}
|
|
189
|
+
const groupIn = groupNode.ports.find((p) => p.io === "in")
|
|
190
|
+
const groupMain = groupNode.ports.find((p) => p.io === "out")
|
|
191
|
+
if (!groupIn || !groupMain) {
|
|
192
|
+
return { ok: false, reason: "groupNode のポート生成に失敗しました" }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 親の置換:選択関連の線(内部・境界)を除き、境界を group の in/main へ繋ぎ直す
|
|
196
|
+
const removed = new Set(
|
|
197
|
+
[...analysis.internal, ...analysis.inbound, ...analysis.outbound].map(
|
|
198
|
+
(c) => `${c.from_port_id}→${c.to_port_id}`,
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
const parentConnections = [
|
|
202
|
+
...connections.filter(
|
|
203
|
+
(c) => !removed.has(`${c.from_port_id}→${c.to_port_id}`),
|
|
204
|
+
),
|
|
205
|
+
...analysis.inbound.map((c) => ({
|
|
206
|
+
from_port_id: c.from_port_id,
|
|
207
|
+
to_port_id: groupIn.id,
|
|
208
|
+
})),
|
|
209
|
+
...analysis.outbound.map((c) => ({
|
|
210
|
+
from_port_id: groupMain.id,
|
|
211
|
+
to_port_id: c.to_port_id,
|
|
212
|
+
})),
|
|
213
|
+
]
|
|
214
|
+
const parentNodes = [...nodes.filter((n) => !selected.has(n.id)), groupNode]
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
ok: true,
|
|
218
|
+
childNodes,
|
|
219
|
+
childConnections,
|
|
220
|
+
groupNode,
|
|
221
|
+
parentNodes,
|
|
222
|
+
parentConnections,
|
|
223
|
+
}
|
|
224
|
+
}
|