@mawaru/sdk 0.38.0 → 0.40.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 (91) hide show
  1. package/_schemas/ai-manifest.test.ts +8 -0
  2. package/_schemas/ai-manifest.ts +2 -0
  3. package/_schemas/graph.test.ts +51 -9
  4. package/_schemas/graph.ts +23 -8
  5. package/_schemas/human-manifest.test.ts +129 -0
  6. package/_schemas/human-manifest.ts +158 -0
  7. package/_schemas/node.ts +4 -0
  8. package/_schemas/port-spec.test.ts +164 -40
  9. package/_schemas/port-spec.ts +77 -26
  10. package/dist/_cli/login.js +1 -15
  11. package/dist/_cli/openBrowser.d.ts +1 -0
  12. package/dist/_cli/openBrowser.js +15 -0
  13. package/dist/_cli/previewApi.d.ts +1 -1
  14. package/dist/_cli/previewCommand.d.ts +8 -0
  15. package/dist/_cli/previewCommand.js +95 -37
  16. package/dist/_cli/previewFiles.d.ts +2 -1
  17. package/dist/_cli/previewFiles.js +42 -19
  18. package/dist/_cli/previewSlack.d.ts +1 -1
  19. package/dist/_cli/previewSlack.js +1 -1
  20. package/dist/_schemas/ai-manifest.d.ts +3 -0
  21. package/dist/_schemas/ai-manifest.js +2 -0
  22. package/dist/_schemas/graph.d.ts +23 -3
  23. package/dist/_schemas/graph.js +20 -8
  24. package/dist/_schemas/human-manifest.d.ts +27 -0
  25. package/dist/_schemas/human-manifest.js +121 -0
  26. package/dist/_schemas/node.d.ts +1 -0
  27. package/dist/_schemas/node.js +4 -0
  28. package/dist/_schemas/port-spec.d.ts +8 -9
  29. package/dist/_schemas/port-spec.js +64 -9
  30. package/dist/cli.js +3 -2
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.js +1 -0
  33. package/dist/init.js +22 -3
  34. package/dist/typegen.d.ts +3 -1
  35. package/dist/typegen.js +96 -14
  36. package/dist/validate.js +55 -0
  37. package/dist/views/_approval/ApprovalBar.d.ts +20 -0
  38. package/dist/views/_approval/ApprovalBar.js +74 -0
  39. package/dist/views/_approvalActions.d.ts +18 -0
  40. package/dist/views/_approvalActions.js +10 -0
  41. package/dist/views/_bridge.d.ts +12 -0
  42. package/dist/views/_bridge.js +10 -0
  43. package/dist/views/_parts/DocumentCard.d.ts +2 -0
  44. package/dist/views/_parts/DocumentCard.js +16 -0
  45. package/dist/views/_parts/FilePreview.d.ts +5 -0
  46. package/dist/views/_parts/FilePreview.js +101 -0
  47. package/dist/views/_parts/Linkify.d.ts +7 -0
  48. package/dist/views/_parts/Linkify.js +28 -0
  49. package/dist/views/_parts/Markdown.d.ts +44 -0
  50. package/dist/views/_parts/Markdown.js +313 -0
  51. package/dist/views/_parts/SchemaForm.d.ts +19 -0
  52. package/dist/views/_parts/SchemaForm.js +310 -0
  53. package/dist/views/_parts/fileRef.d.ts +7 -0
  54. package/dist/views/_parts/fileRef.js +11 -0
  55. package/dist/views/_parts/useNarrow.d.ts +5 -0
  56. package/dist/views/_parts/useNarrow.js +23 -0
  57. package/dist/views/host.d.ts +18 -0
  58. package/dist/views/host.js +20 -0
  59. package/dist/views/human.d.ts +73 -0
  60. package/dist/views/human.js +6 -0
  61. package/dist/views/index.d.ts +8 -0
  62. package/dist/views/index.js +12 -0
  63. package/dist/views/protocol.d.ts +34 -0
  64. package/dist/views/protocol.js +5 -0
  65. package/dist/views/runtime.d.ts +3 -0
  66. package/dist/views/runtime.js +86 -0
  67. package/dist/{slack-view.d.ts → views/slack.d.ts} +1 -1
  68. package/docs/development.md +45 -5
  69. package/index.ts +1 -0
  70. package/package.json +36 -4
  71. package/skills/create-loop/SKILL.md +6 -3
  72. package/views/_approval/ApprovalBar.tsx +239 -0
  73. package/views/_approvalActions.tsx +31 -0
  74. package/views/_bridge.tsx +36 -0
  75. package/views/_parts/DocumentCard.ts +18 -0
  76. package/views/_parts/FilePreview.tsx +180 -0
  77. package/views/_parts/Linkify.test.ts +40 -0
  78. package/views/_parts/Linkify.tsx +40 -0
  79. package/views/_parts/Markdown.test.ts +131 -0
  80. package/views/_parts/Markdown.tsx +472 -0
  81. package/views/_parts/SchemaForm.tsx +742 -0
  82. package/views/_parts/fileRef.ts +21 -0
  83. package/views/_parts/useNarrow.ts +24 -0
  84. package/views/host.ts +23 -0
  85. package/views/human.ts +91 -0
  86. package/views/index.ts +16 -0
  87. package/views/protocol.ts +40 -0
  88. package/views/runtime.tsx +132 -0
  89. package/views/slack.test.ts +40 -0
  90. package/{slack-view.ts → views/slack.ts} +3 -3
  91. /package/dist/{slack-view.js → views/slack.js} +0 -0
