@unson/brainbase-mcp 0.11.0 → 0.13.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.
@@ -4,8 +4,9 @@
4
4
  * The host supplies a same-origin request function and a review token. A host
5
5
  * that cannot read a local journal may replace the unavailable notice. This
6
6
  * module owns presentation only: it never treats an unavailable journal as an
7
- * empty list, never edits the judgment journal, and keeps internal IDs inside
8
- * the audit details.
7
+ * empty list and never edits the judgment journal. Recorded basis target IDs
8
+ * are shown as trace metadata; no source URL is invented when the payload has
9
+ * no source-read contract.
9
10
  *
10
11
  * The screen follows the organization edition's pattern (workspace-kit): page
11
12
  * head, the 記録の範囲 notice, filters, metrics, and the 委任の地図 as a ledger
@@ -547,11 +548,98 @@ function renderDelegationMap(doc, state, callbacks) {
547
548
  }
548
549
 
549
550
  function basisText(entry) {
550
- const application = text(entry.application) ?? '適用内容なし';
551
- const layer = BASIS_LAYER_LABELS[entry.layer];
551
+ const application = text(entry?.application) ?? '適用内容なし';
552
+ const layer = BASIS_LAYER_LABELS[entry?.layer];
552
553
  return layer ? `[${layer}] ${application}` : application;
553
554
  }
554
555
 
