feat: start fresh research chats while preserving deck context
This commit is contained in:
@@ -103,6 +103,10 @@ accounts' lists before planning new searches, then read relevant list columns
|
|||||||
for evidence. Follow-ups can reuse catalogs already fetched in the conversation.
|
for evidence. Follow-ups can reuse catalogs already fetched in the conversation.
|
||||||
Manual deck creation and column editing remain
|
Manual deck creation and column editing remain
|
||||||
available. The backend handles tools even when the browser is closed.
|
available. The backend handles tools even when the browser is closed.
|
||||||
|
Use **新しいチャット** to clear the current conversation and start a fresh Codex
|
||||||
|
thread with your next message. The open deck and host reports remain available.
|
||||||
|
If a turn is running, stop it first. The conversation reset is shared across
|
||||||
|
open devices; previous conversations are not listed in this prototype.
|
||||||
Each turn is limited to 6 columns, 12 upstream requests shared between list
|
Each turn is limited to 6 columns, 12 upstream requests shared between list
|
||||||
discovery and post retrieval (at most 20 posts per fetch), and 15 minutes.
|
discovery and post retrieval (at most 20 posts per fetch), and 15 minutes.
|
||||||
The prototype accepts up to 100 messages per backend process.
|
The prototype accepts up to 100 messages per backend process.
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ editing a deck is a valid completed turn. Reports, when requested, remain host
|
|||||||
Markdown files. SSE snapshots include the conversation and latest generated
|
Markdown files. SSE snapshots include the conversation and latest generated
|
||||||
deck so reopening a page restores both without restarting work or polling.
|
deck so reopening a page restores both without restarting work or polling.
|
||||||
|
|
||||||
|
The chat header can reset an idle conversation. The next message starts a new
|
||||||
|
Codex thread while retaining the browser's current deck as context. Reset pushes
|
||||||
|
an empty conversation to every subscriber, preserves host reports and does not
|
||||||
|
delete deck definitions. Active turns must be stopped first; a stale reset must
|
||||||
|
not clear a newer conversation started on another device. This does not add a
|
||||||
|
conversation-history UI.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
- Protocol tests for dynamic-tool requests, failures, cancellation and process
|
- Protocol tests for dynamic-tool requests, failures, cancellation and process
|
||||||
@@ -202,3 +209,13 @@ be handled by the backend so research can continue without the initiating tab.
|
|||||||
an upstream `Dependency: Unspecified` error; a direct retry through the same
|
an upstream `Dependency: Unspecified` error; a direct retry through the same
|
||||||
Bird catalog API returned three lists. A follow-up message in the same Codex
|
Bird catalog API returned three lists. A follow-up message in the same Codex
|
||||||
conversation then retried `list_lists` successfully and also reported three.
|
conversation then retried `list_lists` successfully and also reported three.
|
||||||
|
|
||||||
|
## New-chat verification
|
||||||
|
|
||||||
|
- 284 unit/component tests and eight desktop/mobile research E2E cases passed,
|
||||||
|
along with typecheck, lint and production build.
|
||||||
|
- A real Codex check on a separate development server reset a completed chat
|
||||||
|
across two browser pages, cleared the draft and retained the current deck.
|
||||||
|
The next message used a different thread ID, contained only the new user
|
||||||
|
message and received the current deck context. The separate server avoided
|
||||||
|
interrupting research already running in the public workspace.
|
||||||
|
|||||||
@@ -11,6 +11,11 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.research-heading {
|
.research-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
@@ -18,6 +23,12 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
.research-heading p {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.research-heading button {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
.research-heading p,
|
.research-heading p,
|
||||||
.research-context {
|
.research-context {
|
||||||
margin: 0.5rem 0 0;
|
margin: 0.5rem 0 0;
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ const actions = vi.hoisted(() => ({
|
|||||||
status: vi.fn(),
|
status: vi.fn(),
|
||||||
start: vi.fn(),
|
start: vi.fn(),
|
||||||
cancel: vi.fn(),
|
cancel: vi.fn(),
|
||||||
|
reset: vi.fn(),
|
||||||
}))
|
}))
|
||||||
vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn }))
|
vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn }))
|
||||||
vi.mock('./server-functions', () => ({
|
vi.mock('./server-functions', () => ({
|
||||||
loadResearchStatus: actions.status,
|
loadResearchStatus: actions.status,
|
||||||
startResearch: actions.start,
|
startResearch: actions.start,
|
||||||
cancelResearch: actions.cancel,
|
cancelResearch: actions.cancel,
|
||||||
|
resetResearch: actions.reset,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const connection: Connection = {
|
const connection: Connection = {
|
||||||
@@ -374,3 +376,91 @@ it('selects newly loaded connected accounts until the user changes their selecti
|
|||||||
})
|
})
|
||||||
expect(screen.getByRole('button', { name: '送信' })).toBeEnabled()
|
expect(screen.getByRole('button', { name: '送信' })).toBeEnabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('starts a new chat without changing the deck and clears the draft only after success', async () => {
|
||||||
|
current = {
|
||||||
|
configured: true,
|
||||||
|
run: { ...running, status: 'complete', threadId: 'old-thread' },
|
||||||
|
}
|
||||||
|
actions.reset
|
||||||
|
.mockRejectedValueOnce(new Error('offline'))
|
||||||
|
.mockResolvedValue({ configured: true, run: null })
|
||||||
|
const view = show()
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: '新しいチャット' }),
|
||||||
|
).toBeEnabled(),
|
||||||
|
)
|
||||||
|
fireEvent.change(screen.getByLabelText('メッセージ'), {
|
||||||
|
target: { value: '下書き' },
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '新しいチャット' }))
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
'会話と入力内容は残っています',
|
||||||
|
)
|
||||||
|
expect(screen.getByLabelText('メッセージ')).toHaveValue('下書き')
|
||||||
|
const callsBeforeReset = view.onOpenDeck.mock.calls.length
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '新しいチャット' }))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByLabelText('メッセージ')).toHaveValue(''),
|
||||||
|
)
|
||||||
|
expect(screen.queryByText('WebMCPの反応')).toBeNull()
|
||||||
|
expect(view.onOpenDeck).toHaveBeenCalledTimes(callsBeforeReset)
|
||||||
|
expect(screen.getByText('手動デッキ')).toBeVisible()
|
||||||
|
fireEvent.change(screen.getByLabelText('メッセージ'), {
|
||||||
|
target: { value: '新しい話題' },
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '送信' }))
|
||||||
|
await waitFor(() => expect(actions.start).toHaveBeenCalledOnce())
|
||||||
|
expect(actions.start.mock.calls[0]?.[0].data).not.toHaveProperty('runId')
|
||||||
|
expect(actions.reset).toHaveBeenCalledWith({ data: { id: running.id } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables new chat during a running turn and receives another device reset over SSE', async () => {
|
||||||
|
current = { configured: true, run: running }
|
||||||
|
const view = show()
|
||||||
|
await screen.findByText('WebMCPの反応')
|
||||||
|
expect(screen.getByRole('button', { name: '新しいチャット' })).toBeDisabled()
|
||||||
|
const callsBeforeReset = view.onOpenDeck.mock.calls.length
|
||||||
|
act(() =>
|
||||||
|
MockEventSource.instances[0]?.onmessage?.({
|
||||||
|
data: JSON.stringify({ configured: true, run: null }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await waitFor(() => expect(screen.queryByText('WebMCPの反応')).toBeNull())
|
||||||
|
expect(view.onOpenDeck).toHaveBeenCalledTimes(callsBeforeReset)
|
||||||
|
expect(actions.reset).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not let a late reset response erase a newer streamed conversation', async () => {
|
||||||
|
current = { configured: true, run: { ...running, status: 'complete' } }
|
||||||
|
let finish!: (snapshot: typeof current) => void
|
||||||
|
actions.reset.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<typeof current>((resolve) => {
|
||||||
|
finish = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const view = show()
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: '新しいチャット' }),
|
||||||
|
).toBeEnabled(),
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '新しいチャット' }))
|
||||||
|
const newer = {
|
||||||
|
configured: true,
|
||||||
|
run: {
|
||||||
|
...running,
|
||||||
|
id: 'new-run',
|
||||||
|
messages: [{ id: 'new-user', role: 'user', text: '別端末の新しい話題' }],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
act(() =>
|
||||||
|
MockEventSource.instances[0]?.onmessage?.({ data: JSON.stringify(newer) }),
|
||||||
|
)
|
||||||
|
await screen.findByText('別端末の新しい話題')
|
||||||
|
await act(async () => finish({ configured: true, run: null }))
|
||||||
|
expect(view.client.getQueryData(['research-status'])).toEqual(newer)
|
||||||
|
expect(screen.getByText('別端末の新しい話題')).toBeVisible()
|
||||||
|
})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { isResearchActive, type ResearchRun } from './model'
|
|||||||
import {
|
import {
|
||||||
cancelResearch,
|
cancelResearch,
|
||||||
loadResearchStatus,
|
loadResearchStatus,
|
||||||
|
resetResearch,
|
||||||
startResearch,
|
startResearch,
|
||||||
} from './server-functions'
|
} from './server-functions'
|
||||||
import './research-panel.css'
|
import './research-panel.css'
|
||||||
@@ -34,6 +35,7 @@ export function ResearchPanel({
|
|||||||
const fetchStatus = useServerFn(loadResearchStatus)
|
const fetchStatus = useServerFn(loadResearchStatus)
|
||||||
const start = useServerFn(startResearch)
|
const start = useServerFn(startResearch)
|
||||||
const cancel = useServerFn(cancelResearch)
|
const cancel = useServerFn(cancelResearch)
|
||||||
|
const reset = useServerFn(resetResearch)
|
||||||
const client = useQueryClient()
|
const client = useQueryClient()
|
||||||
const status = useQuery({
|
const status = useQuery({
|
||||||
queryKey: ['research-status'],
|
queryKey: ['research-status'],
|
||||||
@@ -44,6 +46,8 @@ export function ResearchPanel({
|
|||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState<string>()
|
const [error, setError] = useState<string>()
|
||||||
const [reconnecting, setReconnecting] = useState(false)
|
const [reconnecting, setReconnecting] = useState(false)
|
||||||
|
const [draftResetVersion, setDraftResetVersion] = useState(0)
|
||||||
|
const previousRunId = useRef<string | null>(null)
|
||||||
const attemptedVersion = useRef<string | null>(null)
|
const attemptedVersion = useRef<string | null>(null)
|
||||||
const pendingTurn = useRef<{
|
const pendingTurn = useRef<{
|
||||||
runId?: string
|
runId?: string
|
||||||
@@ -61,6 +65,15 @@ export function ResearchPanel({
|
|||||||
)
|
)
|
||||||
const request = useRef<{ signature: string; id: string } | null>(null)
|
const request = useRef<{ signature: string; id: string } | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!run && previousRunId.current) {
|
||||||
|
setDraftResetVersion((value) => value + 1)
|
||||||
|
request.current = null
|
||||||
|
pendingTurn.current = null
|
||||||
|
}
|
||||||
|
previousRunId.current = run?.id ?? null
|
||||||
|
}, [run])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const events = new EventSource('/api/research/events')
|
const events = new EventSource('/api/research/events')
|
||||||
events.onopen = () => setReconnecting(false)
|
events.onopen = () => setReconnecting(false)
|
||||||
@@ -128,9 +141,7 @@ export function ResearchPanel({
|
|||||||
client.setQueryData<{ configured: boolean; run: ResearchRun | null }>(
|
client.setQueryData<{ configured: boolean; run: ResearchRun | null }>(
|
||||||
['research-status'],
|
['research-status'],
|
||||||
(existing) =>
|
(existing) =>
|
||||||
existing !== beforeStart && existing?.run?.id === next.id
|
existing !== beforeStart ? existing : { configured: true, run: next },
|
||||||
? existing
|
|
||||||
: { configured: true, run: next },
|
|
||||||
)
|
)
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
@@ -146,14 +157,19 @@ export function ResearchPanel({
|
|||||||
|
|
||||||
async function stop() {
|
async function stop() {
|
||||||
if (!run) return
|
if (!run) return
|
||||||
|
const beforeCancel = client.getQueryData(['research-status'])
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError(undefined)
|
setError(undefined)
|
||||||
try {
|
try {
|
||||||
const next = await cancel({ data: { id: run.id } })
|
const next = await cancel({ data: { id: run.id } })
|
||||||
client.setQueryData(['research-status'], {
|
client.setQueryData(['research-status'], (existing: unknown) =>
|
||||||
configured: status.data?.configured ?? true,
|
existing === beforeCancel
|
||||||
run: next,
|
? {
|
||||||
})
|
configured: status.data?.configured ?? true,
|
||||||
|
run: next,
|
||||||
|
}
|
||||||
|
: existing,
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
setError('調査を中止できませんでした。もう一度お試しください。')
|
setError('調査を中止できませんでした。もう一度お試しください。')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -161,6 +177,28 @@ export function ResearchPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function newChat() {
|
||||||
|
if (!run || isResearchActive(run)) return
|
||||||
|
const beforeReset = client.getQueryData(['research-status'])
|
||||||
|
setBusy(true)
|
||||||
|
setError(undefined)
|
||||||
|
try {
|
||||||
|
const next = await reset({ data: { id: run.id } })
|
||||||
|
client.setQueryData(['research-status'], (existing: unknown) =>
|
||||||
|
existing === beforeReset ? next : existing,
|
||||||
|
)
|
||||||
|
request.current = null
|
||||||
|
pendingTurn.current = null
|
||||||
|
setDraftResetVersion((value) => value + 1)
|
||||||
|
} catch {
|
||||||
|
setError(
|
||||||
|
'新しいチャットを開始できませんでした。会話と入力内容は残っています。もう一度お試しください。',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openDeck(deck: Deck) {
|
async function openDeck(deck: Deck) {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError(undefined)
|
setError(undefined)
|
||||||
@@ -179,6 +217,18 @@ export function ResearchPanel({
|
|||||||
<aside className="research-panel" aria-label="調査チャット">
|
<aside className="research-panel" aria-label="調査チャット">
|
||||||
<header className="research-heading">
|
<header className="research-heading">
|
||||||
<h2>調査チャット</h2>
|
<h2>調査チャット</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy || !run || isResearchActive(run)}
|
||||||
|
title={
|
||||||
|
isResearchActive(run)
|
||||||
|
? '調査を中止してから新しいチャットを開始できます'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={() => void newChat()}
|
||||||
|
>
|
||||||
|
新しいチャット
|
||||||
|
</button>
|
||||||
<p>会話に合わせて右のデッキが変わります。</p>
|
<p>会話に合わせて右のデッキが変わります。</p>
|
||||||
</header>
|
</header>
|
||||||
{reconnecting && (
|
{reconnecting && (
|
||||||
@@ -214,6 +264,7 @@ export function ResearchPanel({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ResearchComposer
|
<ResearchComposer
|
||||||
|
resetVersion={draftResetVersion}
|
||||||
connections={connections}
|
connections={connections}
|
||||||
contextTitle={contextDeck.title}
|
contextTitle={contextDeck.title}
|
||||||
disabled={
|
disabled={
|
||||||
@@ -272,11 +323,13 @@ function ResearchConversation({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ResearchComposer({
|
function ResearchComposer({
|
||||||
|
resetVersion,
|
||||||
connections,
|
connections,
|
||||||
contextTitle,
|
contextTitle,
|
||||||
disabled,
|
disabled,
|
||||||
onSend,
|
onSend,
|
||||||
}: {
|
}: {
|
||||||
|
resetVersion: number
|
||||||
connections: Connection[]
|
connections: Connection[]
|
||||||
contextTitle: string
|
contextTitle: string
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
@@ -286,6 +339,9 @@ function ResearchComposer({
|
|||||||
(connection) => connection.status === 'connected',
|
(connection) => connection.status === 'connected',
|
||||||
)
|
)
|
||||||
const [topic, setTopic] = useState('')
|
const [topic, setTopic] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
if (resetVersion > 0) setTopic('')
|
||||||
|
}, [resetVersion])
|
||||||
const [selected, setSelected] = useState<string[] | null>(null)
|
const [selected, setSelected] = useState<string[] | null>(null)
|
||||||
const selectedIds =
|
const selectedIds =
|
||||||
selected ?? available.slice(0, 6).map((connection) => connection.id)
|
selected ?? available.slice(0, 6).map((connection) => connection.id)
|
||||||
|
|||||||
@@ -339,6 +339,20 @@ export function createResearchService(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
status: snapshot,
|
status: snapshot,
|
||||||
|
reset(id: string) {
|
||||||
|
if (isResearchActive(run) || state?.stopping)
|
||||||
|
throw new Error(
|
||||||
|
'調査中です。中止してから新しいチャットを開始してください。',
|
||||||
|
)
|
||||||
|
if (run && run.id !== id)
|
||||||
|
throw new Error(
|
||||||
|
'別のチャットが開かれています。最新の状態を確認してください。',
|
||||||
|
)
|
||||||
|
run = null
|
||||||
|
state = undefined
|
||||||
|
emit()
|
||||||
|
return snapshot()
|
||||||
|
},
|
||||||
subscribe(listener: (snapshot: ResearchSnapshot) => void) {
|
subscribe(listener: (snapshot: ResearchSnapshot) => void) {
|
||||||
listeners.add(listener)
|
listeners.add(listener)
|
||||||
publish(listener)
|
publish(listener)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||||
@@ -418,6 +418,45 @@ it('rejects unknown continuation IDs', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('resets a completed conversation for every viewer and starts a fresh thread without deleting its report', async () => {
|
||||||
|
const first = service.start(input())
|
||||||
|
const old = await running()
|
||||||
|
const reportPath = join(directory, first.id, 'report.md')
|
||||||
|
await writeFile(reportPath, '# Saved research')
|
||||||
|
old.completed()
|
||||||
|
await vi.waitFor(() => expect(service.status().run?.status).toBe('complete'))
|
||||||
|
const listener = vi.fn()
|
||||||
|
service.subscribe(listener)
|
||||||
|
expect(service.reset(first.id)).toEqual({ configured: true, run: null })
|
||||||
|
expect(listener).toHaveBeenLastCalledWith({ configured: true, run: null })
|
||||||
|
expect(service.reset(first.id).run).toBeNull()
|
||||||
|
expect(await readFile(reportPath, 'utf8')).toBe('# Saved research')
|
||||||
|
expect(() => service.start({ ...input(), runId: first.id })).toThrow(
|
||||||
|
'続ける会話が見つかりません',
|
||||||
|
)
|
||||||
|
const next = service.start(input())
|
||||||
|
const current = await running()
|
||||||
|
expect(next.id).not.toBe(first.id)
|
||||||
|
expect(current.calls).toHaveBeenCalledWith('thread/start', expect.anything())
|
||||||
|
expect(current.calls).not.toHaveBeenCalledWith(
|
||||||
|
'thread/resume',
|
||||||
|
expect.anything(),
|
||||||
|
)
|
||||||
|
expect(service.status().run?.messages).toHaveLength(1)
|
||||||
|
old.completed()
|
||||||
|
expect(service.status().run?.id).toBe(next.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires an active turn to stop before resetting and rejects a stale reset from another viewer', async () => {
|
||||||
|
const first = service.start(input())
|
||||||
|
await running()
|
||||||
|
expect(() => service.reset(first.id)).toThrow('中止してから')
|
||||||
|
await service.cancel(first.id)
|
||||||
|
expect(() => service.reset(randomUUID())).toThrow('別のチャット')
|
||||||
|
expect(service.status().run?.id).toBe(first.id)
|
||||||
|
expect(service.reset(first.id).run).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps the generated deck identity across turns while advancing its version', async () => {
|
it('keeps the generated deck identity across turns while advancing its version', async () => {
|
||||||
const first = service.start(input())
|
const first = service.start(input())
|
||||||
const old = await running()
|
const old = await running()
|
||||||
|
|||||||
@@ -20,3 +20,10 @@ export const cancelResearch = createServerFn({ method: 'POST' })
|
|||||||
const { researchService } = await import('./runner.server')
|
const { researchService } = await import('./runner.server')
|
||||||
return researchService().cancel(data.id)
|
return researchService().cancel(data.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const resetResearch = createServerFn({ method: 'POST' })
|
||||||
|
.validator(z.object({ id: z.string().uuid() }).strict())
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
const { researchService } = await import('./runner.server')
|
||||||
|
return researchService().reset(data.id)
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user