Files
xinmi/packages/dsh-weknora/src/client.ts
T
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

434 lines
15 KiB
TypeScript

/** Minimal WeKnora REST client: JSON endpoints plus the SSE chat streams. */
import type { ResolvedConfig } from './config.ts'
/** One retrieval hit, as returned by WeKnora's `SearchResult`. */
export interface SearchResult {
id?: string
content?: string
knowledge_id?: string
chunk_index?: number
knowledge_title?: string
knowledge_filename?: string
score?: number
match_type?: number | string
[key: string]: unknown
}
/** A knowledge base as listed by `GET /knowledge-bases`. */
export interface KnowledgeBaseSummary {
id?: string
name?: string
description?: string
[key: string]: unknown
}
/** One stored chunk as returned by `GET /chunks/:knowledge_id`. */
export interface ChunkRecord {
id?: string
content?: string
chunk_index?: number
knowledge_id?: string
[key: string]: unknown
}
/** A document as returned by the by-name search and by `GET /knowledge/:id`. */
export interface DocumentRecord {
id?: string
title?: string
file_name?: string
file_type?: string
description?: string
knowledge_base_id?: string
knowledge_base_name?: string
[key: string]: unknown
}
/** A page of chunks plus its pagination envelope. */
export interface ChunkPage {
chunks: ChunkRecord[]
total: number
page: number
pageSize: number
}
/** The assembled outcome of one streamed answer. */
export interface StreamedAnswer {
answer: string
references: SearchResult[]
sessionId: string
toolCalls: string[]
}
/** Raised for any non-2xx response or transport failure, with the key redacted. */
export class WeknoraApiError extends Error {
readonly status: number | undefined
constructor(message: string, status?: number) {
super(message)
this.name = 'WeknoraApiError'
this.status = status
}
}
interface Envelope<T> {
success?: boolean
data?: T
error?: unknown
message?: string
total?: number
page?: number
page_size?: number
}
/** Trim a response body for an error message without leaking a whole page. */
function snippet(body: string): string {
const collapsed = body.replace(/\s+/g, ' ').trim()
return collapsed.length > 300 ? `${collapsed.slice(0, 300)}…` : collapsed
}
/** Extract a human-readable reason from WeKnora's error envelope. */
function reasonOf(body: string): string {
try {
const parsed = JSON.parse(body) as { error?: { message?: string } | string, message?: string }
if (typeof parsed.error === 'string') return parsed.error
if (parsed.error?.message !== undefined) return parsed.error.message
if (parsed.message !== undefined) return parsed.message
} catch {
// Fall through to the raw snippet: a proxy may answer with HTML.
}
return snippet(body)
}
/**
* Combine the caller's cancellation with this call's deadline. The tool body
* must abort when the harness aborts, and a hung backend must not hold a turn
* open forever.
*/
function deadline(signal: AbortSignal, timeoutMs: number): AbortSignal {
return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)])
}
export class WeknoraClient {
/**
* A knowledge-base-restricted API key rejects `resource_urls=public` with
* 403. After the first such refusal this client stays on handle mode so
* later calls do not pay the same round trip.
*/
private publicModeBlocked = false
constructor(private readonly config: ResolvedConfig) {}
private headers(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json',
}
if (this.config.apiKey !== undefined) headers['X-API-Key'] = this.config.apiKey
if (this.config.tenantId !== undefined) headers['X-Tenant-ID'] = this.config.tenantId
return headers
}
private resourceMode(): 'handle' | 'public' {
return this.config.resourceUrls === 'public' && !this.publicModeBlocked ? 'public' : 'handle'
}
private url(path: string, query?: Record<string, string | number | undefined>): string {
const url = new URL(`${this.config.baseUrl}${path}`)
for (const [key, value] of Object.entries(query ?? {})) {
if (value !== undefined) url.searchParams.set(key, String(value))
}
if (this.resourceMode() === 'public') url.searchParams.set('resource_urls', 'public')
return url.toString()
}
private isPublicModeForbidden(error: unknown): boolean {
return error instanceof WeknoraApiError
&& this.config.resourceUrls === 'public'
&& !this.publicModeBlocked
&& error.status === 403
&& error.message.includes('resource_urls=public')
}
private async fetchJson<T>(
path: string,
init: { method: 'GET' | 'POST', body?: unknown, query?: Record<string, string | number | undefined> },
signal: AbortSignal,
): Promise<Envelope<T>> {
try {
return await this.fetchJsonOnce(path, init, signal)
} catch (error) {
if (!this.isPublicModeForbidden(error)) throw error
this.publicModeBlocked = true
return await this.fetchJsonOnce(path, init, signal)
}
}
private async fetchJsonOnce<T>(
path: string,
init: { method: 'GET' | 'POST', body?: unknown, query?: Record<string, string | number | undefined> },
signal: AbortSignal,
): Promise<Envelope<T>> {
const url = this.url(path, init.query)
let response: Response
try {
response = await fetch(url, {
method: init.method,
headers: this.headers(),
...init.body === undefined ? {} : { body: JSON.stringify(init.body) },
signal: deadline(signal, this.config.requestTimeoutMs),
})
} catch (cause) {
throw new WeknoraApiError(`${init.method} ${path} failed: ${describeTransportFailure(cause, signal)}`)
}
const text = await response.text()
if (!response.ok) {
throw new WeknoraApiError(`${init.method} ${path} failed with HTTP ${response.status}: ${reasonOf(text)}`, response.status)
}
try {
return JSON.parse(text) as Envelope<T>
} catch {
throw new WeknoraApiError(`${init.method} ${path} returned a non-JSON body: ${snippet(text)}`, response.status)
}
}
/** List the knowledge bases this credential can retrieve from. */
async listKnowledgeBases(signal: AbortSignal): Promise<KnowledgeBaseSummary[]> {
const envelope = await this.fetchJson<KnowledgeBaseSummary[]>('/knowledge-bases', { method: 'GET' }, signal)
return Array.isArray(envelope.data) ? envelope.data : []
}
/** Hybrid (vector + keyword) retrieval across one or more knowledge bases. */
async search(
input: { query: string, knowledgeBaseIds: string[], knowledgeIds: string[] },
signal: AbortSignal,
): Promise<SearchResult[]> {
const envelope = await this.fetchJson<SearchResult[]>('/knowledge-search', {
method: 'POST',
body: {
query: input.query,
...input.knowledgeBaseIds.length > 0 ? { knowledge_base_ids: input.knowledgeBaseIds } : {},
...input.knowledgeIds.length > 0 ? { knowledge_ids: input.knowledgeIds } : {},
},
}, signal)
return Array.isArray(envelope.data) ? envelope.data : []
}
/**
* Find documents by title or filename. Unlike `search` this spans every
* knowledge base the credential can see and needs no scope, so it is what
* answers "where is the document called X".
*/
async findDocuments(
input: { keyword: string, limit: number },
signal: AbortSignal,
): Promise<DocumentRecord[]> {
const envelope = await this.fetchJson<DocumentRecord[]>('/knowledge/search', {
method: 'GET',
query: { keyword: input.keyword, limit: input.limit },
}, signal)
return Array.isArray(envelope.data) ? envelope.data : []
}
/** A document's metadata, including the generated summary WeKnora stores as `description`. */
async getDocument(knowledgeId: string, signal: AbortSignal): Promise<DocumentRecord> {
const envelope = await this.fetchJson<DocumentRecord>(
`/knowledge/${encodeURIComponent(knowledgeId)}`,
{ method: 'GET' },
signal,
)
return envelope.data ?? {}
}
/** One page of a document's stored chunks, in storage order. */
async listChunks(
input: { knowledgeId: string, page: number, pageSize: number },
signal: AbortSignal,
): Promise<ChunkPage> {
const envelope = await this.fetchJson<ChunkRecord[]>(`/chunks/${encodeURIComponent(input.knowledgeId)}`, {
method: 'GET',
query: { page: input.page, page_size: input.pageSize },
}, signal)
const chunks = Array.isArray(envelope.data) ? envelope.data : []
return {
chunks,
total: typeof envelope.total === 'number' ? envelope.total : chunks.length,
page: typeof envelope.page === 'number' ? envelope.page : input.page,
pageSize: typeof envelope.page_size === 'number' ? envelope.page_size : input.pageSize,
}
}
/** Create a conversation container to hold one or more asked questions. */
async createSession(title: string, signal: AbortSignal): Promise<string> {
const envelope = await this.fetchJson<{ id?: string }>('/sessions', {
method: 'POST',
body: { title },
}, signal)
const id = envelope.data?.id
if (typeof id !== 'string' || id === '') {
throw new WeknoraApiError('POST /sessions returned no session id')
}
return id
}
/**
* Ask a question and assemble the streamed answer. `agentId` selects the
* ReAct pipeline (`/agent-chat`); without it the RAG pipeline answers
* (`/knowledge-chat`).
*/
async ask(
input: {
sessionId: string
query: string
knowledgeBaseIds: string[]
agentId: string | undefined
webSearch: boolean
},
signal: AbortSignal,
): Promise<StreamedAnswer> {
const route = input.agentId === undefined ? 'knowledge-chat' : 'agent-chat'
const path = `/${route}/${encodeURIComponent(input.sessionId)}`
const body = {
query: input.query,
channel: 'api',
...input.knowledgeBaseIds.length > 0 ? { knowledge_base_ids: input.knowledgeBaseIds } : {},
...input.agentId === undefined ? {} : { agent_id: input.agentId, agent_enabled: true },
...input.webSearch ? { web_search_enabled: true } : {},
}
const open = async (): Promise<Response> => {
const combined = deadline(signal, this.config.chatTimeoutMs)
try {
return await fetch(this.url(path), {
method: 'POST',
headers: { ...this.headers(), Accept: 'text/event-stream' },
body: JSON.stringify(body),
signal: combined,
})
} catch (cause) {
throw new WeknoraApiError(`POST ${path} failed: ${describeTransportFailure(cause, signal)}`)
}
}
let response = await open()
if (!response.ok) {
const text = await response.text()
const error = new WeknoraApiError(`POST ${path} failed with HTTP ${response.status}: ${reasonOf(text)}`, response.status)
if (!this.isPublicModeForbidden(error)) throw error
this.publicModeBlocked = true
response = await open()
}
if (!response.ok) {
const text = await response.text()
throw new WeknoraApiError(`POST ${path} failed with HTTP ${response.status}: ${reasonOf(text)}`, response.status)
}
if (response.body === null) {
throw new WeknoraApiError(`POST ${path} returned no response body`)
}
return await assembleStream(response.body, input.sessionId, path, signal)
}
}
/** Turn an aborted or refused fetch into a message that names the real cause. */
function describeTransportFailure(cause: unknown, callerSignal: AbortSignal): string {
if (cause instanceof Error && cause.name === 'AbortError') {
return callerSignal.aborted ? 'the call was cancelled' : 'the request timed out'
}
if (cause instanceof Error && cause.name === 'TimeoutError') return 'the request timed out'
return cause instanceof Error ? cause.message : String(cause)
}
/** One decoded `data:` payload of the WeKnora stream. */
interface StreamEvent {
response_type?: string
content?: string
knowledge_references?: SearchResult[]
session_id?: string
tool_calls?: { function?: { name?: string }, name?: string }[]
}
/**
* Consume WeKnora's `text/event-stream` and assemble the parts a tool result
* needs: the answer text, its citations, and the tool names the agent used.
* Server-side `error` events become a thrown failure so the model is never
* handed a silently empty answer.
*/
async function assembleStream(
body: ReadableStream<Uint8Array>,
sessionId: string,
path: string,
callerSignal: AbortSignal,
): Promise<StreamedAnswer> {
const decoder = new TextDecoder()
const reader = body.getReader()
const answer: string[] = []
const toolCalls: string[] = []
let references: SearchResult[] = []
let resolvedSessionId = sessionId
let buffer = ''
let completed = false
const consumeLine = (line: string): void => {
if (!line.startsWith('data:')) return
const payload = line.slice(5).trim()
if (payload === '' || payload === '[DONE]') return
let event: StreamEvent
try {
event = JSON.parse(payload) as StreamEvent
} catch {
return
}
if (typeof event.session_id === 'string' && event.session_id !== '') resolvedSessionId = event.session_id
switch (event.response_type) {
case 'answer':
if (typeof event.content === 'string') answer.push(event.content)
break
case 'references':
if (Array.isArray(event.knowledge_references)) references = event.knowledge_references
break
case 'tool_call':
for (const call of event.tool_calls ?? []) {
const name = call.function?.name ?? call.name
if (typeof name === 'string' && name !== '' && !toolCalls.includes(name)) toolCalls.push(name)
}
break
case 'error':
throw new WeknoraApiError(`POST ${path} streamed an error: ${event.content ?? 'unknown error'}`)
case 'complete':
completed = true
break
default:
break
}
}
try {
while (!completed) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let newline = buffer.indexOf('\n')
while (newline >= 0) {
consumeLine(buffer.slice(0, newline).replace(/\r$/, ''))
buffer = buffer.slice(newline + 1)
if (completed) break
newline = buffer.indexOf('\n')
}
}
if (!completed && buffer !== '') consumeLine(buffer.replace(/\r$/, ''))
} catch (cause) {
if (cause instanceof WeknoraApiError) throw cause
throw new WeknoraApiError(`POST ${path} stream failed: ${describeTransportFailure(cause, callerSignal)}`)
} finally {
await reader.cancel().catch(() => undefined)
}
// WeKnora ends every answer with a `complete` event. Without it the stream was
// cut short, and handing the model the partial text would present a truncated
// answer as a whole one.
if (!completed) {
throw new WeknoraApiError(`POST ${path} ended before WeKnora completed the answer; `
+ `${answer.join('').length} character(s) had streamed`)
}
return { answer: answer.join(''), references, sessionId: resolvedSessionId, toolCalls }
}