@@ -0,0 +1,742 @@
1
+ import {
2
+ type CSSProperties,
3
+ type ReactNode,
4
+ useEffect,
5
+ useRef,
6
+ useState,
7
+ } from "react"
8
+ import { useViewBridge } from "../_bridge.js"
9
+ import { type FileRef, isFileRef } from "./fileRef.js"
10
+ import { isLongText, Markdown, useAutoGrow } from "./Markdown.js"
11
+
12
+ // JSON Schema(ポートの schema)を解釈して入力フォームを描画する(手動実行の入力データ用・
13
+ // 承認画面の書類)。object の各 property を string / number / integer / boolean / enum / file /
14
+ // ネスト object のフィールドとして描画し、array や未知の型はフィールド単位の
15
+ // JSON 入力にフォールバックする。
16
+ // 値の入った長文 string は「読み物」として扱う:既定は Markdown の整形表示で、
17
+ // 「編集」を押した時だけ raw の textarea に入れ替わる(生ソースを常時見せない)。
18
+ // 長文が複数あるときは先頭(=主役の成果物)だけ開き、以降は畳んで出す。
19
+
20
+ export type JsonSchema = {
21
+ type?: string
22
+ title?: string
23
+ description?: string
24
+ properties?: Record<string, JsonSchema>
25
+ required?: string[]
26
+ enum?: unknown[]
27
+ items?: JsonSchema
28
+ // file 型のマーカー(port-spec.ts の fileSchema)
29
+ "x-mawaru-type"?: string
30
+ }
31
+
32
+ const isFileSchema = (schema: JsonSchema | undefined): boolean =>
33
+ schema?.["x-mawaru-type"] === "file"
34
+
35
+ /** フォーム描画できる schema(properties を持つ object)なら型を付けて返す */
36
+ export const asFormSchema = (schema: unknown): JsonSchema | null => {
37
+ if (schema == null || typeof schema !== "object") return null
38
+ const s = schema as JsonSchema
39
+ if (s.type !== "object") return null
40
+ if (!s.properties || Object.keys(s.properties).length === 0) return null
41
+ return s
42
+ }
43
+
44
+ const nameStyle: CSSProperties = {
45
+ fontFamily: "var(--font-mono)",
46
+ fontSize: "var(--text-sm)",
47
+ fontWeight: "var(--weight-medium)" as CSSProperties["fontWeight"],
48
+ color: "var(--ink)",
49
+ whiteSpace: "nowrap",
50
+ }
51
+
52
+ // 見出し行の右に置く小さな操作(「編集」「表示に戻す」)
53
+ const headActionStyle: CSSProperties = {
54
+ minHeight: "22px",
55
+ padding: "0 var(--space-2)",
56
+ fontSize: "var(--text-xs)",
57
+ fontWeight: "var(--weight-normal)" as CSSProperties["fontWeight"],
58
+ flex: "none",
59
+ }
60
+
61
+ const FieldHead = ({
62
+ name,
63
+ schema,
64
+ required,
65
+ marker,
66
+ action,
67
+ }: {
68
+ name: string
69
+ schema: JsonSchema
70
+ required: boolean
71
+ /** 開閉の三角(畳める見出しのときだけ) */
72
+ marker?: ReactNode
73
+ /** 見出し右の操作(長文フィールドの「編集」など) */
74
+ action?: ReactNode
75
+ }) => (
76
+ <span
77
+ style={{
78
+ display: "flex",
79
+ // boolean ではチェックボックスと横並びの flex アイテムになる。残り幅に広げて説明を出す
80
+ flex: 1,
81
+ minWidth: 0,
82
+ alignItems: "baseline",
83
+ gap: "var(--space-2)",
84
+ fontSize: "var(--text-xs)",
85
+ color: "var(--muted)",
86
+ }}
87
+ >
88
+ {marker}
89
+ <span style={nameStyle}>{name}</span>
90
+ {required && (
91
+ <span style={{ color: "var(--accent)", flex: "none" }}>*</span>
92
+ )}
93
+ {/* 説明は残り幅に収めて省略する。width: 0 で最小幅に数えない(数えると長い説明が
94
+ フォーム全体を押し広げ、サイドパネルからはみ出す) */}
95
+ <span
96
+ style={{
97
+ flex: 1,
98
+ width: 0,
99
+ overflow: "hidden",
100
+ textOverflow: "ellipsis",
101
+ whiteSpace: "nowrap",
102
+ }}
103
+ >
104
+ {schema.description}
105
+ </span>
106
+ {action}
107
+ </span>
108
+ )
109
+
110
+ // array・未知の型のフォールバック:フィールド単位の JSON 入力。
111
+ // 入力途中の不正 JSON は text に留め、valid になった時点で親へ反映する。
112
+ const JsonField = ({
113
+ value,
114
+ onChange,
115
+ }: {
116
+ value: unknown
117
+ onChange: (v: unknown) => void
118
+ }) => {
119
+ const [text, setText] = useState(() => JSON.stringify(value ?? null, null, 2))
120
+ const [invalid, setInvalid] = useState(false)
121
+ const ref = useAutoGrow(text)
122
+ return (
123
+ <div style={{ display: "grid", gap: "2px" }}>
124
+ <textarea
125
+ ref={ref}
126
+ rows={3}
127
+ value={text}
128
+ onChange={(e) => {
129
+ setText(e.target.value)
130
+ try {
131
+ onChange(JSON.parse(e.target.value))
132
+ setInvalid(false)
133
+ } catch {
134
+ setInvalid(true)
135
+ }
136
+ }}
137
+ style={{
138
+ fontFamily: "var(--font-mono)",
139
+ fontSize: "var(--text-xs)",
140
+ lineHeight: 1.6,
141
+ maxHeight: "60vh",
142
+ resize: "vertical",
143
+ }}
144
+ />
145
+ {invalid && (
146
+ <p role="alert" style={{ fontSize: "var(--text-xs)" }}>
147
+ JSON として不正です
148
+ </p>
149
+ )}
150
+ </div>
151
+ )
152
+ }
153
+
154
+ // ---- file 型(x-mawaru-type: "file")の添付フィールド ----
155
+ // 選択と同時に backend(POST /tenants/:id/files)へアップロードし、フォーム値には
156
+ // path 参照(fileRef)を入れる。実体は private バケットに置かれ、実行時に署名 URL が発行される
157
+
158
+ const fileRefOf = (value: unknown): FileRef | null =>
159
+ isFileRef(value) ? value : null
160
+
161
+ const formatSize = (size?: number): string | null => {
162
+ if (size == null) return null
163
+ if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)}MB`
164
+ return `${Math.max(1, Math.round(size / 1024))}KB`
165
+ }
166
+
167
+ const useFileUpload = () => {
168
+ const bridge = useViewBridge()
169
+ const [uploading, setUploading] = useState(false)
170
+ const [error, setError] = useState<string | null>(null)
171
+ const upload = async (list: FileList): Promise<FileRef[]> => {
172
+ setUploading(true)
173
+ setError(null)
174
+ try {
175
+ const refs: FileRef[] = []
176
+ for (const file of Array.from(list)) {
177
+ refs.push(await bridge.uploadFile(file))
178
+ }
179
+ return refs
180
+ } catch (err) {
181
+ setError(err instanceof Error ? err.message : String(err))
182
+ return []
183
+ } finally {
184
+ setUploading(false)
185
+ }
186
+ }
187
+ return { upload, uploading, error }
188
+ }
189
+
190
+ const FileChip = ({
191
+ file,
192
+ onRemove,
193
+ }: {
194
+ file: FileRef
195
+ onRemove: () => void
196
+ }) => (
197
+ <span
198
+ style={{
199
+ display: "inline-flex",
200
+ alignItems: "center",
201
+ gap: "var(--space-1)",
202
+ maxWidth: "100%",
203
+ border: "var(--border-w) solid var(--border)",
204
+ borderRadius: "var(--radius-s)",
205
+ background: "var(--surface-2)",
206
+ padding: "2px var(--space-2)",
207
+ fontSize: "var(--text-xs)",
208
+ }}
209
+ >
210
+ <span aria-hidden="true">📎</span>
211
+ <span
212
+ style={{
213
+ minWidth: 0,
214
+ overflow: "hidden",
215
+ textOverflow: "ellipsis",
216
+ whiteSpace: "nowrap",
217
+ }}
218
+ >
219
+ {file.name}
220
+ </span>
221
+ {formatSize(file.size) && (
222
+ <span style={{ color: "var(--muted)", flex: "none" }}>
223
+ {formatSize(file.size)}
224
+ </span>
225
+ )}
226
+ <button
227
+ type="button"
228
+ className="ghost"
229
+ onClick={onRemove}
230
+ aria-label={`${file.name} を外す`}
231
+ style={{
232
+ minHeight: "18px",
233
+ padding: "0 4px",
234
+ fontSize: "var(--text-xs)",
235
+ }}
236
+ >
237
+ ✕
238
+ </button>
239
+ </span>
240
+ )
241
+
242
+ // 破線のドロップゾーン兼選択ボタン。クリックで隠し input[type=file] を開き、
243
+ // ドラッグ&ドロップも受け付ける(アップロード中は無効化)
244
+ const FilePicker = ({
245
+ label,
246
+ multiple,
247
+ uploading,
248
+ onPick,
249
+ }: {
250
+ label: string
251
+ multiple?: boolean
252
+ uploading: boolean
253
+ onPick: (list: FileList) => void
254
+ }) => {
255
+ const inputRef = useRef<HTMLInputElement>(null)
256
+ // dragenter/leave は子要素をまたぐたびに発火するため深さを数えて判定する
257
+ const depthRef = useRef(0)
258
+ const [dragOver, setDragOver] = useState(false)
259
+ const hasFiles = (e: React.DragEvent) =>
260
+ Array.from(e.dataTransfer.types).includes("Files")
261
+ return (
262
+ <>
263
+ <input
264
+ ref={inputRef}
265
+ type="file"
266
+ multiple={multiple}
267
+ style={{ display: "none" }}
268
+ onChange={(e) => {
269
+ if (e.target.files && e.target.files.length > 0) {
270
+ onPick(e.target.files)
271
+ }
272
+ e.target.value = ""
273
+ }}
274
+ />
275
+ <button
276
+ type="button"
277
+ onClick={() => inputRef.current?.click()}
278
+ disabled={uploading}
279
+ onDragEnter={(e) => {
280
+ if (uploading || !hasFiles(e)) return
281
+ e.preventDefault()
282
+ depthRef.current += 1
283
+ setDragOver(true)
284
+ }}
285
+ onDragOver={(e) => {
286
+ if (uploading || !hasFiles(e)) return
287
+ e.preventDefault()
288
+ }}
289
+ onDragLeave={() => {
290
+ if (depthRef.current > 0) depthRef.current -= 1
291
+ if (depthRef.current === 0) setDragOver(false)
292
+ }}
293
+ onDrop={(e) => {
294
+ e.preventDefault()
295
+ depthRef.current = 0
296
+ setDragOver(false)
297
+ if (uploading) return
298
+ if (e.dataTransfer.files.length > 0) onPick(e.dataTransfer.files)
299
+ }}
300
+ style={{
301
+ display: "flex",
302
+ alignItems: "center",
303
+ justifyContent: "center",
304
+ gap: "var(--space-1)",
305
+ width: "100%",
306
+ minHeight: "40px",
307
+ padding: "var(--space-2)",
308
+ border: `var(--border-w) dashed ${dragOver ? "var(--accent)" : "var(--border)"}`,
309
+ borderRadius: "var(--radius-s)",
310
+ background: dragOver ? "var(--surface-2)" : "transparent",
311
+ color: dragOver ? "var(--accent)" : "var(--muted)",
312
+ fontSize: "var(--text-xs)",
313
+ }}
314
+ >
315
+ <span aria-hidden="true">📎</span>
316
+ {uploading ? "アップロード中…" : `${label}、またはドラッグ&ドロップ`}
317
+ </button>
318
+ </>
319
+ )
320
+ }
321
+
322
+ // file 単体:未選択(null)⇄ 選択済みチップ
323
+ const FileField = ({
324
+ value,
325
+ onChange,
326
+ }: {
327
+ value: unknown
328
+ onChange: (v: unknown) => void
329
+ }) => {
330
+ const { upload, uploading, error } = useFileUpload()
331
+ const file = fileRefOf(value)
332
+ const pick = async (list: FileList) => {
333
+ const refs = await upload(list)
334
+ if (refs[0]) onChange(refs[0])
335
+ }
336
+ return (
337
+ <div style={{ display: "grid", gap: "var(--space-1)" }}>
338
+ {file ? (
339
+ <FileChip file={file} onRemove={() => onChange(null)} />
340
+ ) : (
341
+ <FilePicker
342
+ label="ファイルを選択"
343
+ uploading={uploading}
344
+ onPick={pick}
345
+ />
346
+ )}
347
+ {error && (
348
+ <p role="alert" style={{ margin: 0, fontSize: "var(--text-xs)" }}>
349
+ {error}
350
+ </p>
351
+ )}
352
+ </div>
353
+ )
354
+ }
355
+
356
+ // file 配列:チップのリスト+追加(複数選択可)
357
+ const FileArrayField = ({
358
+ value,
359
+ onChange,
360
+ }: {
361
+ value: unknown
362
+ onChange: (v: unknown) => void
363
+ }) => {
364
+ const { upload, uploading, error } = useFileUpload()
365
+ const files = Array.isArray(value)
366
+ ? value.map(fileRefOf).filter((f): f is FileRef => f !== null)
367
+ : []
368
+ const pick = async (list: FileList) => {
369
+ const refs = await upload(list)
370
+ if (refs.length > 0) onChange([...files, ...refs])
371
+ }
372
+ return (
373
+ <div style={{ display: "grid", gap: "var(--space-1)" }}>
374
+ {files.map((file, i) => (
375
+ <FileChip
376
+ key={file.path}
377
+ file={file}
378
+ onRemove={() => onChange(files.filter((_, j) => j !== i))}
379
+ />
380
+ ))}
381
+ <FilePicker
382
+ label="ファイルを追加"
383
+ multiple
384
+ uploading={uploading}
385
+ onPick={pick}
386
+ />
387
+ {error && (
388
+ <p role="alert" style={{ margin: 0, fontSize: "var(--text-xs)" }}>
389
+ {error}
390
+ </p>
391
+ )}
392
+ </div>
393
+ )
394
+ }
395
+
396
+ // 長文の raw 編集欄:内容の高さまで伸び、上限(60vh)を超えたらその中でスクロールする
397
+ const LongTextArea = ({
398
+ value,
399
+ onChange,
400
+ autoFocus,
401
+ }: {
402
+ value: string
403
+ onChange: (v: string) => void
404
+ autoFocus?: boolean
405
+ }) => {
406
+ const ref = useAutoGrow(value)
407
+ return (
408
+ <textarea
409
+ ref={ref}
410
+ // biome-ignore lint/a11y/noAutofocus: 「編集」を押して現れる欄なので即入力できる方が自然
411
+ autoFocus={autoFocus}
412
+ rows={3}
413
+ value={value}
414
+ onChange={(e) => onChange(e.target.value)}
415
+ style={{
416
+ fontSize: "var(--text-base)",
417
+ lineHeight: 1.9,
418
+ maxHeight: "60vh",
419
+ resize: "vertical",
420
+ }}
421
+ />
422
+ )
423
+ }
424
+
425
+ // string フィールド:長文・改行入りの値は textarea に昇格する(AI の下書き等)。
426
+ // 入力中に input ⇄ textarea が flip してフォーカスが飛ばないよう、昇格したら戻さない
427
+ const StringInput = ({
428
+ value,
429
+ onChange,
430
+ }: {
431
+ value: string
432
+ onChange: (v: string) => void
433
+ }) => {
434
+ const long = isLongText(value)
435
+ const [multiline, setMultiline] = useState(long)
436
+ useEffect(() => {
437
+ if (long) setMultiline(true)
438
+ }, [long])
439
+ return multiline ? (
440
+ <LongTextArea value={value} onChange={onChange} />
441
+ ) : (
442
+ <input
443
+ type="text"
444
+ value={value}
445
+ onChange={(e) => onChange(e.target.value)}
446
+ />
447
+ )
448
+ }
449
+
450
+ // 値の入った長文 string=読み物。既定は Markdown の整形表示で、「編集」を押した時だけ
451
+ // raw に入れ替わる。長文が複数あるときの2つ目以降は畳んだ状態で始まる(見出しだけ出す)
452
+ const LongTextField = ({
453
+ name,
454
+ schema,
455
+ required,
456
+ value,
457
+ editable,
458
+ defaultCollapsed,
459
+ onChange,
460
+ }: {
461
+ name: string
462
+ schema: JsonSchema
463
+ required: boolean
464
+ value: string
465
+ editable: boolean
466
+ defaultCollapsed: boolean
467
+ onChange: (v: string) => void
468
+ }) => {
469
+ // 開閉は <details>:読み取り専用の局面で書類全体を <fieldset disabled> で包んでも
470
+ // (フォーム部品ではないので)畳んだ本文を開いて読める
471
+ const [open, setOpen] = useState(!defaultCollapsed)
472
+ const [editing, setEditing] = useState(false)
473
+ const lines = value.split("\n").length
474
+
475
+ return (
476
+ // details 自身には display を当てない(当てると閉じた中身が出てしまうブラウザがある)
477
+ <details open={open} onToggle={(e) => setOpen(e.currentTarget.open)}>
478
+ <summary style={{ cursor: "pointer", listStyle: "none" }}>
479
+ <FieldHead
480
+ name={name}
481
+ schema={schema}
482
+ required={required}
483
+ marker={
484
+ <span aria-hidden="true" style={{ flex: "none" }}>
485
+ {open ? "▾" : "▸"}
486
+ </span>
487
+ }
488
+ action={
489
+ open ? (
490
+ editable ? (
491
+ <button
492
+ type="button"
493
+ className="ghost"
494
+ // summary の中なので、押しても開閉させない
495
+ onClick={(e) => {
496
+ e.preventDefault()
497
+ setEditing((current) => !current)
498
+ }}
499
+ style={headActionStyle}
500
+ >
501
+ {editing ? "表示に戻す" : "編集"}
502
+ </button>
503
+ ) : null
504
+ ) : (
505
+ <span
506
+ style={{
507
+ flex: "none",
508
+ fontVariantNumeric:
509
+ "var(--numeric)" as CSSProperties["fontVariantNumeric"],
510
+ }}
511
+ >
512
+ {lines}行
513
+ </span>
514
+ )
515
+ }
516
+ />
517
+ </summary>
518
+ <div
519
+ style={{
520
+ display: "grid",
521
+ marginTop: "var(--space-3)",
522
+ borderTop: "var(--border-w) solid var(--border)",
523
+ paddingTop: "var(--space-3)",
524
+ }}
525
+ >
526
+ {editing ? (
527
+ <LongTextArea value={value} onChange={onChange} autoFocus />
528
+ ) : (
529
+ <Markdown text={value} />
530
+ )}
531
+ </div>
532
+ </details>
533
+ )
534
+ }
535
+
536
+ const Field = ({
537
+ name,
538
+ schema,
539
+ required,
540
+ value,
541
+ readOnly,
542
+ asDocument,
543
+ defaultCollapsed,
544
+ onChange,
545
+ }: {
546
+ name: string
547
+ schema: JsonSchema
548
+ required: boolean
549
+ value: unknown
550
+ readOnly: boolean
551
+ /** 読み物(Markdown 表示)として描くか。開いた時点の値で決まる */
552
+ asDocument: boolean
553
+ /** 読み物を畳んだ状態で始めるか(先頭の読み物だけ開く) */
554
+ defaultCollapsed: boolean
555
+ onChange: (v: unknown) => void
556
+ }) => {
557
+ const head = <FieldHead name={name} schema={schema} required={required} />
558
+
559
+ // file 型は enum / type 判定より先に見る(実体は object / array なので)
560
+ if (isFileSchema(schema)) {
561
+ return (
562
+ <div style={{ display: "grid", gap: "var(--space-1)" }}>
563
+ {head}
564
+ <FileField value={value} onChange={onChange} />
565
+ </div>
566
+ )
567
+ }
568
+ if (schema.type === "array" && isFileSchema(schema.items)) {
569
+ return (
570
+ <div style={{ display: "grid", gap: "var(--space-1)" }}>
571
+ {head}
572
+ <FileArrayField value={value} onChange={onChange} />
573
+ </div>
574
+ )
575
+ }
576
+
577
+ const options = schema.enum
578
+
579
+ if (options && options.length > 0) {
580
+ const idx = options.findIndex(
581
+ (opt) => JSON.stringify(opt) === JSON.stringify(value),
582
+ )
583
+ return (
584
+ <label>
585
+ {head}
586
+ <select
587
+ value={String(idx)}
588
+ onChange={(e) => onChange(options[Number(e.target.value)])}
589
+ >
590
+ {idx < 0 && (
591
+ <option value="-1" disabled>
592
+ 選択してください
593
+ </option>
594
+ )}
595
+ {options.map((opt, i) => (
596
+ <option key={JSON.stringify(opt)} value={String(i)}>
597
+ {typeof opt === "string" ? opt : JSON.stringify(opt)}
598
+ </option>
599
+ ))}
600
+ </select>
601
+ </label>
602
+ )
603
+ }
604
+
605
+ switch (schema.type) {
606
+ case "string": {
607
+ const text = typeof value === "string" ? value : ""
608
+ if (asDocument) {
609
+ return (
610
+ <LongTextField
611
+ name={name}
612
+ schema={schema}
613
+ required={required}
614
+ value={text}
615
+ editable={!readOnly}
616
+ defaultCollapsed={defaultCollapsed}
617
+ onChange={onChange}
618
+ />
619
+ )
620
+ }
621
+ return (
622
+ <div style={{ display: "grid", gap: "var(--space-1)" }}>
623
+ {head}
624
+ <StringInput value={text} onChange={onChange} />
625
+ </div>
626
+ )
627
+ }
628
+ case "number":
629
+ case "integer":
630
+ return (
631
+ <label>
632
+ {head}
633
+ <input
634
+ type="number"
635
+ step={schema.type === "integer" ? 1 : "any"}
636
+ value={typeof value === "number" ? value : ""}
637
+ onChange={(e) => {
638
+ const raw = e.target.value
639
+ onChange(raw === "" ? null : Number(raw))
640
+ }}
641
+ />
642
+ </label>
643
+ )
644
+ case "boolean":
645
+ return (
646
+ <label
647
+ style={{
648
+ display: "flex",
649
+ alignItems: "center",
650
+ gap: "var(--space-2)",
651
+ }}
652
+ >
653
+ <input
654
+ type="checkbox"
655
+ checked={Boolean(value)}
656
+ onChange={(e) => onChange(e.target.checked)}
657
+ style={{ minHeight: 0, width: "14px", height: "14px" }}
658
+ />
659
+ {head}
660
+ </label>
661
+ )
662
+ case "object": {
663
+ if (!schema.properties || Object.keys(schema.properties).length === 0) {
664
+ break
665
+ }
666
+ const obj = (
667
+ value != null && typeof value === "object" ? value : {}
668
+ ) as Record<string, unknown>
669
+ return (
670
+ <div style={{ display: "grid", gap: "var(--space-2)" }}>
671
+ {head}
672
+ <div
673
+ style={{
674
+ borderLeft: "var(--border-w) solid var(--border)",
675
+ paddingLeft: "var(--space-3)",
676
+ }}
677
+ >
678
+ <SchemaForm
679
+ schema={schema}
680
+ value={obj}
681
+ readOnly={readOnly}
682
+ onChange={onChange}
683
+ />
684
+ </div>
685
+ </div>
686
+ )
687
+ }
688
+ }
689
+
690
+ return (
691
+ <div style={{ display: "grid", gap: "var(--space-1)" }}>
692
+ {head}
693
+ <JsonField value={value} onChange={onChange} />
694
+ </div>
695
+ )
696
+ }
697
+
698
+ export const SchemaForm = ({
699
+ schema,
700
+ value,
701
+ readOnly = false,
702
+ onChange,
703
+ }: {
704
+ schema: JsonSchema
705
+ value: Record<string, unknown>
706
+ /** 値を直せない局面(担当外・全員承認・対応済み)では「編集」を出さない */
707
+ readOnly?: boolean
708
+ onChange: (v: Record<string, unknown>) => void
709
+ }) => {
710
+ const fields = Object.entries(schema.properties ?? {})
711
+ // 中身の入った長文=読み物(Markdown 表示)。開いた時点の値で決め、以後は変えない:
712
+ // 入力中に 80 文字を超えた瞬間に入力欄が表示に化けてフォーカスが飛ぶのを避ける
713
+ // (空欄から書き始める手動実行の入力フォームは従来どおり入力欄のまま)
714
+ const [documentKeys] = useState(() =>
715
+ fields
716
+ .filter(([key]) => {
717
+ const field = value[key]
718
+ return typeof field === "string" && isLongText(field)
719
+ })
720
+ .map(([key]) => key),
721
+ )
722
+
723
+ return (
724
+ <div style={{ display: "grid", gap: "var(--space-5)" }}>
725
+ {fields.map(([key, child]) => (
726
+ <Field
727
+ key={key}
728
+ name={key}
729
+ schema={child}
730
+ required={schema.required?.includes(key) ?? false}
731
+ value={value[key]}
732
+ readOnly={readOnly}
733
+ asDocument={documentKeys.includes(key)}
734
+ // 読み物が複数あるとき、先頭(=主役の成果物)だけ開いて以降は畳む。
735
+ // key 名には依存せず、スキーマの並び順だけで決める
736
+ defaultCollapsed={key !== documentKeys[0]}
737
+ onChange={(v) => onChange({ ...value, [key]: v })}
738
+ />
739
+ ))}
740
+ </div>
741
+ )
742
+ }