feat: prototype WebMCP reader tools
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
} from '../page'
|
||||
import type { Post } from '../types'
|
||||
import { type FeedRequest, PostLoadError, usePostFeed } from '../use-post-feed'
|
||||
import { useFeedTools } from '../webmcp-tools'
|
||||
import { PostCard } from './post-card'
|
||||
|
||||
function FocalPost({ post }: { post: Post }) {
|
||||
@@ -21,6 +22,7 @@ function FocalPost({ post }: { post: Post }) {
|
||||
|
||||
export function PostFeed({ request }: { request: FeedRequest | undefined }) {
|
||||
const query = usePostFeed(request)
|
||||
useFeedTools(request, query)
|
||||
const sentinel = useRef<HTMLDivElement>(null)
|
||||
const pages = query.data?.pages ?? []
|
||||
const focalPost =
|
||||
@@ -44,7 +46,7 @@ export function PostFeed({ request }: { request: FeedRequest | undefined }) {
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting && !requested) {
|
||||
requested = true
|
||||
void query.fetchNextPage()
|
||||
void query.fetchNextPage({ cancelRefetch: false })
|
||||
}
|
||||
},
|
||||
{ rootMargin: '600px 0px' },
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createElement, type ReactNode } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createPostFeedOptions,
|
||||
getPostFeedData,
|
||||
PostLoadError,
|
||||
usePostFeed,
|
||||
} from './use-post-feed'
|
||||
@@ -229,6 +230,97 @@ describe('createPostFeedOptions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPostFeedData', () => {
|
||||
it('reuses the visible feed without fetching a second copy', async () => {
|
||||
const loadUser = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, page: { tweets: [] } })
|
||||
const options = createPostFeedOptions(
|
||||
{ kind: 'user', target: 'yuta' },
|
||||
loaders({ loadUser }),
|
||||
)
|
||||
const client = new QueryClient()
|
||||
const visible = await client.fetchInfiniteQuery(options)
|
||||
|
||||
expect(await getPostFeedData(client, options)).toEqual(visible)
|
||||
expect(loadUser).toHaveBeenCalledOnce()
|
||||
client.clear()
|
||||
})
|
||||
|
||||
it('waits for the active profile refresh instead of returning old cached posts', async () => {
|
||||
let release = () => {}
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const freshPage = {
|
||||
tweets: [
|
||||
{
|
||||
id: 'new-profile',
|
||||
text: 'fresh',
|
||||
author: { username: 'new', name: 'New' },
|
||||
},
|
||||
],
|
||||
}
|
||||
const loadUser = vi.fn(async () => {
|
||||
await gate
|
||||
return { ok: true as const, page: freshPage }
|
||||
})
|
||||
const options = createPostFeedOptions(
|
||||
{ kind: 'user', target: 'yuta' },
|
||||
loaders({ loadUser }),
|
||||
)
|
||||
const client = new QueryClient()
|
||||
client.setQueryData(options.queryKey, {
|
||||
pages: [{ tweets: [] }],
|
||||
pageParams: [undefined],
|
||||
})
|
||||
await client.invalidateQueries({
|
||||
queryKey: options.queryKey,
|
||||
refetchType: 'none',
|
||||
})
|
||||
const refresh = client.fetchInfiniteQuery(options)
|
||||
const resolved = vi.fn()
|
||||
const tool = getPostFeedData(client, options).then(resolved)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(resolved).not.toHaveBeenCalled()
|
||||
release()
|
||||
await Promise.all([refresh, tool])
|
||||
expect(resolved).toHaveBeenCalledWith({
|
||||
pages: [freshPage],
|
||||
pageParams: [undefined],
|
||||
})
|
||||
expect(loadUser).toHaveBeenCalledOnce()
|
||||
client.clear()
|
||||
})
|
||||
|
||||
it('refreshes invalidated cached data before returning it', async () => {
|
||||
const loadUser = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
page: { tweets: [], nextCursor: 'fresh' },
|
||||
})
|
||||
const options = createPostFeedOptions(
|
||||
{ kind: 'user', target: 'yuta' },
|
||||
loaders({ loadUser }),
|
||||
)
|
||||
const client = new QueryClient()
|
||||
client.setQueryData(options.queryKey, {
|
||||
pages: [{ tweets: [] }],
|
||||
pageParams: [undefined],
|
||||
})
|
||||
await client.invalidateQueries({
|
||||
queryKey: options.queryKey,
|
||||
refetchType: 'none',
|
||||
})
|
||||
|
||||
expect((await getPostFeedData(client, options)).pages[0]?.nextCursor).toBe(
|
||||
'fresh',
|
||||
)
|
||||
expect(loadUser).toHaveBeenCalledOnce()
|
||||
client.clear()
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePostFeed', () => {
|
||||
beforeEach(() => {
|
||||
useServerFn.mockReset()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { type QueryClient, useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useServerFn } from '@tanstack/react-start'
|
||||
import type { SearchProduct } from '@yuta/bird'
|
||||
import {
|
||||
@@ -120,23 +120,41 @@ export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) {
|
||||
}
|
||||
}
|
||||
|
||||
export function usePostFeed(request: FeedRequest | undefined) {
|
||||
export function usePostFeedOptions() {
|
||||
const loadUser = useServerFn(loadUserPosts)
|
||||
const loadList = useServerFn(loadListPosts)
|
||||
const search = useServerFn(searchPosts)
|
||||
const thread = useServerFn(loadThreadPosts)
|
||||
return (request: FeedRequest) =>
|
||||
createPostFeedOptions(request, {
|
||||
loadUser,
|
||||
loadList,
|
||||
search,
|
||||
thread,
|
||||
})
|
||||
}
|
||||
|
||||
export function getPostFeedData(
|
||||
client: QueryClient,
|
||||
options: ReturnType<typeof createPostFeedOptions>,
|
||||
) {
|
||||
const state = client.getQueryState(options.queryKey)
|
||||
// Profile changes invalidate and refresh an existing feed. Wait for that
|
||||
// refresh instead of returning the previous profile's cached posts.
|
||||
return state?.fetchStatus === 'fetching' || state?.isInvalidated
|
||||
? client.fetchInfiniteQuery(options)
|
||||
: client.ensureInfiniteQueryData(options)
|
||||
}
|
||||
|
||||
export function usePostFeed(request: FeedRequest | undefined) {
|
||||
const options = usePostFeedOptions()
|
||||
const disabled = {
|
||||
kind: 'user',
|
||||
target: '',
|
||||
} satisfies FeedRequest
|
||||
|
||||
return useInfiniteQuery({
|
||||
...createPostFeedOptions(request ?? disabled, {
|
||||
loadUser,
|
||||
loadList,
|
||||
search,
|
||||
thread,
|
||||
}),
|
||||
...options(request ?? disabled),
|
||||
enabled: request !== undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Post, ThreadPage } from './types'
|
||||
import { type FeedRequest, PostLoadError } from './use-post-feed'
|
||||
import {
|
||||
feedResult,
|
||||
prepareSearch,
|
||||
readToolInput,
|
||||
toolResult,
|
||||
} from './webmcp-contracts'
|
||||
|
||||
const post = (id: string): Post => ({
|
||||
id,
|
||||
text: `Post ${id}`,
|
||||
author: { username: 'reader', name: 'Reader' },
|
||||
})
|
||||
|
||||
const request = { kind: 'user', target: 'reader' } satisfies FeedRequest
|
||||
|
||||
describe('WebMCP search input', () => {
|
||||
it('defaults to chronological search without adding filters', () => {
|
||||
expect(prepareSearch({ q: ' hello ' })).toMatchObject({
|
||||
search: { q: 'hello', product: 'Latest', following: false },
|
||||
request: {
|
||||
kind: 'search',
|
||||
query: 'hello',
|
||||
product: 'Latest',
|
||||
following: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('compiles the same author, date, content, and exclusion filters as the UI', () => {
|
||||
expect(
|
||||
prepareSearch({
|
||||
q: 'release',
|
||||
from: '@reader',
|
||||
since: '2026-09-01',
|
||||
until: '2026-09-24',
|
||||
lang: 'ja',
|
||||
content: 'links',
|
||||
excludeReplies: true,
|
||||
excludeReposts: true,
|
||||
product: 'Top',
|
||||
following: true,
|
||||
}).request,
|
||||
).toEqual({
|
||||
kind: 'search',
|
||||
query:
|
||||
'release from:reader since:2026-09-01 until:2026-09-24 lang:ja filter:links -filter:replies -filter:retweets filter:follows',
|
||||
product: 'Top',
|
||||
following: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts an author as the deliberate search intent', () => {
|
||||
expect(prepareSearch({ from: 'reader' }).request.query).toBe('from:reader')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an impossible date', { q: 'hello', since: '2026-02-30' }],
|
||||
['an invalid date format', { q: 'hello', until: 'yesterday' }],
|
||||
[
|
||||
'an inverted period',
|
||||
{ q: 'hello', since: '2026-09-24', until: '2026-09-01' },
|
||||
],
|
||||
[
|
||||
'an empty period',
|
||||
{ q: 'hello', since: '2026-09-24', until: '2026-09-24' },
|
||||
],
|
||||
['missing search intent', {}],
|
||||
['whitespace-only intent', { q: ' ', from: ' ' }],
|
||||
['an unrecognized property', { q: 'hello', profile: 'other' }],
|
||||
])('rejects %s', (_description, input) => {
|
||||
expect(() => prepareSearch(input)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebMCP read input', () => {
|
||||
it('defaults to the first 20 loaded posts', () => {
|
||||
expect(readToolInput.parse({})).toEqual({ offset: 0, limit: 20 })
|
||||
})
|
||||
|
||||
it('accepts the maximum batch size', () => {
|
||||
expect(readToolInput.parse({ offset: 10, limit: 50 })).toEqual({
|
||||
offset: 10,
|
||||
limit: 50,
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ limit: 0 },
|
||||
{ limit: 51 },
|
||||
{ limit: 1.5 },
|
||||
{ limit: '20' },
|
||||
{ offset: -1 },
|
||||
{ offset: 0.5 },
|
||||
{ cursor: 'upstream-cursor' },
|
||||
])('rejects invalid pagination %j', (input) => {
|
||||
expect(readToolInput.safeParse(input).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebMCP feed output', () => {
|
||||
it('bounds post text and indicates when text was truncated', () => {
|
||||
const result = feedResult(request, [
|
||||
{
|
||||
tweets: [
|
||||
{ ...post('1'), text: 'a'.repeat(2001) },
|
||||
{ ...post('2'), text: 'b'.repeat(2000) },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result.posts).toEqual([
|
||||
{
|
||||
id: '1',
|
||||
author: { username: 'reader', name: 'Reader' },
|
||||
text: 'a'.repeat(2000),
|
||||
textTruncated: true,
|
||||
createdAt: undefined,
|
||||
url: 'https://x.com/reader/status/1',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
author: { username: 'reader', name: 'Reader' },
|
||||
text: 'b'.repeat(2000),
|
||||
textTruncated: false,
|
||||
createdAt: undefined,
|
||||
url: 'https://x.com/reader/status/2',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('deduplicates loaded pages before applying the requested offset and limit', () => {
|
||||
const result = feedResult(
|
||||
request,
|
||||
[
|
||||
{ tweets: [post('1'), post('2')], nextCursor: 'next' },
|
||||
{ tweets: [post('2'), post('3'), post('4')] },
|
||||
],
|
||||
1,
|
||||
2,
|
||||
)
|
||||
|
||||
expect(result.posts.map(({ id }) => id)).toEqual(['2', '3'])
|
||||
expect(result).toMatchObject({
|
||||
loadedCount: 4,
|
||||
offset: 1,
|
||||
nextOffset: 3,
|
||||
hasMore: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('includes the focal post once before the deduplicated conversation', () => {
|
||||
const focal = post('2')
|
||||
const pages: ThreadPage[] = [
|
||||
{
|
||||
focalPost: focal,
|
||||
conversationId: '1',
|
||||
tweets: [post('1'), focal],
|
||||
nextCursor: 'next',
|
||||
},
|
||||
{ conversationId: '1', tweets: [focal, post('3'), post('1')] },
|
||||
]
|
||||
|
||||
const result = feedResult({ kind: 'thread', tweetId: '2' }, pages)
|
||||
|
||||
expect(result.posts.map(({ id }) => id)).toEqual(['2', '1', '3'])
|
||||
expect(result.loadedCount).toBe(3)
|
||||
})
|
||||
|
||||
it('reports upstream continuation separately from remaining loaded posts', () => {
|
||||
const result = feedResult(request, [
|
||||
{ tweets: [post('1')], nextCursor: 'more-on-server' },
|
||||
])
|
||||
|
||||
expect(result).toMatchObject({
|
||||
loadedCount: 1,
|
||||
nextOffset: null,
|
||||
hasMore: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an empty batch when the offset is beyond the loaded posts', () => {
|
||||
expect(
|
||||
feedResult(request, [{ tweets: [post('1')] }], 10, 20),
|
||||
).toMatchObject({
|
||||
posts: [],
|
||||
loadedCount: 1,
|
||||
offset: 10,
|
||||
nextOffset: null,
|
||||
hasMore: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebMCP execution result', () => {
|
||||
it.each([
|
||||
true,
|
||||
false,
|
||||
])('preserves a load failure with retryable=%s', async (retryable) => {
|
||||
const detail = {
|
||||
code: 'upstream' as const,
|
||||
message: 'Relay unavailable',
|
||||
retryable,
|
||||
}
|
||||
|
||||
const result = await toolResult(() =>
|
||||
Promise.reject(new PostLoadError(detail)),
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: JSON.stringify(detail) }],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns invalid-input for schema validation failures', async () => {
|
||||
const result = await toolResult(() => readToolInput.parse({ limit: 100 }))
|
||||
|
||||
expect(result).toMatchObject({ isError: true })
|
||||
expect(JSON.parse(result.content[0].text)).toMatchObject({
|
||||
code: 'invalid-input',
|
||||
retryable: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps successful empty results distinguishable from failures', async () => {
|
||||
const result = await toolResult(() => feedResult(request, [{ tweets: [] }]))
|
||||
|
||||
expect(result).not.toHaveProperty('isError')
|
||||
expect(JSON.parse(result.content[0].text)).toEqual({
|
||||
request,
|
||||
posts: [],
|
||||
loadedCount: 0,
|
||||
offset: 0,
|
||||
nextOffset: null,
|
||||
hasMore: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import { z } from 'zod'
|
||||
import { buildFilteredSearchQuery, InputError } from './inputs'
|
||||
import {
|
||||
flattenConversationPages,
|
||||
flattenPostPages,
|
||||
focalPostFromPages,
|
||||
} from './page'
|
||||
import type { Post, PostPage, ThreadPage } from './types'
|
||||
import { type FeedRequest, PostLoadError } from './use-post-feed'
|
||||
|
||||
export const searchToolInput = z
|
||||
.object({
|
||||
q: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(512)
|
||||
.default('')
|
||||
.describe(
|
||||
'Search text; raw X search operators are supported. Provide q or from.',
|
||||
),
|
||||
from: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(16)
|
||||
.default('')
|
||||
.describe('Author handle, with or without @.'),
|
||||
since: z
|
||||
.string()
|
||||
.default('')
|
||||
.describe(
|
||||
'Inclusive start date, YYYY-MM-DD, using X search date semantics.',
|
||||
),
|
||||
until: z
|
||||
.string()
|
||||
.default('')
|
||||
.describe('Exclusive end date, YYYY-MM-DD; must be after since.'),
|
||||
lang: z.enum(['all', 'ja', 'en']).default('all'),
|
||||
content: z.enum(['all', 'images', 'videos', 'links']).default('all'),
|
||||
excludeReplies: z.boolean().default(false),
|
||||
excludeReposts: z.boolean().default(false),
|
||||
product: z
|
||||
.enum(['Latest', 'Top'])
|
||||
.default('Latest')
|
||||
.describe('Latest is chronological; Top uses X ranking.'),
|
||||
following: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe('Restrict to accounts followed by the active relay profile.'),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const readToolInput = z
|
||||
.object({
|
||||
offset: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.default(0)
|
||||
.describe('Offset into the currently loaded posts, starting at zero.'),
|
||||
limit: z.number().int().min(1).max(50).default(20),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const emptyToolInput = z.object({}).strict()
|
||||
|
||||
export function prepareSearch(input: unknown) {
|
||||
const search = searchToolInput.parse(input)
|
||||
const request = {
|
||||
kind: 'search',
|
||||
query: buildFilteredSearchQuery(search, search.following),
|
||||
product: search.product,
|
||||
following: search.following,
|
||||
} satisfies FeedRequest
|
||||
return { search, request }
|
||||
}
|
||||
|
||||
function summarizePost(post: Post) {
|
||||
return {
|
||||
id: post.id,
|
||||
author: { username: post.author.username, name: post.author.name },
|
||||
text: post.text.slice(0, 2000),
|
||||
textTruncated: post.text.length > 2000,
|
||||
createdAt: post.createdAt,
|
||||
url: `https://x.com/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function loadedPosts(
|
||||
request: FeedRequest,
|
||||
pages: Array<PostPage | ThreadPage>,
|
||||
) {
|
||||
const focal =
|
||||
request.kind === 'thread' ? focalPostFromPages(pages) : undefined
|
||||
return focal
|
||||
? [focal, ...flattenConversationPages(pages, focal.id)]
|
||||
: flattenPostPages(pages)
|
||||
}
|
||||
|
||||
export function feedResult(
|
||||
request: FeedRequest,
|
||||
pages: Array<PostPage | ThreadPage>,
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
) {
|
||||
const posts = loadedPosts(request, pages)
|
||||
const selected = posts.slice(offset, offset + limit)
|
||||
const nextOffset = offset + selected.length
|
||||
return {
|
||||
request,
|
||||
posts: selected.map(summarizePost),
|
||||
loadedCount: posts.length,
|
||||
offset,
|
||||
nextOffset: nextOffset < posts.length ? nextOffset : null,
|
||||
hasMore: Boolean(pages.at(-1)?.nextCursor),
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the tool's failure distinguishable from a successful empty result.
|
||||
export async function toolResult(run: () => unknown | Promise<unknown>) {
|
||||
try {
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text' as const, text: JSON.stringify(await run()) },
|
||||
] as const,
|
||||
}
|
||||
} catch (error) {
|
||||
const detail =
|
||||
error instanceof PostLoadError
|
||||
? error.detail
|
||||
: {
|
||||
code:
|
||||
error instanceof z.ZodError || error instanceof InputError
|
||||
? 'invalid-input'
|
||||
: 'tool-error',
|
||||
message:
|
||||
error instanceof Error ? error.message : 'Tool execution failed.',
|
||||
retryable: false,
|
||||
}
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{ type: 'text' as const, text: JSON.stringify(detail) },
|
||||
] as const,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useRouter } from '@tanstack/react-router'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useWebMCP } from 'usewebmcp'
|
||||
import {
|
||||
type FeedRequest,
|
||||
getPostFeedData,
|
||||
PostLoadError,
|
||||
type usePostFeed,
|
||||
usePostFeedOptions,
|
||||
} from './use-post-feed'
|
||||
import {
|
||||
emptyToolInput,
|
||||
feedResult,
|
||||
loadedPosts,
|
||||
prepareSearch,
|
||||
readToolInput,
|
||||
searchToolInput,
|
||||
toolResult,
|
||||
} from './webmcp-contracts'
|
||||
|
||||
function useWebMCPSupported() {
|
||||
const [supported, setSupported] = useState(false)
|
||||
useEffect(() => {
|
||||
setSupported(Boolean(document.modelContext))
|
||||
}, [])
|
||||
return supported
|
||||
}
|
||||
|
||||
export function SearchTool() {
|
||||
const supported = useWebMCPSupported()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const options = usePostFeedOptions()
|
||||
const searching = useRef(false)
|
||||
|
||||
useWebMCP({
|
||||
name: 'search_posts',
|
||||
description:
|
||||
'Search X posts and return the first result slice. Updates the visible search page and its filters. Uses the active relay profile. Returns post text, authors, dates, source URLs and pagination information.',
|
||||
inputSchema: searchToolInput,
|
||||
enabled: supported,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
untrustedContentHint: true,
|
||||
},
|
||||
execute: (input) =>
|
||||
toolResult(async () => {
|
||||
const { search, request } = prepareSearch(input)
|
||||
if (searching.current)
|
||||
throw new Error('A search is already running. Wait for it to finish.')
|
||||
searching.current = true
|
||||
try {
|
||||
await router.navigate({ to: '/search', search })
|
||||
const href = router.buildLocation({ to: '/search', search }).href
|
||||
if (router.state.location.href !== href)
|
||||
throw new Error(
|
||||
'The page changed. Search again from the current page.',
|
||||
)
|
||||
// Join the UI request (or use its completed data), rather than fetch twice.
|
||||
const result = await getPostFeedData(queryClient, options(request))
|
||||
if (router.state.location.href !== href)
|
||||
throw new Error(
|
||||
'The page changed while searching. Read the current feed or search again.',
|
||||
)
|
||||
return feedResult(request, result.pages)
|
||||
} finally {
|
||||
searching.current = false
|
||||
}
|
||||
}),
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
export function useFeedTools(
|
||||
request: FeedRequest | undefined,
|
||||
query: ReturnType<typeof usePostFeed>,
|
||||
) {
|
||||
const supported = useWebMCPSupported()
|
||||
const generation = useRef(0)
|
||||
const requestKey = JSON.stringify(request)
|
||||
useEffect(() => {
|
||||
// Executions belong to the feed that was visible when they started.
|
||||
void requestKey
|
||||
generation.current += 1
|
||||
return () => {
|
||||
generation.current += 1
|
||||
}
|
||||
}, [requestKey])
|
||||
|
||||
useWebMCP({
|
||||
name: 'get_loaded_posts',
|
||||
description:
|
||||
'Read a slice of posts already loaded in the current timeline, search or conversation without a network request. Includes the selected conversation post. Returns active criteria, loading status, source URLs, nextOffset for remaining loaded posts and hasMore for upstream continuation.',
|
||||
inputSchema: readToolInput,
|
||||
enabled: supported && Boolean(request),
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
untrustedContentHint: true,
|
||||
},
|
||||
execute: (input) =>
|
||||
toolResult(() => {
|
||||
const { offset, limit } = readToolInput.parse(input)
|
||||
if (!request)
|
||||
throw new Error('Open a timeline, search or conversation first.')
|
||||
return {
|
||||
...feedResult(request, query.data?.pages ?? [], offset, limit),
|
||||
status: query.isPending
|
||||
? 'loading'
|
||||
: query.isError
|
||||
? 'error'
|
||||
: 'ready',
|
||||
loading: query.isFetching,
|
||||
error:
|
||||
query.error instanceof PostLoadError ? query.error.detail : null,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
useWebMCP({
|
||||
name: 'load_more_posts',
|
||||
description:
|
||||
'Load one continuation page into the current feed and return only newly appended posts. Joins an in-progress scroll request. Returns nextOffset if additional loaded posts remain outside this response. At the end returns an empty posts array with hasMore false. Can retry a failed continuation.',
|
||||
inputSchema: emptyToolInput,
|
||||
enabled: supported && Boolean(request),
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
untrustedContentHint: true,
|
||||
},
|
||||
execute: (input) =>
|
||||
toolResult(async () => {
|
||||
emptyToolInput.parse(input)
|
||||
if (!request || query.isPending)
|
||||
throw new Error(
|
||||
'Wait for the initial feed to load before requesting more posts.',
|
||||
)
|
||||
if (query.isError && !query.isFetchNextPageError) throw query.error
|
||||
if (query.isFetching && !query.isFetchingNextPage)
|
||||
throw new Error(
|
||||
'The feed is refreshing. Wait before requesting more posts.',
|
||||
)
|
||||
const pages = query.data?.pages ?? []
|
||||
const offset = loadedPosts(request, pages).length
|
||||
if (!query.hasNextPage) return feedResult(request, pages, offset)
|
||||
const started = generation.current
|
||||
const result = await query.fetchNextPage({ cancelRefetch: false })
|
||||
if (generation.current !== started)
|
||||
throw new Error(
|
||||
'The feed changed while loading. Read the current feed before requesting more posts.',
|
||||
)
|
||||
if (result.isError) throw result.error
|
||||
return feedResult(request, result.data?.pages ?? [], offset)
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -2,8 +2,10 @@ import type { QueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
createRootRouteWithContext,
|
||||
HeadContent,
|
||||
Outlet,
|
||||
Scripts,
|
||||
} from '@tanstack/react-router'
|
||||
import { SearchTool } from '#/features/posts/webmcp-tools'
|
||||
import appCss from '../styles.css?url'
|
||||
|
||||
type RouterContext = { queryClient: QueryClient }
|
||||
@@ -28,8 +30,18 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
],
|
||||
}),
|
||||
shellComponent: RootDocument,
|
||||
component: RootContent,
|
||||
})
|
||||
|
||||
function RootContent() {
|
||||
return (
|
||||
<>
|
||||
<SearchTool />
|
||||
<Outlet />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="ja">
|
||||
|
||||
Reference in New Issue
Block a user