|
@@ -3,6 +3,7 @@
|
|
|
import type {
|
|
import type {
|
|
|
AgentEvent,
|
|
AgentEvent,
|
|
|
ApiErrorBody,
|
|
ApiErrorBody,
|
|
|
|
|
+ BrowserTokenDto,
|
|
|
CreateSessionBody,
|
|
CreateSessionBody,
|
|
|
FileDto,
|
|
FileDto,
|
|
|
InteractionRequest,
|
|
InteractionRequest,
|
|
@@ -12,6 +13,7 @@ import type {
|
|
|
ProfileDto,
|
|
ProfileDto,
|
|
|
ProviderStatusDto,
|
|
ProviderStatusDto,
|
|
|
RunDto,
|
|
RunDto,
|
|
|
|
|
+ UsageSummaryDto,
|
|
|
SendMessageBody,
|
|
SendMessageBody,
|
|
|
SessionDetailDto,
|
|
SessionDetailDto,
|
|
|
SessionDto,
|
|
SessionDto,
|
|
@@ -20,8 +22,13 @@ import type {
|
|
|
export interface AgentClientOptions {
|
|
export interface AgentClientOptions {
|
|
|
/** 服务地址,例如 https://agent.example.com;同源时可为空字符串 */
|
|
/** 服务地址,例如 https://agent.example.com;同源时可为空字符串 */
|
|
|
baseUrl: string
|
|
baseUrl: string
|
|
|
- /** API Key;后台同源使用 Cookie 时可省略 */
|
|
|
|
|
|
|
+ /** API Key:只在业务平台的后端使用,不要放进浏览器代码 */
|
|
|
apiKey?: string
|
|
apiKey?: string
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 浏览器中使用:返回浏览器令牌(由平台后端用 API Key 换取,见 browserTokenProvider)。
|
|
|
|
|
+ * 令牌失效(401)时会以 { refresh: true } 再调用一次并重试
|
|
|
|
|
+ */
|
|
|
|
|
+ getToken?: (opts: { refresh: boolean }) => string | Promise<string>
|
|
|
fetch?: typeof fetch
|
|
fetch?: typeof fetch
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -40,16 +47,29 @@ export class AgentClient {
|
|
|
this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis)
|
|
this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private headers(extra?: Record<string, string>): Record<string, string> {
|
|
|
|
|
- return { ...(this.opts.apiKey ? { authorization: `Bearer ${this.opts.apiKey}` } : {}), ...extra }
|
|
|
|
|
|
|
+ private async headers(extra?: Record<string, string>, refresh = false): Promise<Record<string, string>> {
|
|
|
|
|
+ const token = this.opts.getToken ? await this.opts.getToken({ refresh }) : this.opts.apiKey
|
|
|
|
|
+ return { ...(token ? { authorization: `Bearer ${token}` } : {}), ...extra }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 发请求;使用浏览器令牌时,令牌失效则刷新后重试一次。Cookie 只在同源(后台)时携带 */
|
|
|
|
|
+ private async send(path: string, init: RequestInit & { headers?: Record<string, string> }): Promise<Response> {
|
|
|
|
|
+ const go = async (refresh: boolean) => this.fetchImpl(`${this.opts.baseUrl}${path}`, {
|
|
|
|
|
+ ...init, credentials: 'same-origin', headers: await this.headers(init.headers, refresh),
|
|
|
|
|
+ })
|
|
|
|
|
+ const res = await go(false)
|
|
|
|
|
+ if (res.status === 401 && this.opts.getToken) {
|
|
|
|
|
+ await res.body?.cancel()
|
|
|
|
|
+ return go(true)
|
|
|
|
|
+ }
|
|
|
|
|
+ return res
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
|
const isForm = typeof FormData !== 'undefined' && body instanceof FormData
|
|
const isForm = typeof FormData !== 'undefined' && body instanceof FormData
|
|
|
- const res = await this.fetchImpl(`${this.opts.baseUrl}${path}`, {
|
|
|
|
|
|
|
+ const res = await this.send(path, {
|
|
|
method,
|
|
method,
|
|
|
- credentials: 'include',
|
|
|
|
|
- headers: this.headers(body !== undefined && !isForm ? { 'content-type': 'application/json' } : undefined),
|
|
|
|
|
|
|
+ headers: body !== undefined && !isForm ? { 'content-type': 'application/json' } : undefined,
|
|
|
body: body === undefined ? undefined : isForm ? body : JSON.stringify(body),
|
|
body: body === undefined ? undefined : isForm ? body : JSON.stringify(body),
|
|
|
})
|
|
})
|
|
|
if (!res.ok) {
|
|
if (!res.ok) {
|
|
@@ -61,6 +81,10 @@ export class AgentClient {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
providers = () => this.request<ProviderStatusDto[]>('GET', '/v1/providers')
|
|
providers = () => this.request<ProviderStatusDto[]>('GET', '/v1/providers')
|
|
|
|
|
+ /** 平台后端调用(需要 API Key):签发交给浏览器使用的短期令牌 */
|
|
|
|
|
+ issueBrowserToken = (ttlSeconds?: number) => this.request<BrowserTokenDto>('POST', '/v1/browser-tokens', ttlSeconds === undefined ? {} : { ttlSeconds })
|
|
|
|
|
+ /** 本平台最近 N 天的用量 */
|
|
|
|
|
+ usage = (days = 30) => this.request<UsageSummaryDto>('GET', `/v1/usage?days=${days}`)
|
|
|
profiles = () => this.request<ProfileSummary[]>('GET', '/v1/profiles')
|
|
profiles = () => this.request<ProfileSummary[]>('GET', '/v1/profiles')
|
|
|
|
|
|
|
|
sessions = {
|
|
sessions = {
|
|
@@ -98,7 +122,7 @@ export class AgentClient {
|
|
|
|
|
|
|
|
/** 带鉴权头下载文件为 Blob(API Key 模式下预览图片用) */
|
|
/** 带鉴权头下载文件为 Blob(API Key 模式下预览图片用) */
|
|
|
async fileBlob(fileId: string): Promise<Blob> {
|
|
async fileBlob(fileId: string): Promise<Blob> {
|
|
|
- const res = await this.fetchImpl(this.fileUrl(fileId, true), { headers: this.headers(), credentials: 'include' })
|
|
|
|
|
|
|
+ const res = await this.send(`/v1/files/${fileId}?inline=1`, {})
|
|
|
if (!res.ok) throw new AgentApiError(res.status, 'http_error', `HTTP ${res.status}`)
|
|
if (!res.ok) throw new AgentApiError(res.status, 'http_error', `HTTP ${res.status}`)
|
|
|
return res.blob()
|
|
return res.blob()
|
|
|
}
|
|
}
|
|
@@ -114,9 +138,8 @@ export class AgentClient {
|
|
|
while (!stopped) {
|
|
while (!stopped) {
|
|
|
controller = new AbortController()
|
|
controller = new AbortController()
|
|
|
try {
|
|
try {
|
|
|
- const res = await this.fetchImpl(`${this.opts.baseUrl}/v1/sessions/${sessionId}/stream`, {
|
|
|
|
|
- headers: this.headers({ accept: 'text/event-stream', 'last-event-id': String(lastSeq) }),
|
|
|
|
|
- credentials: 'include',
|
|
|
|
|
|
|
+ const res = await this.send(`/v1/sessions/${sessionId}/stream`, {
|
|
|
|
|
+ headers: { accept: 'text/event-stream', 'last-event-id': String(lastSeq) },
|
|
|
signal: controller.signal,
|
|
signal: controller.signal,
|
|
|
})
|
|
})
|
|
|
if (!res.ok || !res.body) {
|
|
if (!res.ok || !res.body) {
|
|
@@ -152,6 +175,22 @@ export class AgentClient {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * 浏览器令牌缓存:fetchToken 调用平台自己的后端(后端再用 API Key 调 issueBrowserToken)。
|
|
|
|
|
+ * 在过期前 60 秒自动换新,并发请求共用同一次获取
|
|
|
|
|
+ */
|
|
|
|
|
+export function browserTokenProvider(fetchToken: () => Promise<BrowserTokenDto>): NonNullable<AgentClientOptions['getToken']> {
|
|
|
|
|
+ let current: BrowserTokenDto | null = null
|
|
|
|
|
+ let pending: Promise<BrowserTokenDto> | null = null
|
|
|
|
|
+ return async ({ refresh }) => {
|
|
|
|
|
+ const fresh = current && Date.parse(current.expiresAt) - Date.now() > 60_000
|
|
|
|
|
+ if (fresh && !refresh) return current!.token
|
|
|
|
|
+ pending ??= fetchToken().finally(() => { pending = null })
|
|
|
|
|
+ current = await pending
|
|
|
|
|
+ return current.token
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
export interface StreamHandlers {
|
|
export interface StreamHandlers {
|
|
|
onEvent: (e: AgentEvent) => void
|
|
onEvent: (e: AgentEvent) => void
|
|
|
onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void
|
|
onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void
|