mirror of
https://github.com/kennethnym/aris.git
synced 2026-06-13 19:11:18 +01:00
feat: add exa web search source (#124)
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
"@freya/source-location": "workspace:*",
|
||||
"@freya/source-tfl": "workspace:*",
|
||||
"@freya/source-weatherkit": "workspace:*",
|
||||
"@freya/source-web-search": "workspace:*",
|
||||
"@openrouter/sdk": "^0.9.11",
|
||||
"arktype": "^2.1.29",
|
||||
"better-auth": "^1",
|
||||
|
||||
@@ -18,6 +18,7 @@ import { UserSessionManager } from "./session/index.ts"
|
||||
import { registerSourcesHttpHandlers } from "./sources/http.ts"
|
||||
import { TflSourceProvider } from "./tfl/provider.ts"
|
||||
import { WeatherSourceProvider } from "./weather/provider.ts"
|
||||
import { WebSearchSourceProvider } from "./web-search/provider.ts"
|
||||
|
||||
function main() {
|
||||
const { db, close: closeDb } = createDatabase(process.env.DATABASE_URL!)
|
||||
@@ -60,6 +61,7 @@ function main() {
|
||||
},
|
||||
}),
|
||||
new TflSourceProvider({ apiKey: process.env.TFL_API_KEY! }),
|
||||
new WebSearchSourceProvider({ apiKey: process.env.EXA_API_KEY }),
|
||||
],
|
||||
feedEnhancer,
|
||||
credentialEncryptor,
|
||||
|
||||
@@ -186,6 +186,18 @@ function put(app: Hono, sourceId: string, body: unknown) {
|
||||
})
|
||||
}
|
||||
|
||||
function listActions(app: Hono, sourceId: string) {
|
||||
return app.request(`/api/sources/${sourceId}/actions`, { method: "GET" })
|
||||
}
|
||||
|
||||
function executeAction(app: Hono, sourceId: string, actionId: string, body: unknown) {
|
||||
return app.request(`/api/sources/${sourceId}/actions/${actionId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -781,6 +793,168 @@ describe("PUT /api/sources/:sourceId", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("GET /api/sources/:sourceId/actions", () => {
|
||||
test("returns 401 without auth", async () => {
|
||||
activeStore = createInMemoryStore()
|
||||
const { app } = createApp([createStubProvider("freya.location")])
|
||||
|
||||
const res = await listActions(app, "freya.location")
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test("returns 404 for source that is not enabled in the user session", async () => {
|
||||
activeStore = createInMemoryStore()
|
||||
const { app } = createApp([createStubProvider("freya.location")], MOCK_USER_ID)
|
||||
|
||||
const res = await listActions(app, "freya.location")
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
test("returns serializable action definitions", async () => {
|
||||
activeStore = createInMemoryStore()
|
||||
activeStore.seed(MOCK_USER_ID, "test.actions")
|
||||
const provider: FeedSourceProvider = {
|
||||
sourceId: "test.actions",
|
||||
async feedSourceForUser() {
|
||||
return {
|
||||
id: "test.actions",
|
||||
async listActions() {
|
||||
return {
|
||||
search: {
|
||||
id: "search",
|
||||
description: "Search something",
|
||||
input: tflConfig,
|
||||
},
|
||||
}
|
||||
},
|
||||
async executeAction() {
|
||||
return undefined
|
||||
},
|
||||
async fetchContext() {
|
||||
return null
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
const { app } = createApp([provider], MOCK_USER_ID)
|
||||
|
||||
const res = await listActions(app, "test.actions")
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
actions: Record<string, { id: string; description?: string; input?: unknown }>
|
||||
}
|
||||
expect(body.actions.search).toEqual({
|
||||
id: "search",
|
||||
description: "Search something",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("POST /api/sources/:sourceId/actions/:actionId", () => {
|
||||
test("returns 401 without auth", async () => {
|
||||
activeStore = createInMemoryStore()
|
||||
const { app } = createApp([createStubProvider("freya.location")])
|
||||
|
||||
const res = await executeAction(app, "freya.location", "update-location", {})
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test("executes source action with request body as params", async () => {
|
||||
activeStore = createInMemoryStore()
|
||||
activeStore.seed(MOCK_USER_ID, "test.actions")
|
||||
let receivedParams: unknown
|
||||
const provider: FeedSourceProvider = {
|
||||
sourceId: "test.actions",
|
||||
async feedSourceForUser() {
|
||||
return {
|
||||
id: "test.actions",
|
||||
async listActions() {
|
||||
return {
|
||||
search: { id: "search", description: "Search something" },
|
||||
}
|
||||
},
|
||||
async executeAction(_actionId: string, params: unknown) {
|
||||
receivedParams = params
|
||||
return { ok: true, count: 2 }
|
||||
},
|
||||
async fetchContext() {
|
||||
return null
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
const { app } = createApp([provider], MOCK_USER_ID)
|
||||
|
||||
const res = await executeAction(app, "test.actions", "search", { query: "exa" })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(receivedParams).toEqual({ query: "exa" })
|
||||
const body = (await res.json()) as { result: unknown }
|
||||
expect(body.result).toEqual({ ok: true, count: 2 })
|
||||
})
|
||||
|
||||
test("returns 404 for unknown action", async () => {
|
||||
activeStore = createInMemoryStore()
|
||||
activeStore.seed(MOCK_USER_ID, "freya.location")
|
||||
const { app } = createApp([createStubProvider("freya.location")], MOCK_USER_ID)
|
||||
|
||||
const res = await executeAction(app, "freya.location", "missing", {})
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
test("returns 400 for invalid JSON", async () => {
|
||||
activeStore = createInMemoryStore()
|
||||
activeStore.seed(MOCK_USER_ID, "freya.location")
|
||||
const { app } = createApp([createStubProvider("freya.location")], MOCK_USER_ID)
|
||||
|
||||
const res = await app.request("/api/sources/freya.location/actions/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "not-json",
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as { error: string }
|
||||
expect(body.error).toBe("Invalid JSON")
|
||||
})
|
||||
|
||||
test("returns 400 when source rejects params", async () => {
|
||||
activeStore = createInMemoryStore()
|
||||
activeStore.seed(MOCK_USER_ID, "test.actions")
|
||||
const provider: FeedSourceProvider = {
|
||||
sourceId: "test.actions",
|
||||
async feedSourceForUser() {
|
||||
return {
|
||||
id: "test.actions",
|
||||
async listActions() {
|
||||
return {
|
||||
search: { id: "search" },
|
||||
}
|
||||
},
|
||||
async executeAction() {
|
||||
throw new Error("query must not be empty")
|
||||
},
|
||||
async fetchContext() {
|
||||
return null
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
const { app } = createApp([provider], MOCK_USER_ID)
|
||||
|
||||
const res = await executeAction(app, "test.actions", "search", { query: "" })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as { error: string }
|
||||
expect(body.error).toBe("query must not be empty")
|
||||
})
|
||||
})
|
||||
|
||||
describe("PUT /api/sources/:sourceId/credentials", () => {
|
||||
test("returns 401 without auth", async () => {
|
||||
activeStore = createInMemoryStore()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ActionDefinition } from "@freya/core"
|
||||
import type { Context, Hono } from "hono"
|
||||
|
||||
import { type } from "arktype"
|
||||
@@ -55,6 +56,13 @@ export function registerSourcesHttpHandlers(
|
||||
app.get("/api/sources/:sourceId", inject, authSessionMiddleware, handleGetSource)
|
||||
app.patch("/api/sources/:sourceId", inject, authSessionMiddleware, handleUpdateSource)
|
||||
app.put("/api/sources/:sourceId", inject, authSessionMiddleware, handleReplaceSource)
|
||||
app.get("/api/sources/:sourceId/actions", inject, authSessionMiddleware, handleListActions)
|
||||
app.post(
|
||||
"/api/sources/:sourceId/actions/:actionId",
|
||||
inject,
|
||||
authSessionMiddleware,
|
||||
handleExecuteAction,
|
||||
)
|
||||
app.put(
|
||||
"/api/sources/:sourceId/credentials",
|
||||
inject,
|
||||
@@ -189,6 +197,71 @@ async function handleReplaceSource(c: Context<Env>) {
|
||||
return c.body(null, 204)
|
||||
}
|
||||
|
||||
async function handleListActions(c: Context<Env>) {
|
||||
const sourceId = c.req.param("sourceId")
|
||||
if (!sourceId) {
|
||||
return c.body(null, 404)
|
||||
}
|
||||
|
||||
const user = c.get("user")!
|
||||
const sessionManager = c.get("sessionManager")
|
||||
|
||||
let session
|
||||
try {
|
||||
session = await sessionManager.getOrCreate(user.id)
|
||||
} catch (err) {
|
||||
console.error("[handleListActions] Failed to create session:", err)
|
||||
return c.json({ error: "Service unavailable" }, 503)
|
||||
}
|
||||
|
||||
try {
|
||||
const actions = await session.engine.listActions(sourceId)
|
||||
return c.json({ actions: serializeActions(actions) })
|
||||
} catch (err) {
|
||||
if (isActionNotFoundError(err)) {
|
||||
return c.json({ error: err.message }, 404)
|
||||
}
|
||||
console.error(`[handleListActions] Failed to list actions for "${sourceId}":`, err)
|
||||
return c.json({ error: "Failed to list actions" }, 500)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExecuteAction(c: Context<Env>) {
|
||||
const sourceId = c.req.param("sourceId")
|
||||
const actionId = c.req.param("actionId")
|
||||
if (!sourceId || !actionId) {
|
||||
return c.body(null, 404)
|
||||
}
|
||||
|
||||
let params: unknown
|
||||
try {
|
||||
params = await c.req.json()
|
||||
} catch {
|
||||
return c.json({ error: "Invalid JSON" }, 400)
|
||||
}
|
||||
|
||||
const user = c.get("user")!
|
||||
const sessionManager = c.get("sessionManager")
|
||||
|
||||
let session
|
||||
try {
|
||||
session = await sessionManager.getOrCreate(user.id)
|
||||
} catch (err) {
|
||||
console.error("[handleExecuteAction] Failed to create session:", err)
|
||||
return c.json({ error: "Service unavailable" }, 503)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await session.engine.executeAction(sourceId, actionId, params)
|
||||
return c.json({ result })
|
||||
} catch (err) {
|
||||
if (isActionNotFoundError(err)) {
|
||||
return c.json({ error: err.message }, 404)
|
||||
}
|
||||
return c.json({ error: err instanceof Error ? err.message : String(err) }, 400)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateCredentials(c: Context<Env>) {
|
||||
const sourceId = c.req.param("sourceId")
|
||||
if (!sourceId) {
|
||||
@@ -228,3 +301,21 @@ async function handleUpdateCredentials(c: Context<Env>) {
|
||||
|
||||
return c.body(null, 204)
|
||||
}
|
||||
|
||||
function serializeActions(actions: Record<string, ActionDefinition>) {
|
||||
const serialized: Record<string, { id: string; description?: string }> = {}
|
||||
for (const [key, action] of Object.entries(actions)) {
|
||||
serialized[key] = {
|
||||
id: action.id,
|
||||
...(action.description ? { description: action.description } : {}),
|
||||
}
|
||||
}
|
||||
return serialized
|
||||
}
|
||||
|
||||
function isActionNotFoundError(err: unknown): err is Error {
|
||||
if (!(err instanceof Error)) {
|
||||
return false
|
||||
}
|
||||
return err.message.startsWith("Source not found:") || err.message.startsWith("Action ")
|
||||
}
|
||||
|
||||
30
apps/freya-backend/src/web-search/provider.ts
Normal file
30
apps/freya-backend/src/web-search/provider.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { WebSearchSource, type WebSearchClient } from "@freya/source-web-search"
|
||||
|
||||
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
||||
|
||||
export type WebSearchSourceProviderOptions =
|
||||
| { apiKey: string | undefined; client?: never }
|
||||
| { apiKey?: never; client: WebSearchClient }
|
||||
|
||||
export class WebSearchSourceProvider implements FeedSourceProvider {
|
||||
readonly sourceId = "freya.web-search"
|
||||
|
||||
private readonly apiKey: string | undefined
|
||||
private readonly client: WebSearchClient | undefined
|
||||
|
||||
constructor(options: WebSearchSourceProviderOptions) {
|
||||
this.apiKey = "apiKey" in options ? options.apiKey : undefined
|
||||
this.client = "client" in options ? options.client : undefined
|
||||
}
|
||||
|
||||
async feedSourceForUser(
|
||||
_userId: string,
|
||||
_config: unknown,
|
||||
_credentials: unknown,
|
||||
): Promise<WebSearchSource> {
|
||||
return new WebSearchSource({
|
||||
apiKey: this.apiKey,
|
||||
client: this.client,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user