feat: add live Codex research chat and reusable deck sources

This commit is contained in:
2026-09-24 17:55:51 +09:00
parent c47f58f065
commit b2db67ea1c
28 changed files with 4450 additions and 15 deletions
+415
View File
@@ -0,0 +1,415 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import type { Connection } from '../connections/model'
import type { Deck } from '../decks/model'
import { isResearchActive, type ResearchRun } from './model'
import {
cancelResearch,
loadResearchStatus,
startResearch,
} from './server-functions'
import './research-panel.css'
const statusNames: Record<ResearchRun['status'], string> = {
starting: '開始しています',
running: '調査中',
complete: '完了',
failed: '失敗',
cancelled: '中止しました',
}
type ResearchInput = { topic: string; connectionIds: string[] }
export function ResearchPanel({
ready,
contextDeck,
connections,
onOpenDeck,
}: {
ready: boolean
contextDeck: Deck
connections: Connection[]
onOpenDeck: (deck: Deck, activate?: boolean) => unknown
}) {
const fetchStatus = useServerFn(loadResearchStatus)
const start = useServerFn(startResearch)
const cancel = useServerFn(cancelResearch)
const client = useQueryClient()
const status = useQuery({
queryKey: ['research-status'],
queryFn: () => fetchStatus(),
retry: false,
})
const run = status.data?.run ?? null
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string>()
const [reconnecting, setReconnecting] = useState(false)
const attemptedVersion = useRef<string | null>(null)
const pendingTurn = useRef<{
runId?: string
previousRunId?: string
version: number
} | null>(null)
const syncQueue = useRef<Promise<unknown>>(Promise.resolve())
const syncDeck = useCallback(
(deck: Deck, activate: boolean) => {
const pending = syncQueue.current.then(() => onOpenDeck(deck, activate))
syncQueue.current = pending.catch(() => undefined)
return pending
},
[onOpenDeck],
)
const request = useRef<{ signature: string; id: string } | null>(null)
useEffect(() => {
const events = new EventSource('/api/research/events')
events.onopen = () => setReconnecting(false)
events.onerror = () => setReconnecting(true)
events.onmessage = (event) => {
try {
const next = JSON.parse(event.data) as {
configured: boolean
run: ResearchRun | null
}
void client
.cancelQueries({ queryKey: ['research-status'] })
.then(() => {
client.setQueryData(['research-status'], next)
})
.catch(() => setReconnecting(true))
} catch {
setReconnecting(true)
}
}
return () => events.close()
}, [client])
useEffect(() => {
if (!ready || !run?.deck) return
const version = `${run.id}:${run.deckVersion}`
if (attemptedVersion.current === version) return
attemptedVersion.current = version
const deck = run.deck
const turn = pendingTurn.current
const activate =
!!turn &&
run.deckVersion > turn.version &&
(turn.runId ? run.id === turn.runId : run.id !== turn.previousRunId)
if (activate) pendingTurn.current = null
void syncDeck(deck, activate).catch(() => {
setError(
'調査デッキを同期できませんでした。「一時デッキで開く」から再試行できます。',
)
})
}, [run, syncDeck, ready])
async function begin(input: ResearchInput) {
const beforeStart = client.getQueryData(['research-status'])
pendingTurn.current = {
runId: run?.threadId ? run.id : undefined,
previousRunId: run?.id,
version: run?.threadId ? run.deckVersion : 0,
}
setBusy(true)
setError(undefined)
const payload = {
...input,
contextDeck,
...(run?.threadId ? { runId: run.id } : {}),
}
const signature = JSON.stringify(payload)
if (request.current?.signature !== signature)
request.current = { signature, id: crypto.randomUUID() }
try {
const next = await start({
data: { ...payload, requestId: request.current.id },
})
request.current = null
client.setQueryData<{ configured: boolean; run: ResearchRun | null }>(
['research-status'],
(existing) =>
existing !== beforeStart && existing?.run?.id === next.id
? existing
: { configured: true, run: next },
)
return true
} catch {
pendingTurn.current = null
setError(
'送信できませんでした。接続を確認して再試行してください。入力内容は残っています。',
)
return false
} finally {
setBusy(false)
}
}
async function stop() {
if (!run) return
setBusy(true)
setError(undefined)
try {
const next = await cancel({ data: { id: run.id } })
client.setQueryData(['research-status'], {
configured: status.data?.configured ?? true,
run: next,
})
} catch {
setError('調査を中止できませんでした。もう一度お試しください。')
} finally {
setBusy(false)
}
}
async function openDeck(deck: Deck) {
setBusy(true)
setError(undefined)
try {
await syncDeck(deck, true)
} catch {
setError(
'調査デッキを開けませんでした。保存処理の完了後に再試行してください。',
)
} finally {
setBusy(false)
}
}
return (
<aside className="research-panel" aria-label="調査チャット">
<header className="research-heading">
<h2>調査チャット</h2>
<p>会話に合わせて右のデッキが変わります。</p>
</header>
{reconnecting && (
<p className="deck-save-status" role="status">
調査状況の配信に再接続しています。処理は自宅サーバーで続きます。
</p>
)}
{status.isError && (
<p className="deck-error" role="alert">
調査状況を取得できませんでした。
<button type="button" onClick={() => void status.refetch()}>
再試行
</button>
</p>
)}
{error && (
<p className="deck-error" role="alert">
{error}
</p>
)}
{status.isPending ? (
<p role="status">調査の接続を確認しています…</p>
) : !status.data?.configured ? (
<p role="status">自宅のCodexへの接続が設定されていません。</p>
) : null}
<ResearchConversation messages={run?.messages ?? []} />
{run && (
<ResearchRunStatus
run={run}
busy={busy}
onCancel={() => void stop()}
onOpenDeck={(deck) => void openDeck(deck)}
/>
)}
<ResearchComposer
connections={connections}
contextTitle={contextDeck.title}
disabled={
!ready || busy || isResearchActive(run) || !status.data?.configured
}
onSend={begin}
/>
</aside>
)
}
function ResearchConversation({
messages,
}: {
messages: ResearchRun['messages']
}) {
const container = useRef<HTMLDivElement>(null)
const follow = useRef(true)
useEffect(() => {
const element = container.current
if (element && follow.current && messages.length > 0)
element.scrollTop = element.scrollHeight
}, [messages])
const names = { user: 'あなた', assistant: 'Codex', tool: 'ツール実行' }
return (
<div
className="research-conversation"
role="log"
aria-label="調査の会話"
aria-live="polite"
ref={container}
onScroll={() => {
const element = container.current
if (element)
follow.current =
element.scrollHeight - element.scrollTop - element.clientHeight < 60
}}
>
{messages.length === 0 ? (
<p className="research-empty">
気になる話題や調べたいことを送ってください。検索を進めながらカラムを追加し、レポートを自宅サーバーに保存します。
</p>
) : (
messages.map((message) => (
<article
className={`research-message research-message-${message.role}`}
key={message.id}
>
<strong>{names[message.role]}</strong>
<p>{message.text}</p>
</article>
))
)}
</div>
)
}
function ResearchComposer({
connections,
contextTitle,
disabled,
onSend,
}: {
connections: Connection[]
contextTitle: string
disabled: boolean
onSend: (input: ResearchInput) => Promise<boolean>
}) {
const available = connections.filter(
(connection) => connection.status === 'connected',
)
const [topic, setTopic] = useState('')
const [selected, setSelected] = useState<string[] | null>(null)
const selectedIds =
selected ?? available.slice(0, 6).map((connection) => connection.id)
const id = useId()
const validSelection = selectedIds.filter((value) =>
available.some((connection) => connection.id === value),
)
return (
<form
className="research-start-form"
aria-label="調査メッセージ"
onSubmit={(event) => {
event.preventDefault()
if (!topic.trim() || validSelection.length === 0 || disabled) return
void onSend({
topic: topic.trim(),
connectionIds: validSelection,
}).then((sent) => {
if (sent) setTopic('')
})
}}
>
<p className="research-context">
送信するデッキ: <strong>{contextTitle}</strong>
</p>
<fieldset disabled={disabled}>
<details className="research-account-picker">
<summary>接続アカウント · {validSelection.length}件</summary>
<fieldset className="research-accounts">
<legend>調査に使う接続アカウント(最大6件)</legend>
{available.length === 0 ? (
<p>接続アカウントを先に追加してください。</p>
) : (
available.map((connection) => (
<label key={connection.id}>
<input
type="checkbox"
checked={selectedIds.includes(connection.id)}
disabled={
!selectedIds.includes(connection.id) &&
validSelection.length >= 6
}
onChange={(event) =>
setSelected(
event.target.checked
? [...selectedIds, connection.id]
: selectedIds.filter(
(value) => value !== connection.id,
),
)
}
/>
{connection.displayName} ·{' '}
{connection.platform === 'twitter' ? 'Twitter' : 'Mastodon'}
</label>
))
)}
</fieldset>
</details>
<label htmlFor={id}>メッセージ</label>
<textarea
id={id}
value={topic}
onChange={(event) => setTopic(event.target.value)}
maxLength={1000}
rows={3}
placeholder="例:WebMCPの反応を比較して。その後、このデッキを絞り込んで。"
required
/>
<button
type="submit"
className="deck-primary"
disabled={disabled || !topic.trim() || validSelection.length === 0}
>
送信
</button>
</fieldset>
</form>
)
}
function ResearchRunStatus({
run,
busy,
onCancel,
onOpenDeck,
}: {
run: ResearchRun
busy: boolean
onCancel: () => void
onOpenDeck: (deck: Deck) => void
}) {
return (
<section className="research-run" aria-label="調査状況">
<p role="status">{statusNames[run.status]}</p>
{run.error && (
<p className="deck-error" role="alert">
{run.error}
</p>
)}
{run.reportPath && (
<p>
Markdownの保存先
<br />
<code className="research-report-path">{run.reportPath}</code>
</p>
)}
<div className="deck-actions">
{isResearchActive(run) && (
<button type="button" disabled={busy} onClick={onCancel}>
調査を中止
</button>
)}
{run.deck && (
<button
type="button"
onClick={() => {
if (run.deck) onOpenDeck(run.deck)
}}
>
一時デッキで開く
</button>
)}
</div>
</section>
)
}