feat(web): add basic past-session search (round 1)
This commit is contained in:
@@ -11,6 +11,7 @@ import type { Agent, AgentMessage, AgentMessageId, AgentStatus } from '@deepseek
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
|
||||
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
|
||||
import {
|
||||
@@ -20,8 +21,8 @@ import {
|
||||
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
|
||||
WorkspaceId, WorkspaceView,
|
||||
ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSearchItem,
|
||||
SessionSummary, ToolEventView, WorkspaceId, WorkspaceView,
|
||||
} from './api/index.ts'
|
||||
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
@@ -37,9 +38,17 @@ import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
|
||||
/** Page size when history is called without maxMessages. */
|
||||
const DEFAULT_MAX_MESSAGES = 50
|
||||
|
||||
/** Product contract: sidebar search returns one bounded page and no cursor. */
|
||||
const SESSION_SEARCH_LIMIT = 20
|
||||
|
||||
/** Surface message event types (the pagination counting unit). */
|
||||
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
|
||||
|
||||
/** Read live abort state across awaits without treating it as synchronously immutable. */
|
||||
function isAborted(signal: AbortSignal): boolean {
|
||||
return signal.aborted
|
||||
}
|
||||
|
||||
/**
|
||||
* Message-boundary pagination: count maxMessages surface messages backwards from
|
||||
* the window tail; the cut is the starting seq of the oldest message group
|
||||
@@ -561,6 +570,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
return operation
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the session.list baseline shared by listing and search visibility.
|
||||
* Attached sessions come from memory; servable cold sessions merge from
|
||||
* persistence, and the final order is newest-first.
|
||||
*/
|
||||
async function listVisibleSessionSummaries(): Promise<SessionSummary[]> {
|
||||
const items = ctx.sessions.list().map((session) => {
|
||||
const agent = ctx.agents.get(session.id)
|
||||
return summarize(session, agent?.status === 'running')
|
||||
})
|
||||
const attached = new Set(items.map(item => item.sessionId))
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
if (persistence !== undefined) {
|
||||
const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
|
||||
items.push(...await Promise.all(cold.map(meta => summarizeCold(persistence, meta))))
|
||||
}
|
||||
items.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
return items
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: {
|
||||
// Attached sessions summarize from memory; persisted-but-unattached (cold)
|
||||
@@ -568,18 +597,61 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// Legacy logs without a cwd (pre-project stance) are not served — every
|
||||
// session now records its project at create time.
|
||||
async list(request) {
|
||||
const items = ctx.sessions.list().map((session) => {
|
||||
const agent = ctx.agents.get(session.id)
|
||||
return summarize(session, agent?.status === 'running')
|
||||
return ok(request, { items: await listVisibleSessionSummaries() })
|
||||
},
|
||||
|
||||
async search(request, signal) {
|
||||
const cancelled = () => err<{ items: SessionSearchItem[]; hasMore: boolean }>(request, {
|
||||
code: 'cancelled',
|
||||
message: 'session search was aborted',
|
||||
details: {},
|
||||
})
|
||||
const attached = new Set(items.map(item => item.sessionId))
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
if (persistence !== undefined) {
|
||||
const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
|
||||
items.push(...await Promise.all(cold.map(meta => summarizeCold(persistence, meta))))
|
||||
if (isAborted(signal)) return cancelled()
|
||||
const sessionQuery = ctx.get('sessionQuery')
|
||||
if (sessionQuery === undefined) {
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: 'session search is unavailable: this deployment does not mount @deepseek-ai/dsh-session-query',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
try {
|
||||
const visible = await listVisibleSessionSummaries()
|
||||
if (isAborted(signal)) return cancelled()
|
||||
if (visible.length === 0) return ok(request, { items: [], hasMore: false })
|
||||
const visibleIds = new Set(visible.map(item => item.sessionId))
|
||||
const page = await sessionQuery.searchSessions({
|
||||
query: request.payload.query,
|
||||
sessionFilters: [{ kind: 'id', values: [...visibleIds] }],
|
||||
eventFilters: [
|
||||
{ kind: 'type', values: ['user/message', 'assistant/message', 'steering/message'] },
|
||||
{ kind: 'surface', values: ['current'] },
|
||||
],
|
||||
limit: SESSION_SEARCH_LIMIT,
|
||||
}, { signal })
|
||||
if (isAborted(signal)) return cancelled()
|
||||
// The id filter is the authorization boundary. Re-check the provider
|
||||
// projection before emitting it so a backend regression cannot leak
|
||||
// a session that `session.list` withheld.
|
||||
const authorized = page.items.filter(hit => visibleIds.has(hit.header.id))
|
||||
return ok(request, {
|
||||
items: authorized.slice(0, SESSION_SEARCH_LIMIT).map(hit => ({
|
||||
sessionId: hit.header.id,
|
||||
snippet: hit.bestMatch.snippet,
|
||||
})),
|
||||
hasMore: page.nextCursor !== undefined || authorized.length > SESSION_SEARCH_LIMIT,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
isAborted(signal)
|
||||
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')
|
||||
) return cancelled()
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: `session search failed: ${String(error)}`,
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
items.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
return ok(request, { items })
|
||||
},
|
||||
|
||||
async create(request) {
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface ApiProxy {
|
||||
}
|
||||
|
||||
// ---- Domain interfaces and payload entities ----
|
||||
export type { HistoryEntry, SessionsApi, SessionSummary } from './sessions.ts'
|
||||
export type { HistoryEntry, SessionSearchItem, SessionsApi, SessionSummary } from './sessions.ts'
|
||||
export type { HostApi } from './host.ts'
|
||||
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
|
||||
export type { CommandsApi, CommandDescriptor, CommandExecuteResult } from './commands.ts'
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { RpcResponse } from './rpc.ts'
|
||||
*/
|
||||
export interface RpcMethodMap {
|
||||
'session.list': SessionsApi['list']
|
||||
'session.search': SessionsApi['search']
|
||||
'session.create': SessionsApi['create']
|
||||
'session.history': SessionsApi['history']
|
||||
'session.prompt': SessionsApi['prompt']
|
||||
|
||||
@@ -9,7 +9,7 @@ import { z } from 'zod'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type { HistoryEntry, SessionSummary } from './sessions.ts'
|
||||
import type { HistoryEntry, SessionSearchItem, SessionSummary } from './sessions.ts'
|
||||
import type { ToolEventView } from './events.ts'
|
||||
import type { WorkspaceId } from './workspace.ts'
|
||||
|
||||
@@ -54,6 +54,29 @@ export const sessionListValueSchema = z.object({
|
||||
items: z.array(sessionSummarySchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.list'>>>
|
||||
|
||||
/** Fixed wire bound for one interactive sidebar query. */
|
||||
const SESSION_SEARCH_QUERY_MAX_CHARS = 500
|
||||
/** Product response bound validated independently by every client carrier. */
|
||||
const SESSION_SEARCH_RESULT_LIMIT = 20
|
||||
|
||||
/** session.search request payload. */
|
||||
export const sessionSearchRequestSchema = z.object({
|
||||
query: z.string().trim().min(1).max(SESSION_SEARCH_QUERY_MAX_CHARS)
|
||||
.refine(query => !query.includes('\0'), { message: 'search query must not contain NUL' }),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.search'>>>
|
||||
|
||||
/** One session.search result. */
|
||||
export const sessionSearchItemSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
snippet: z.string(),
|
||||
}) satisfies z.ZodType<Wire<SessionSearchItem>>
|
||||
|
||||
/** session.search response value. */
|
||||
export const sessionSearchValueSchema = z.object({
|
||||
items: z.array(sessionSearchItemSchema).max(SESSION_SEARCH_RESULT_LIMIT),
|
||||
hasMore: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.search'>>>
|
||||
|
||||
/** session.create request payload (at most one of workspaceId / cwd). */
|
||||
export const sessionCreateRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema.optional(),
|
||||
|
||||
@@ -53,11 +53,28 @@ export interface SessionSummary {
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
/** One session-content search result; display metadata stays owned by `session.list`. */
|
||||
export interface SessionSearchItem {
|
||||
sessionId: SessionId
|
||||
/** Plain-text excerpt around the strongest matching visible message. */
|
||||
snippet: string
|
||||
}
|
||||
|
||||
/** Session-domain unary methods (the map keys session.* of RpcMethodMap). */
|
||||
export interface SessionsApi {
|
||||
/** Lists persisted sessions (updatedAt descending). v1 returns everything; cursor is a reserved seat, unimplemented. */
|
||||
list(request: RpcRequest<{ cursor?: string }>): Promise<RpcResponse<{ items: SessionSummary[] }>>
|
||||
|
||||
/**
|
||||
* Searches the current user/assistant/steering message surface across
|
||||
* sessions visible to `list`. Results contain at most 20 sessions and carry
|
||||
* no continuation cursor; `hasMore` asks the client to refine the query.
|
||||
*/
|
||||
search(
|
||||
request: RpcRequest<{ query: string }>,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>>
|
||||
|
||||
/**
|
||||
* Creates a real session and its idle agent. At most one of `workspaceId` /
|
||||
* `cwd` is accepted; an omitted project uses the Host cwd. A caller may
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
sessionHistoryValueSchema,
|
||||
sessionListValueSchema,
|
||||
sessionPromptValueSchema,
|
||||
sessionSearchValueSchema,
|
||||
} from '../api/sessions.schema.ts'
|
||||
import {
|
||||
workspaceCreateValueSchema,
|
||||
@@ -48,6 +49,7 @@ import { skillListValueSchema } from '../api/skills.schema.ts'
|
||||
export interface IApiClient {
|
||||
sessions: {
|
||||
list(payload: RequestPayload<'session.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.list'>>>
|
||||
search(payload: RequestPayload<'session.search'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.search'>>>
|
||||
create(payload: RequestPayload<'session.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.create'>>>
|
||||
history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.history'>>>
|
||||
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
|
||||
@@ -83,6 +85,7 @@ export interface IApiClient {
|
||||
*/
|
||||
const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseValue<K>>> } = {
|
||||
'session.list': sessionListValueSchema,
|
||||
'session.search': sessionSearchValueSchema,
|
||||
'session.create': sessionCreateValueSchema,
|
||||
'session.history': sessionHistoryValueSchema,
|
||||
'session.prompt': sessionPromptValueSchema,
|
||||
@@ -271,6 +274,7 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
|
||||
readonly sessions: IApiClient['sessions'] = {
|
||||
list: (payload, signal) => this.callUnary('session.list', payload, signal),
|
||||
search: (payload, signal) => this.callUnary('session.search', payload, signal),
|
||||
create: (payload, signal) => this.callUnary('session.create', payload, signal),
|
||||
history: (payload, signal) => this.callUnary('session.history', payload, signal),
|
||||
prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
sessionHistoryRequestSchema,
|
||||
sessionListRequestSchema,
|
||||
sessionPromptRequestSchema,
|
||||
sessionSearchRequestSchema,
|
||||
} from '../api/sessions.schema.ts'
|
||||
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
|
||||
import {
|
||||
@@ -38,7 +39,8 @@ import { skillListRequestSchema } from '../api/skills.schema.ts'
|
||||
* Schemas anchor to the Wire<> widening (the repo-wide exactOptionalPropertyTypes accommodation
|
||||
* documented on Wire); the dispatch point carries the one Wire→exact cast.
|
||||
* Every invoke receives the carrier Request's signal; methods whose contract
|
||||
* declares a signal parameter (command.execute) forward it, the rest ignore it.
|
||||
* declares a signal parameter (session.search and command.execute) forward it,
|
||||
* the rest ignore it.
|
||||
*/
|
||||
type UnaryRoutes = {
|
||||
[K in keyof RpcMethodMap]: {
|
||||
@@ -49,6 +51,7 @@ type UnaryRoutes = {
|
||||
|
||||
const UNARY_ROUTES: UnaryRoutes = {
|
||||
'session.list': { schema: sessionListRequestSchema, invoke: (api, r) => api.sessions.list(r) },
|
||||
'session.search': { schema: sessionSearchRequestSchema, invoke: (api, r, signal) => api.sessions.search(r, signal) },
|
||||
'session.create': { schema: sessionCreateRequestSchema, invoke: (api, r) => api.sessions.create(r) },
|
||||
'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) },
|
||||
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
|
||||
|
||||
Reference in New Issue
Block a user