/*__IIFE_WRAPPED__*/ ;(function(){ // screens-event.jsx — Secret-Input(本音) / Plan-Pending / Plan-Reveal / Surprise-Hint const { useState: useS, useEffect: useE, useRef: useR } = React; const { useApp, Icon, Btn, Chip, Card, Avatar, AiAvatar, ImgSlot, Eyebrow, LottieBox, TopBar, Body, Field, Segmented, PrivacyBadge, SAFE_TOP, Amulet, Weather, WeatherBadge, MonthGrid, TimeBand, parseISODate, parseTimeRange, hhmm, WD, } = window; const PARTNER_FALLBACK = 'パートナー'; /* ── 音声入力ボタン(Web Speech API)── タップして話すと、確定した認識結果を onText(text) で親の入力欄へ渡す。 ブラウザ/端末が未対応なら何も描画しない(従来どおりキーボード入力)。 */ function MicButton({ onText, lang = 'ja-JP' }) { const [supported] = useS(() => !!(window.SpeechRecognition || window.webkitSpeechRecognition)); const [listening, setListening] = useS(false); const recRef = useR(null); const onTextRef = useR(onText); onTextRef.current = onText; useE(() => { const SR = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SR) return; const rec = new SR(); rec.lang = lang; rec.interimResults = false; rec.continuous = false; rec.onresult = (e) => { let fin = ''; for (let i = e.resultIndex; i < e.results.length; i++) { if (e.results[i].isFinal) fin += e.results[i][0].transcript; } fin = fin.trim(); if (fin && onTextRef.current) onTextRef.current(fin); }; rec.onend = () => setListening(false); rec.onerror = () => setListening(false); recRef.current = rec; return () => { try { rec.abort(); } catch (_) {} }; }, []); if (!supported) return null; const toggle = () => { const rec = recRef.current; if (!rec) return; if (listening) { try { rec.stop(); } catch (_) {} setListening(false); return; } try { rec.start(); setListening(true); } catch (_) { /* 二重start は無視 */ } }; return ( ); } /* 音声認識テキストを既存入力へ追記する(末尾に半角スペースを挟む) */ const appendVoice = (setText) => (t) => setText(prev => (prev && prev.trim() ? prev.replace(/\s+$/, '') + ' ' : '') + t); const FIRST_PROMPT = { who: 'ai', text: 'どんな気分ですか? 行きたい場所、いまの正直な体力、なんでも聞かせてください。ここはあなただけのひみつの場所です。' }; const delay = ms => new Promise(r => setTimeout(r, ms)); const spotToProposal = s => ({ name: s.name, kind: s.category || 'スポット', blurb: s.note || '', img: (s.category || 'SPOT').slice(0, 6), lat: s.lat, lng: s.lng, category: s.category, photo: s.photoUrl, placeId: s.placeId, address: s.address, rating: s.rating, mapsUri: s.mapsUri, }); /* ─────────── 体力バッテリー(スワイプで充電) ─────────── */ function batteryLabel(v) { return v < 25 ? 'クタクタ' : v < 55 ? 'ほどほど' : v < 80 ? '元気' : '元気満タン'; } function batteryColor(v) { return v < 25 ? 'var(--me)' : v < 55 ? 'var(--gold)' : 'var(--partner)'; } function BatterySwipe({ value, onChange }) { const ref = useR(null); const drag = useR(false); useE(() => { const setFrom = cx => { const r = ref.current.getBoundingClientRect(); const p = Math.round(((cx - r.left) / r.width) * 100); onChange(Math.max(0, Math.min(100, p))); }; const move = e => { if (drag.current) { e.preventDefault(); setFrom(e.clientX); } }; const up = () => { drag.current = false; }; window.addEventListener('pointermove', move, { passive: false }); window.addEventListener('pointerup', up); ref.current.__setFrom = setFrom; return () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); }; }, [onChange]); const col = batteryColor(value); return (
今日の体力 {batteryLabel(value)}
{ drag.current = true; ref.current.__setFrom(e.clientX); }} style={{ position: 'relative', flex: 1, height: 56, borderRadius: 14, cursor: 'grab', touchAction: 'none', background: 'var(--surface-2)', border: '3px solid var(--ink)', padding: 4, overflow: 'hidden', }}>
{[0, 1, 2, 3, 4].map(i =>
)}
48 ? '#fff' : 'var(--ink)', textShadow: value > 48 ? '0 1px 3px rgba(0,0,0,.25)' : 'none' }}>{value}%
クタクタスワイプして充電 →元気満タン
); } /* ─────────── チャット共通: 吹き出し・タイピング・提案 ─────────── */ function Bubble({ m }) { const me = m.who === 'me'; return (
{!me && }
{m.text}
); } /* 配列をシャッフルした新しい配列を返す(毎回おなじ順で出ると「作り物」に見えるため) */ const shuffled = (arr) => { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; }; /* 応答待ちの“間”を埋める、意味をなさないひとりごと。 ドットだけだと止まって見えるので、言葉が動き続けることで「生きている」ことを伝える。 */ const WHIMSY_LINES = [ 'こねこね中', 'ひらめき待ち', 'ふむふむ', 'ことばを探し中', 'ぐるぐる思案中', 'そわそわ', 'まぜまぜ', 'ひとりごと中', 'メモ取り中', 'たくらみ中', 'うんうん唸り中', 'にやにや中', 'そっと考え中', 'ぽかぽか思考中', 'とんとん拍子中', 'えんやこら', 'ひらり思いつき中', 'もぐもぐ咀嚼中', 'ぱたぱた探し中', 'しんみり中', ]; function TypingBubble({ lines = WHIMSY_LINES }) { const seq = useR(null); if (!seq.current) seq.current = shuffled(lines); const [i, setI] = useS(0); const [sec, setSec] = useS(0); useE(() => { const t = setInterval(() => setI(v => (v + 1) % seq.current.length), 1900); const s = setInterval(() => setSec(v => v + 1), 1000); return () => { clearInterval(t); clearInterval(s); }; }, []); return (
{[0, 1, 2].map(d => ( ))}
{seq.current[i]}… {sec >= 5 && ( {sec}s )}
); } function ProposalBlock({ spots, onPick }) { const [picked, setPicked] = useS(null); const list = (spots && spots.length ? spots : []); return (
このあたり、どこが気になる?
気になる場所を選ぶと、Amulet Map に追加されます。
{list.map(p => { const on = picked === p.name; return ( ); })}
); } /* ─────────── プラン作り直しの承認カード(③ プラン相談) ─────────── 会話が溜まってAIが作り直し案を用意したら出る。押して初めてプランが作り直される (勝手な再生成を避けるための承認ゲート)。 */ function PlanProposalCard({ onApply }) { const { toast } = useApp(); const [busy, setBusy] = useS(false); const [done, setDone] = useS(false); const apply = async () => { if (busy || done) return; setBusy(true); try { await onApply(); setDone(true); } catch (e) { console.error(e); toast(Amulet.friendlyError(e)); } finally { setBusy(false); } }; return (
{done ? <>プランを作り直しました
最新のプランに差し替わっています。 : <>ここまでの相談で、作り直しの案がまとまったよ。
反映してよければ、下のボタンで作り直せます。}
{!done && ( )}
); } /* ─────────── デート作成サジェスト(秘密インプット専用の唯一のサジェスト) ─────────── AIが「そろそろデートにする?」と判断したら出すカード。タップすると デート作成画面が、AIの提案したタイトル・エリア・日付で“埋まった状態”で開く。 */ function EventSuggestCard({ data }) { const { go } = useApp(); const d = data || {}; return (
気持ちが見えてきたよ。そろそろデートにする?
この内容で作成画面をひらけます。あとから直せます。
); } /* ─────────── チャットの共通ロジック(実データ前提・実API) ─────────── */ function useAmuletChat(ev, live, toast, initialMsgs, mode = 'counsel') { const isPlan = mode === 'plan'; const isLive = !!(ev && ev.live && live.coupleId); // 実データ前提。サインイン前(≒PC自動ログイン完了前)は最初の問いかけだけ出す。 const [msgs, setMsgs] = useS(isLive ? [] : [FIRST_PROMPT]); const [loading, setLoading] = useS(isLive); const [typing, setTyping] = useS(false); useE(() => { if (!isLive) return; let dead = false; // ① counsel は inputs スレッド、③ plan は planChat スレッドから履歴を読む const loader = isPlan ? Amulet.getMyPlanChat(live.coupleId, ev.id) : Amulet.getMyInput(live.coupleId, ev.id); loader.then(doc => { if (dead) return; const hist = (doc?.messages || []).map(m => ({ who: m.role === 'user' ? 'me' : 'ai', text: m.text })); // live で履歴が空なら initialMsgs(intro)→ なければ FIRST_PROMPT const base = hist.length ? hist : (initialMsgs?.length ? initialMsgs : [FIRST_PROMPT]); // 前回の相談で承認待ちの案が残っていれば、末尾に承認カードを復元する if (isPlan && doc?.pendingPlan?.body) base.push({ kind: 'plan_proposal' }); setMsgs(base); }).catch(e => { console.error(e); setMsgs([FIRST_PROMPT]); }).finally(() => { if (!dead) setLoading(false); }); return () => { dead = true; }; }, []); const send = async (text) => { if (!text.trim() || typing) return; setMsgs(m => [...m, { who: 'me', text }]); if (!isLive) return; // 実APIが無い状態では送信しない(サインイン後に有効) setTyping(true); try { if (isPlan) { // ③ プラン相談: まずは会話だけ。案が溜まったら承認カードを出し、 // 本人が承認したときだけ applyPlanProposal でプランを作り直す(勝手に再生成しない)。 const r = await Amulet.planChat({ eventId: ev.id, text }); setMsgs(m => { const next = [...m, { who: 'ai', text: r.reply }]; if (r.proposal) next.push({ kind: 'plan_proposal' }); return next; }); } else { // ① 本音・相談役 const r = await Amulet.sendChat({ eventId: ev.id, text }); setMsgs(m => { const next = [...m, { who: 'ai', text: r.reply }]; const evSug = (r.actions || []).find(a => a.type === 'suggest_event'); if (evSug) next.push({ kind: 'event_suggest', data: evSug }); return next; }); } } catch (e) { console.error(e); toast(Amulet.friendlyError(e)); } finally { setTyping(false); } }; // 承認カードの「このプランで作り直す」→ 溜まった案を実際に反映する const apply = async () => { if (!isLive) throw new Error('offline'); await Amulet.applyPlanProposal(ev.id); // listenLatestPlan が新バージョンを拾って画面を差し替える toast(プランを作り直しました); }; const pick = async (p) => { setMsgs(m => [...m, { who: 'me', text: `「${p.name}」が気になるな。` }]); if (isLive && p.lat != null) { Amulet.addPin(live.coupleId, { name: p.name, category: p.category || p.kind || 'スポット', lat: p.lat, lng: p.lng, note: p.blurb || '', photoUrl: p.photo, placeId: p.placeId, address: p.address, rating: p.rating, mapsUri: p.mapsUri, }).catch(e => console.error('addPin:', e)); } setTimeout(() => setMsgs(m => [...m, { who: 'ai', text: `いいですね。「${p.name}」、Amulet Map に追加してプランにも織り込んでおきますね。` }]), 500); }; return { isLive, msgs, loading, typing, send, pick, apply }; } /* ════════════════ HOME CHAT (① EventCreatorAgent · eventId なし) ════════════════ ホームの「なんでも、ふたりのことを相談」から開く “なんでも屋” チャット。 イベント不要。デートの相談・アプリへの不満・ふたりの関係の悩み…なんでも話せる。 会話の中でデート作成の意図が出たら create_event(createDate) で「器」づくりに入り、 タイトル(大まかなテーマ)が分かったらデート作成画面(EventCreateScreen)へ引き継ぐ。 日付/時間帯/エリアの詳細確定はそちらの担当(ここでは作らない)。 ※ 行き先・スポット等の詳細プランは「秘密インプット」以降の担当(ここでは作らない)。 履歴は永続化しないので、毎回フロントから history を渡してマルチターンを成立させる。 */ const HOME_FIRST_PROMPT = { who: 'ai', text: 'こんにちは。悩み、デートの相談…なんでも聞かせてください。ちょっとした不満でも大丈夫です。' }; const WD_HOME = ['日', '月', '火', '水', '木', '金', '土']; const isoToday = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }; function parseISOLocal(iso) { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso || ''); return m ? new Date(+m[1], +m[2] - 1, +m[3]) : null; } function dowFromISO(iso) { const d = parseISOLocal(iso); return d ? WD_HOME[d.getDay()] : ''; } function labelFromISO(iso) { const d = parseISOLocal(iso); return d ? `${d.getMonth() + 1}月${d.getDate()}日` : ''; } /* デート作成ドラフト: 会話で集まった「器」を表示。揃ったら(complete)作成ボタンを出す。 ここで作るのはデートの“器”だけ。行き先や時間割は次の「秘密インプット」で。 */ function EventDraftCard({ data }) { const { go } = useApp(); const d = (data && data.draft) || {}; const dateLabel = d.dateLabel || labelFromISO(d.dateISO); const dow = dowFromISO(d.dateISO); const rows = [ ['cal', '日付', d.dateISO ? `${dateLabel}${dow ? `(${dow})` : ''}` : '—'], ['clock', '時間帯', (d.timeRange && d.timeRange.length === 2) ? `${d.timeRange[0]}–${d.timeRange[1]}` : '—'], ['pin', 'エリア', d.area || '—'], ['edit', 'タイトル', d.title || '(なし)'], ]; // ここでは作成せず、デート作成画面(EventCreateScreen)へ事前入力で渡す。 // 日付・時間・エリアの詳細確定はそちらの担当。 const goCreate = () => { go('event-create', { initTitle: d.title, initArea: d.area, initISO: d.dateISO, initTimeRange: d.timeRange, initSummary: d.summary, }); }; return (
この内容でデートをつくる?
次の画面で日付・時間・エリアを決められます。
{rows.map(([ic, k, v]) => (
{k} {v}
))}
); } // これまでのターンで確定した「デートの器」に、今ターンの draft をマージする。 // 新しい非空フィールドが上書きし、空/未定義は既存を保持する(サーバ側 mergeDraft と同じ意味論)。 // これで、モデルが確定ターンで一部フィールドを出し忘れても器が消えず、確認カードが安定して出る。 function mergeHomeDraft(prev, next) { const p = prev || {}, n = next || {}; const has = v => v !== undefined && v !== null && !(typeof v === 'string' && !v.trim()) && !(Array.isArray(v) && v.length === 0); const pick = k => (has(n[k]) ? n[k] : p[k]); const merged = { title: pick('title'), dateISO: pick('dateISO'), dateLabel: pick('dateLabel'), area: pick('area'), timeRange: pick('timeRange'), summary: pick('summary') }; // 日付が今ターンで変わったら、古い dateLabel は引き継がない(表示ズレ防止。無ければ ISO から補完される) if (has(n.dateISO) && n.dateISO !== p.dateISO) merged.dateLabel = has(n.dateLabel) ? n.dateLabel : undefined; const out = {}; for (const k of Object.keys(merged)) if (merged[k] !== undefined) out[k] = merged[k]; return out; } // タイトル(大まかなテーマ)が分かれば器として確認カードを出す。日付/エリア/時間帯は「プラン入力画面」で決める。 const homeDraftComplete = d => !!(d && d.title); function HomeChatScreen({ params }) { const { go, back, live, toast } = useApp(); const signedIn = !!live.user; // ① は coupleId 不要・認証だけでよい const [msgs, setMsgs] = useS([HOME_FIRST_PROMPT]); const [typing, setTyping] = useS(false); const [text, setText] = useS(''); const scroller = useR(null); const seeded = useR(false); const msgsRef = useR(msgs); // 履歴を渡すため最新 msgs を参照 const draftRef = useR({}); // これまで確定した「デートの器」を蓄積(サーバへ往復で渡す) useE(() => { msgsRef.current = msgs; }, [msgs]); useE(() => { const el = scroller.current; if (el) el.scrollTop = el.scrollHeight; }, [msgs, typing]); /* ── ホームからの shared-element 遷移 ── ホームの「なんでも、相談」ピル(params.hero の矩形)が、そのまま画面下の入力バーへ 滑り降りるモーフ。キーボードの出現で入力バーの最終位置は毎フレーム動くため、 目標矩形はアニメーション中に測り直して追従する。 */ const entering = !!(params && params.hero); const [morphing, setMorphing] = useS(entering); const rootRef = useR(null); const pillRef = useR(null); // 実物: 入力ピル(textarea を包む丸枠) const sendBtnRef = useR(null); // 実物: 送信ボタン const clonePillRef = useR(null); // モーフ用クローン const cloneSendRef = useR(null); const taRef = useR(null); /* キーボードを開いたまま遷移する: ホーム側でジェスチャ内にゴースト input へフォーカス済み。 マウント直後(同一タスク内)に textarea へフォーカスを引き継ぐとキーボードは閉じない。 */ React.useLayoutEffect(() => { if (params && params.focus && taRef.current) taRef.current.focus({ preventScroll: true }); }, []); React.useLayoutEffect(() => { if (!morphing) return; const root = rootRef.current, pill = pillRef.current, send = sendBtnRef.current; const cp = clonePillRef.current, cs = cloneSendRef.current; if (!root || !pill || !send || !cp || !cs) { setMorphing(false); return; } // viewport座標 → root ローカル座標(PCのデバイスフレームは transform:scale されているので割り戻す) const loc = (r, rootRect, scale) => ({ x: (r.left - rootRect.left) / scale, y: (r.top - rootRect.top) / scale, w: r.width / scale, h: r.height / scale, }); const rr0 = root.getBoundingClientRect(); const sc0 = rr0.width / root.offsetWidth || 1; const hr = params.hero; const p0 = loc({ left: hr.left, top: hr.top, width: hr.width, height: hr.height }, rr0, sc0); // 出発点の送信サークル(元ピルの右端内側にあるものを近似) const cSize = Math.max(26, Math.min(44, p0.h - 26)); const s0 = { x: p0.x + p0.w - cSize - (p0.h - cSize) / 2, y: p0.y + (p0.h - cSize) / 2, w: cSize, h: cSize }; const DUR = 420; const ease = t => 1 - Math.pow(1 - t, 3); const lerp = (a, b, k) => a + (b - a) * k; let raf, t0; const step = (now) => { if (t0 === undefined) t0 = now; const k = ease(Math.min(1, (now - t0) / DUR)); const rr = root.getBoundingClientRect(); const sc = rr.width / root.offsetWidth || 1; const p1 = loc(pill.getBoundingClientRect(), rr, sc); const s1 = loc(send.getBoundingClientRect(), rr, sc); const set = (el, a, b, radius) => { el.style.left = lerp(a.x, b.x, k) + 'px'; el.style.top = lerp(a.y, b.y, k) + 'px'; el.style.width = lerp(a.w, b.w, k) + 'px'; el.style.height = lerp(a.h, b.h, k) + 'px'; el.style.borderRadius = radius; el.style.opacity = 1; }; set(cp, p0, p1, lerp(18, 22, k) + 'px'); set(cs, s0, s1, '50%'); if ((now - t0) < DUR) raf = requestAnimationFrame(step); else setMorphing(false); }; raf = requestAnimationFrame(step); return () => cancelAnimationFrame(raf); }, [morphing]); /* キーボードの出入りで表示領域が変わったら、会話は最下部に貼りつけておく */ useE(() => { const vv = window.visualViewport; if (!vv) return; const pin = () => { const el = scroller.current; if (el) el.scrollTop = el.scrollHeight; }; vv.addEventListener('resize', pin); return () => vv.removeEventListener('resize', pin); }, []); const sendText = async (raw) => { const v = (raw || '').trim(); if (!v || typing) return; setMsgs(m => [...m, { who: 'me', text: v }]); if (!signedIn) return; // サインイン前は送らない(最初の問いかけだけ) setTyping(true); try { // これまでの会話を履歴として渡す(ホームチャットは永続化しないため) const history = msgsRef.current .filter(m => m.text && (m.who === 'me' || m.who === 'ai')) .map(m => ({ role: m.who === 'me' ? 'user' : 'model', text: m.text })); history.push({ role: 'user', text: v }); // eventId を渡さない → ① EventCreatorAgent(なんでも屋) // これまで蓄積した器(draftRef)を同送し、サーバ側でもマージして complete を判定させる。 const r = await Amulet.sendChat({ text: v, history, today: isoToday(), draft: draftRef.current }); const acts = r.actions || []; const ce = acts.find(a => a.type === 'create_event'); // 今ターンの draft を蓄積器へマージ。表示判定はマージ結果で行う(サーバの complete 単独に依存しない)。 let mergedDraft = null; if (ce) { draftRef.current = mergeHomeDraft(draftRef.current, ce.draft || {}); mergedDraft = draftRef.current; } setMsgs(m => { const next = [...m, { who: 'ai', text: r.reply }]; // 収集中はカードを出さず会話だけ。日付・時間帯・エリアが揃った確定段階だけ確認カードを出す。 if (homeDraftComplete(mergedDraft)) { const lastCard = [...m].reverse().find(x => x.kind === 'event_draft'); const dup = lastCard && JSON.stringify(lastCard.data?.draft) === JSON.stringify(mergedDraft); if (!dup) next.push({ kind: 'event_draft', data: { draft: mergedDraft } }); } else if (!ce) { const evSug = acts.find(a => a.type === 'suggest_event'); // 旧エージェント互換 if (evSug) next.push({ kind: 'event_suggest', data: evSug }); } return next; }); } catch (e) { console.error(e); toast(Amulet.friendlyError(e)); } finally { setTyping(false); } }; const send = () => { const v = text; if (!v.trim()) return; setText(''); sendText(v); }; // ホームのサジェストチップから来たら、その文言で自動送信(1回だけ) useE(() => { if (seeded.current || !params?.seed) return; seeded.current = true; sendText(params.seed); }, []); return (
{/* header — hero遷移のときは上からふわっと降りてくる */}
ふたりのことを相談
悩み、デートのこと、なんでも
相手には見えません
{/* chat — hero遷移のときは下からふわっと */}
{msgs.map((m, i) => m.kind === 'event_draft' ? : m.kind === 'event_suggest' ? : )} {typing && }
{/* input — キーボードが出たら下パディングを詰める(--kbh は app.jsx が配布)。 モーフ中は実物を透明にしてクローンに演じさせる */}