556
+ function basisTargetText(entry) {
557
+ const entityId = text(entry?.entity_id);
558
+ if (!entityId) return '対象の記録なし';
559
+ const version = text(entry?.version);
560
+ return version ? `${entityId}(版 ${version})` : entityId;
561
+ }
562
+
563
+ const SOURCE_READ_STATES = Object.freeze(['loading', 'available', 'not_found', 'ambiguous', 'forbidden', 'unavailable']);
564
+ const SOURCE_READ_LABELS = Object.freeze({
565
+ loading: '出典を確認中…',
566
+ not_found: '出典が見つかりません(未確認)',
567
+ ambiguous: '同じIDの出典が複数あります(未確認)',
568
+ forbidden: '出典を読む権限がありません(未確認)',
569
+ unavailable: '出典を確認できません(未確認)',
570
+ });
571
+
572
+ function sourceContract(entry) {
573
+ const source = entry?.source;
574
+ if (!isRecord(source) || source.kind !== 'local_graph') return null;
575
+ const entityId = text(source.entity_id);
576
+ const entityType = text(source.entity_type);
577
+ if (!entityId || !entityType || entityId !== text(entry?.entity_id)) return null;
578
+ return source;
579
+ }
580
+
581
+ function sourceKey(source) {
582
+ return [source.kind, source.entity_id, source.entity_type, source.version ?? '', source.digest ?? ''].join('\u0000');
583
+ }
584
+
585
+ function sourceReadState(value) {
586
+ const state = text(value?.state);
587
+ return SOURCE_READ_STATES.includes(state) ? state : 'unavailable';
588
+ }
589
+
590
+ function renderBasisTarget(doc, entry, state) {
591
+ const target = makeElement(doc, 'span', { className: 'bb-vpr-source-target' });
592
+ target.append(makeElement(doc, 'span', { text: basisTargetText(entry) }));
593
+ const source = sourceContract(entry);
594
+ if (!source) return target;
595
+ const key = sourceKey(source);
596
+ const read = state.sourceReads instanceof Map ? state.sourceReads.get(key) : null;
597
+ if (!read) {
598
+ target.append(makeElement(doc, 'span', {
599
+ className: 'bb-vpr-source-status is-unavailable',
600
+ text: '出典の読み取り先を確認できません(未確認)',
601
+ }));
602
+ return target;
603
+ }
604
+ const readState = sourceReadState(read);
605
+ if (readState === 'available') {
606
+ const link = makeElement(doc, 'a', {
607
+ className: 'bb-vpr-source-link',
608
+ text: '出典を開く',
609
+ attrs: { href: `#graph?entity_id=${encodeURIComponent(source.entity_id)}` },
610
+ });
611
+ target.append(link);
612
+ return target;
613
+ }
614
+ target.append(makeElement(doc, 'span', {
615
+ className: `bb-vpr-source-status is-${readState}`,
616
+ text: SOURCE_READ_LABELS[readState] ?? SOURCE_READ_LABELS.unavailable,
617
+ }));
618
+ return target;
619
+ }
620
+
621
+ function journalSetupGuide(doc) {
622
+ const guide = makeElement(doc, 'div', { className: 'bb-vpr-setup-guide' });
623
+ guide.append(makeElement(doc, 'strong', { text: '接続を直す' }));
624
+ guide.append(makeElement(doc, 'p', { text: '判断journalの場所を指定して画面を起動し直したあと、「再試行」を押してください。' }));
625
+ const list = makeElement(doc, 'ol');
626
+ const journalOption = makeElement(doc, 'li');
627
+ journalOption.append(
628
+ makeElement(doc, 'span', { text: '起動時に ' }),
629
+ makeElement(doc, 'code', { text: 'brainbase review:serve --journal <判断journalの場所>' }),
630
+ makeElement(doc, 'span', { text: ' を指定する' }),
631
+ );
632
+ const journalEnv = makeElement(doc, 'li');
633
+ journalEnv.append(
634
+ makeElement(doc, 'span', { text: 'または環境変数 ' }),
635
+ makeElement(doc, 'code', { text: 'BRAINBASE_JUDGMENT_JOURNAL_DIR=<判断journalの場所>' }),
636
+ makeElement(doc, 'span', { text: ' を設定する' }),
637
+ );
638
+ list.append(journalOption, journalEnv);
639
+ guide.append(list);
640
+ return guide;
641
+ }
642
+
555
643
  function inheritanceText(inheritance) {
556
644
  const sources = Array.isArray(inheritance?.sources) ? inheritance.sources : [];
557
645
  if (sources.length === 0) return '引き継ぎの記録なし';
@@ -733,6 +821,7 @@ function renderJudgmentDetail(doc, state, item, callbacks) {
733
821
  title: '根拠と引き継ぎ',
734
822
  content: facts(doc, [
735
823
  ['根拠', basis.length > 0 ? basis.map(basisText).join(' / ') : '根拠の記録なし'],
824
+ ['根拠の対象', basis.length > 0 ? basis.map((entry) => renderBasisTarget(doc, entry, state)) : '対象の記録なし'],
736
825
  ['過去の学習の再利用', reuse === true ? 'あり' : reuse === false ? 'なし' : '未確認'],
737
826
  ['引き継ぎ', inheritanceText(proof.decision.inheritance)],
738
827
  ]),
@@ -861,13 +950,18 @@ export function renderValueProofReview(root, state, callbacks = {}, options = {}
861
950
  if (home.status === 'unavailable') {
862
951
  const hostNotice = options.unavailableNotice;
863
952
  const title = makeElement(doc, 'p', { className: 'bb-vpr-notice-title', text: text(hostNotice?.title) ?? '判断journalに接続できません' });
953
+ const retry = workspaceButton(doc, { text: '再試行', variant: 'primary', onClick: () => void callbacks.onReload?.() });
954
+ const defaultGuide = hostNotice ? null : journalSetupGuide(doc);
864
955
  surface.append(workspaceNotice(doc, {
865
956
  label: '記録の範囲',
866
957
  tone: 'danger',
867
958
  text: noticeBody(doc,
868
959
  title,
869
960
  home.root ? `場所: ${home.root}(${home.reason})` : `理由: ${home.reason}`,
870
- `${text(hostNotice?.guidance) ?? '記録の場所は、起動時の --journal か環境変数 BRAINBASE_JUDGMENT_JOURNAL_DIR で指定できます。'}0件としては扱いません。`),
961
+ text(hostNotice?.guidance) ?? (defaultGuide ? null : '接続元の案内に従って記録を読める状態にしてください。'),
962
+ defaultGuide,
963
+ retry,
964
+ '接続できる状態にしたら「再試行」を押してください。0件としては扱いません。'),
871
965
  }));
872
966
  return finish(null, unreadable);
873
967
  }
@@ -934,6 +1028,8 @@ export function createValueProofReviewUI({
934
1028
  clipboard,
935
1029
  /** Optional `{ title, guidance }` for hosts where the local journal hint does not apply. */
936
1030
  unavailableNotice,
1031
+ /** Optional host-owned reader for typed basis sources. It must not invent a URL. */
1032
+ sourceReader,
937
1033
  autoLoad = true,
938
1034
  } = {}) {
939
1035
  if (!root) throw new TypeError('root is required');
@@ -958,6 +1054,30 @@ export function createValueProofReviewUI({
958
1054
  save: { state: 'idle', message: '' },
959
1055
  focusCard: false,
960
1056
  consultMessage: '',
1057
+ sourceReads: new Map(),
1058
+ };
1059
+
1060
+ const readSelectedSources = async (item) => {
1061
+ if (typeof sourceReader !== 'function' || !item?.proof?.decision?.basis) return;
1062
+ const sources = item.proof.decision.basis.map(sourceContract).filter(Boolean);
1063
+ for (const source of sources) {
1064
+ const key = sourceKey(source);
1065
+ state.sourceReads.set(key, { state: 'loading' });
1066
+ }
1067
+ if (sources.length > 0) controller.render();
1068
+ await Promise.all(sources.map(async (source) => {
1069
+ const key = sourceKey(source);
1070
+ let result;
1071
+ try {
1072
+ result = await sourceReader(source);
1073
+ } catch {
1074
+ result = { state: 'unavailable' };
1075
+ }
1076
+ if (state.selectedKey !== itemKey(item.proof)) return;
1077
+ const normalized = isRecord(result) ? result : {};
1078
+ state.sourceReads.set(key, { ...normalized, state: sourceReadState(normalized) });
1079
+ }));
1080
+ if (state.selectedKey === itemKey(item.proof)) controller.render();
961
1081
  };
962
1082
 
963
1083
  const resetJudgmentInput = () => {
@@ -1000,6 +1120,8 @@ export function createValueProofReviewUI({
1000
1120
  state.focusCard = true;
1001
1121
  controller.render();
1002
1122
  state.focusCard = false;
1123
+ const item = findItem(state, key);
1124
+ if (item) void readSelectedSources(item);
1003
1125
  },
1004
1126
  onDraft(patch) {
1005
1127
  state.draft = { ...state.draft, ...patch };
@@ -60,6 +60,59 @@
60
60
  justify-self: start;
61
61
  }
62
62
 
63
+ /* First-user registration stays in the empty-state notice, beside the fact it affects. */
64
+ .bb-wm-registration-form {
65
+ display: grid;
66
+ grid-template-columns: repeat(2, minmax(0, 1fr));
67
+ gap: var(--bb-space-3);
68
+ margin-top: var(--bb-space-2);
69
+ max-width: 680px;
70
+ }
71
+
72
+ .bb-wm-registration-field {
73
+ display: grid;
74
+ gap: 5px;
75
+ min-width: 0;
76
+ }
77
+
78
+ .bb-wm-registration-field > label {
79
+ color: var(--bb-color-ink);
80
+ font-size: 12px;
81
+ font-weight: 680;
82
+ }
83
+
84
+ .bb-wm-registration-input {
85
+ width: 100%;
86
+ min-height: 34px;
87
+ padding: 7px 9px;
88
+ border: 1px solid var(--bb-color-border-strong);
89
+ border-radius: var(--bb-radius-sm);
90
+ background: var(--bb-color-paper);
91
+ color: var(--bb-color-ink);
92
+ font: inherit;
93
+ font-size: 13px;
94
+ }
95
+
96
+ .bb-wm-registration-input:focus {
97
+ outline: 2px solid var(--bb-color-accent);
98
+ outline-offset: 1px;
99
+ }
100
+
101
+ .bb-wm-registration-status {
102
+ grid-column: 1 / -1;
103
+ color: var(--bb-color-muted);
104
+ font-size: 12px;
105
+ }
106
+
107
+ .bb-wm-registration-form .bb-ws-button {
108
+ justify-self: start;
109
+ }
110
+
111
+ .bb-wm-registration-boundary {
112
+ color: var(--bb-color-muted);
113
+ font-size: 12px;
114
+ }
115
+
63
116
  /* The local shell resets `.bb-shell button { font: inherit }`, which outranks the kit's button type. */
64
117
  .bb-wm .bb-ws-button {
65
118
  font-size: 12px;
@@ -159,6 +212,14 @@
159
212
  }
160
213
 
161
214
  @media (max-width: 760px) {
215
+ .bb-wm-registration-form {
216
+ grid-template-columns: minmax(0, 1fr);
217
+ }
218
+
219
+ .bb-wm-registration-status {
220
+ grid-column: auto;
221
+ }
222
+
162
223
  .bb-wm-view-ledger .bb-ws-ledger-row {
163
224
  grid-template-columns: minmax(0, 1fr) 72px;
164
225
  }
@@ -1,15 +1,17 @@
1
1
  /*
2
- * Read-only World Model view (現状と見通し).
2
+ * World Model view (現状と見通し).
3
3
  *
4
4
  * Shows recorded observations apart from the variables and models that are
5
5
  * the owner's way of seeing them (which may be hypotheses), plus model
6
6
  * adoptions. The host injects the fetcher and base path; this module keeps
7
- * no global state and never writes. Each section loads on its own: a failed
7
+ * no global state. The empty state also offers the host's first-user
8
+ * variable-and-observation registration route. Each section loads on its own: a failed
8
9
  * or unverifiable section is reported in place and never shown as zero items.
9
10
  *
10
11
  * Drawn with the organization edition's screen pattern (workspace-kit): a
11
- * section title under the page head, then one read-only ledger per block.
12
- * Rows are not selectable, so the view never writes to the host's rail.
12
+ * section title under the page head, then one ledger per block. Rows are not
13
+ * selectable, so the view never writes to the host's rail; the only write is
14
+ * the explicit first-registration form shown for a confirmed empty state.
13
15
  *
14
16
  * A section the host cannot serve answers `{ status: 'unavailable', reason }`
15
17
  * (HTTP 200). It is shown as unavailable, never as zero items; a host with no
@@ -333,7 +335,138 @@ function unavailableSummary(doc, state, unavailableNotice) {
333
335
  }
334
336
 
335
337
  /**
336
- * Render the whole view into a host-owned root: a section title and read-only ledgers.
338
+ * The current Personal Web is a read-mostly World Model surface. Show the
339
+ * first-registration entry only after variables and observations have both
340
+ * been confirmed empty;
341
+ * an unknown, partial or unavailable section must not be presented as an
342
+ * empty World Model.
343
+ */
344
+ function registrationField(doc, { id, label, type = 'text', value = '', required = true, attrs = {}, options = [] } = {}) {
345
+ const field = makeElement(doc, 'div', { className: 'bb-wm-registration-field' });
346
+ const labelElement = makeElement(doc, 'label', { text: label, attrs: { for: id } });
347
+ const input = makeElement(doc, type === 'select' ? 'select' : 'input', {
348
+ className: 'bb-wm-registration-input',
349
+ attrs: { id, name: id, type: type === 'select' ? undefined : type, required, ...attrs },
350
+ });
351
+ if (type === 'select') {
352
+ for (const option of options) {
353
+ input.append(makeElement(doc, 'option', { text: option.label, attrs: { value: option.value } }));
354
+ }
355
+ input.value = value;
356
+ } else {
357
+ input.value = value;
358
+ }
359
+ field.append(labelElement, input);
360
+ return { field, input, id };
361
+ }
362
+
363
+ function registrationValue(raw, valueKind) {
364
+ const value = String(raw ?? '').trim();
365
+ if (!value) return { error: '最初の観測値を入力してください。' };
366
+ if (valueKind === 'number') {
367
+ const number = Number(value);
368
+ return Number.isFinite(number) ? { value: number } : { error: '数値の観測値を入力してください。' };
369
+ }
370
+ if (valueKind === 'boolean') {
371
+ if (value === 'true' || value === 'はい') return { value: true };
372
+ if (value === 'false' || value === 'いいえ') return { value: false };
373
+ return { error: '真偽値は「true」または「false」で入力してください。' };
374
+ }
375
+ return { value };
376
+ }
377
+
378
+ function emptyRegistrationEntry(doc, state, callbacks, registration = {}) {
379
+ if (state.variables?.state !== 'empty' || state.observations?.state !== 'empty') return null;
380
+ const formFields = {};
381
+ const form = makeElement(doc, 'form', {
382
+ className: 'bb-wm-registration-form',
383
+ attrs: { 'aria-label': '最初の変数と観測値を登録' },
384
+ });
385
+ const fields = [
386
+ registrationField(doc, { id: 'bb-wm-registration-meaning', label: '何を記録するか', attrs: { autocomplete: 'off' } }),
387
+ registrationField(doc, { id: 'bb-wm-registration-subject', label: '対象', attrs: { autocomplete: 'off' } }),
388
+ registrationField(doc, {
389
+ id: 'bb-wm-registration-value-kind', label: '値の種類', type: 'select', value: 'number',
390
+ options: [
391
+ { value: 'number', label: '数値' },
392
+ { value: 'boolean', label: 'はい/いいえ' },
393
+ { value: 'string', label: '文字' },
394
+ { value: 'state', label: '状態' },
395
+ ],
396
+ }),
397
+ registrationField(doc, { id: 'bb-wm-registration-unit', label: '単位(数値の場合)', required: false, attrs: { autocomplete: 'off' } }),
398
+ registrationField(doc, {
399
+ id: 'bb-wm-registration-aggregation', label: '集計方法', type: 'select', value: 'last',
400
+ options: [
401
+ { value: 'last', label: '最新の値' },
402
+ { value: 'sum', label: '合計' },
403
+ { value: 'average', label: '平均' },
404
+ { value: 'count', label: '件数' },
405
+ { value: 'none', label: '集計しない' },
406
+ ],
407
+ }),
408
+ registrationField(doc, { id: 'bb-wm-registration-granularity', label: '記録の単位', value: '日', attrs: { autocomplete: 'off' } }),
409
+ registrationField(doc, { id: 'bb-wm-registration-method', label: '測り方', attrs: { autocomplete: 'off' } }),
410
+ registrationField(doc, { id: 'bb-wm-registration-value', label: '最初の観測値', attrs: { autocomplete: 'off' } }),
411
+ ];
412
+ for (const { field, input, id } of fields) {
413
+ formFields[id] = input;
414
+ form.append(field);
415
+ }
416
+ const status = makeElement(doc, 'p', {
417
+ className: 'bb-wm-registration-status',
418
+ text: registration.message ?? '登録すると、この画面の記録として読み直します。',
419
+ attrs: { 'aria-live': 'polite' },
420
+ });
421
+ const submit = workspaceButton(doc, {
422
+ text: registration.state === 'saving' ? '登録しています…' : '変数と観測値を登録',
423
+ variant: 'primary',
424
+ disabled: registration.state === 'saving',
425
+ attrs: { type: 'submit' },
426
+ });
427
+ form.addEventListener('submit', (event) => {
428
+ event?.preventDefault?.();
429
+ const parsed = registrationValue(formFields['bb-wm-registration-value'].value, formFields['bb-wm-registration-value-kind'].value);
430
+ const payload = {
431
+ meaning: String(formFields['bb-wm-registration-meaning'].value ?? '').trim(),
432
+ subject: String(formFields['bb-wm-registration-subject'].value ?? '').trim(),
433
+ valueKind: String(formFields['bb-wm-registration-value-kind'].value ?? '').trim(),
434
+ unit: String(formFields['bb-wm-registration-unit'].value ?? '').trim(),
435
+ aggregation: String(formFields['bb-wm-registration-aggregation'].value ?? '').trim(),
436
+ granularity: String(formFields['bb-wm-registration-granularity'].value ?? '').trim(),
437
+ measurementMethod: String(formFields['bb-wm-registration-method'].value ?? '').trim(),
438
+ value: parsed.value,
439
+ };
440
+ if (!payload.meaning || !payload.subject || !payload.granularity || !payload.measurementMethod) {
441
+ status.textContent = '何を記録するか、対象、記録の単位、測り方を入力してください。';
442
+ return;
443
+ }
444
+ if (payload.valueKind === 'number' && !payload.unit) {
445
+ status.textContent = '数値を記録する場合は単位を入力してください。';
446
+ return;
447
+ }
448
+ if (parsed.error) {
449
+ status.textContent = parsed.error;
450
+ return;
451
+ }
452
+ return callbacks.onRegister?.(payload);
453
+ });
454
+ form.append(status, submit);
455
+ const guidance = workspaceNotice(doc, {
456
+ label: '最初の登録',
457
+ text: [
458
+ makeElement(doc, 'p', { text: 'ここから変数と最初の観測値を登録できます。登録者と記録日時は、この手元のWebホストが決めます。' }),
459
+ form,
460
+ makeElement(doc, 'p', { className: 'bb-wm-registration-boundary', text: 'モデルの作成・採用や、既存観測の訂正はこの初回登録の対象外です。確認と権限が必要な別の手順で扱います。' }),
461
+ ],
462
+ });
463
+ guidance.className += ' bb-wm-registration-guidance';
464
+ return guidance;
465
+ }
466
+
467
+ /**
468
+ * Render the whole view into a host-owned root: a section title, optional first-registration form,
469
+ * and ledgers.
337
470
  * `options.unavailableNotice` is the host's `{ title, guidance }` shown once
338
471
  * when every section is unavailable.
339
472
  */
@@ -343,7 +476,7 @@ export function renderWorldModelView(root, state, callbacks = {}, options = {})
343
476
  const surface = makeElement(doc, 'section', { className: 'bb-wm', attrs: { 'data-contract-version': WORLD_MODEL_VIEW_CONTRACT_VERSION, 'aria-label': '現状と見通し' } });
344
477
  surface.append(workspaceSectionTitle(doc, {
345
478
  title: '現状と見通し',
346
- lead: '見方(変数とモデル。仮説を含みます)と、記録された観測を分けて表示します。この欄は表示だけです。',
479
+ lead: '見方(変数とモデル。仮説を含みます)と、記録された観測を分けて表示します。最初の変数と観測値はここから登録できます。',
347
480
  }));
348
481
  const unavailable = unavailableSummary(doc, state, options.unavailableNotice);
349
482
  if (unavailable) {
@@ -352,10 +485,20 @@ export function renderWorldModelView(root, state, callbacks = {}, options = {})
352
485
  return surface;
353
486
  }
354
487
 
488
+ if (options.registration?.state === 'error' && text(options.registration.message)) {
489
+ surface.append(workspaceNotice(doc, {
490
+ label: '登録結果',
491
+ tone: 'danger',
492
+ text: options.registration.message,
493
+ }));
494
+ }
495
+
355
496
  const variables = namesOf(state.variables);
356
497
  const models = namesOf(state.models);
357
498
 
358
499
  const view = block(doc, '変数とモデル', '見方:変数とモデル', '何を測り、どう関係すると考えているか。認識の状態は、確かめた度合いです。観測ではありません。');
500
+ const registrationGuidance = emptyRegistrationEntry(doc, state, callbacks, options.registration);
501
+ if (registrationGuidance) view.append(registrationGuidance);
359
502
  const statuses = {
360
503
  variables: sectionStatus(doc, '変数', 'variables', state.variables, callbacks, '変数はまだ登録がありません。'),
361
504
  models: sectionStatus(doc, 'モデル', 'models', state.models, callbacks, 'モデルはまだ登録がありません。'),
@@ -407,13 +550,14 @@ async function readError(response) {
407
550
  }
408
551
 
409
552
  /**
410
- * Mounts the read-only World Model view.
553
+ * Mounts the World Model view.
411
554
  *
412
555
  * @param {object} options
413
556
  * @param {Element} options.root Where the view is drawn.
414
557
  * @param {Element} [options.rail] Accepted for the host's screen contract; the view never writes to it.
415
558
  * @param {Document} [options.document] The document to build elements with (default: the global one).
416
559
  * @param {Function} [options.fetcher] `fetch`-compatible function for the sections.
560
+ * @param {string} [options.token] Local Web write token sent with registration requests.
417
561
  * @param {string} [options.basePath='/api/world-model'] Where `{variables,models,observations,adoptions}` are served.
418
562
  * @param {boolean} [options.autoLoad=true] Reads every section on mount.
419
563
  * @param {{ title: string, guidance?: string, reasonLabels?: Record<string, string> }} [options.unavailableNotice]
@@ -425,10 +569,11 @@ async function readError(response) {
425
569
  */
426
570
  export function createWorldModelView({
427
571
  root,
428
- /** Accepted for the host's screen contract; the read-only view keeps the rail to the screen's editor. */
572
+ /** Accepted for the host's screen contract; the view keeps the rail to the screen's editor. */
429
573
  rail: _rail,
430
574
  document: explicitDocument,
431
575
  fetcher,
576
+ token,
432
577
  basePath = '/api/world-model',
433
578
  autoLoad = true,
434
579
  unavailableNotice,
@@ -441,12 +586,16 @@ export function createWorldModelView({
441
586
  const base = String(basePath).replace(/\/+$/u, '');
442
587
  const state = Object.fromEntries(WORLD_MODEL_SECTIONS.map((section) => [section, { state: 'loading', items: null }]));
443
588
 
444
- const callbacks = { onRetry: (section) => controller.loadSection(section) };
589
+ const registration = { state: 'idle', message: null };
590
+ const callbacks = {
591
+ onRetry: (section) => controller.loadSection(section),
592
+ onRegister: (payload) => controller.register(payload),
593
+ };
445
594
 
446
595
  const controller = {
447
596
  get state() { return state; },
448
597
  render() {
449
- renderWorldModelView(root, state, callbacks, { document: doc, unavailableNotice });
598
+ renderWorldModelView(root, state, callbacks, { document: doc, unavailableNotice, registration });
450
599
  return controller;
451
600
  },
452
601
  async loadSection(section) {
@@ -477,6 +626,62 @@ export function createWorldModelView({
477
626
  await Promise.all(WORLD_MODEL_SECTIONS.map((section) => controller.loadSection(section)));
478
627
  return state;
479
628
  },
629
+ async register(payload) {
630
+ if (!request) {
631
+ registration.state = 'error';
632
+ registration.message = '登録先が設定されていません。';
633
+ controller.render();
634
+ return;
635
+ }
636
+ registration.state = 'saving';
637
+ registration.message = '登録しています…';
638
+ controller.render();
639
+ const write = async (path, body) => {
640
+ const response = await request(`${base}${path}`, {
641
+ method: 'POST',
642
+ headers: {
643
+ 'Content-Type': 'application/json',
644
+ ...(token ? { 'X-Brainbase-Review-Token': token } : {}),
645
+ },
646
+ body: JSON.stringify(body),
647
+ });
648
+ if (!response.ok) {
649
+ const error = await readError(response);
650
+ throw new Error(error.message ?? error.code ?? `HTTP ${response.status}`);
651
+ }
652
+ return response.json();
653
+ };
654
+ let variableReference = null;
655
+ try {
656
+ const created = await write('/variables', {
657
+ meaning: payload.meaning,
658
+ subject: payload.subject,
659
+ valueKind: payload.valueKind,
660
+ ...(payload.unit ? { unit: payload.unit } : {}),
661
+ aggregation: payload.aggregation,
662
+ granularity: payload.granularity,
663
+ measurementMethod: payload.measurementMethod,
664
+ });
665
+ const reference = created?.reference;
666
+ if (!reference || typeof reference.id !== 'string' || typeof reference.revision !== 'string') {
667
+ throw new Error('変数の登録結果を読み取れませんでした');
668
+ }
669
+ variableReference = reference;
670
+ await write('/observations', { variableRef: reference, value: payload.value });
671
+ registration.state = 'idle';
672
+ registration.message = null;
673
+ await controller.load();
674
+ } catch (error) {
675
+ registration.state = 'error';
676
+ const detail = error instanceof Error ? error.message : '登録できませんでした';
677
+ registration.message = variableReference
678
+ ? `変数は登録されましたが、最初の観測値を保存できませんでした。${detail}`
679
+ : detail;
680
+ await controller.load();
681
+ controller.render();
682
+ }
683
+ return registration;
684
+ },
480
685
  };
481
686
  controller.render();
482
687
  if (autoLoad) void controller.load();