Complete Homarr provider integration
This commit is contained in:
1 parent
bc59355eab
commit
165201632d
23 files changed
+986
-61
No files matched your search
@@ -13,6 +13,8 @@ import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
|
||||
import type {
|
||||
WorkshopComment,
|
||||
WorkshopAssistantActivity,
|
||||
WorkshopAssistantUsage,
|
||||
WorkshopReport,
|
||||
WorkshopReportSummary,
|
||||
WorkshopSubmissionDetail,
|
||||
@@ -28,6 +30,8 @@ import {
|
||||
WORKSHOP_REQUEST_TIMEOUT_MS,
|
||||
WORKSHOP_SCHEMA_BY_TYPE,
|
||||
workshopCommentSchema,
|
||||
workshopAssistantActivitySchema,
|
||||
workshopAssistantUsageSchema,
|
||||
workshopReportSchema,
|
||||
workshopReportSummarySchema,
|
||||
workshopSubmissionDetailSchema,
|
||||
@@ -140,6 +144,19 @@ export interface WorkshopCommentRecord extends WorkshopBaseRecord {
|
||||
expand?: { author?: WorkshopUserRecord };
|
||||
}
|
||||
|
||||
export interface WorkshopAssistantActivityRecord extends WorkshopBaseRecord {
|
||||
status: "processing" | "completed" | "failed";
|
||||
model: string;
|
||||
requestUnits: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
durationMs: number;
|
||||
cost: number;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface TypedWorkshopPocketBase extends PocketBase {
|
||||
collection(idOrName: "users"): RecordService<WorkshopUserRecord>;
|
||||
collection(idOrName: "submissions"): RecordService<WorkshopSubmissionRecord>;
|
||||
@@ -148,6 +165,7 @@ export interface TypedWorkshopPocketBase extends PocketBase {
|
||||
collection(idOrName: "reports"): RecordService<WorkshopReportRecord>;
|
||||
collection(idOrName: "workshop_report_summaries"): RecordService<WorkshopReportSummaryRecord>;
|
||||
collection(idOrName: "comments"): RecordService<WorkshopCommentRecord>;
|
||||
collection(idOrName: "assistant_activity"): RecordService<WorkshopAssistantActivityRecord>;
|
||||
collection(idOrName: string): RecordService;
|
||||
}
|
||||
|
||||
@@ -280,6 +298,10 @@ export class WorkshopBackend {
|
||||
});
|
||||
}
|
||||
|
||||
public get authToken(): string | null {
|
||||
return this.pocketBase.authStore.isValid && this.currentUser ? this.pocketBase.authStore.token : null;
|
||||
}
|
||||
|
||||
public subscribeToAuth(listener: (user: WorkshopUser | null) => void) {
|
||||
return this.pocketBase.authStore.onChange(() => listener(this.currentUser), true);
|
||||
}
|
||||
@@ -314,6 +336,35 @@ export class WorkshopBackend {
|
||||
this.pocketBase.authStore.clear();
|
||||
}
|
||||
|
||||
public async getAssistantUsage(signal?: AbortSignal): Promise<WorkshopAssistantUsage> {
|
||||
try {
|
||||
const value = await this.pocketBase.send("/api/ai/usage", {
|
||||
method: "GET",
|
||||
signal: requestSignal(signal),
|
||||
requestKey: null,
|
||||
});
|
||||
return workshopAssistantUsageSchema.parse(value);
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to load the Homarr provider allowance");
|
||||
}
|
||||
}
|
||||
|
||||
public async listAssistantActivity(limit = 10, signal?: AbortSignal): Promise<WorkshopAssistantActivity[]> {
|
||||
try {
|
||||
const result = await this.pocketBase.collection("assistant_activity").getList(1, Math.min(Math.max(limit, 1), 50), {
|
||||
sort: "-created",
|
||||
signal: requestSignal(signal),
|
||||
requestKey: null,
|
||||
});
|
||||
return result.items.flatMap((item) => {
|
||||
const parsed = workshopAssistantActivitySchema.safeParse(item);
|
||||
return parsed.success ? [parsed.data] : [];
|
||||
});
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to load live Homarr provider activity");
|
||||
}
|
||||
}
|
||||
|
||||
public async list(options: WorkshopListOptions = {}): Promise<WorkshopPage<WorkshopSubmissionSummary>> {
|
||||
try {
|
||||
const page = options.page ?? 1;
|
||||
|
||||
@@ -6,16 +6,25 @@ const mocks = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
getFullList: vi.fn(),
|
||||
getList: vi.fn(),
|
||||
send: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("pocketbase", () => {
|
||||
class MockPocketBase {
|
||||
public readonly authStore = {
|
||||
token: "",
|
||||
record: null as Record<string, unknown> | null,
|
||||
isValid: false,
|
||||
save: (_token: string, record: Record<string, unknown>) => {
|
||||
save: (token: string, record: Record<string, unknown>) => {
|
||||
this.authStore.token = token;
|
||||
this.authStore.record = record;
|
||||
this.authStore.isValid = true;
|
||||
},
|
||||
clear: () => {
|
||||
this.authStore.token = "";
|
||||
this.authStore.record = null;
|
||||
this.authStore.isValid = false;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -28,6 +37,10 @@ vi.mock("pocketbase", () => {
|
||||
return this;
|
||||
}
|
||||
|
||||
public send(path: string, options: unknown) {
|
||||
return mocks.send(path, options);
|
||||
}
|
||||
|
||||
public filter(template: string, values: Record<string, string>) {
|
||||
return template.replaceAll(/\{:(\w+)\}/gu, (_match, key: string) => `'${values[key]}'`);
|
||||
}
|
||||
@@ -62,6 +75,7 @@ describe("WorkshopBackend", () => {
|
||||
mocks.create.mockReset();
|
||||
mocks.getFullList.mockReset();
|
||||
mocks.getList.mockReset();
|
||||
mocks.send.mockReset();
|
||||
mocks.update.mockReset();
|
||||
});
|
||||
|
||||
@@ -91,6 +105,54 @@ describe("WorkshopBackend", () => {
|
||||
expect(mocks.autoCancellation).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("reuses a valid Workshop session for the Homarr provider allowance", async () => {
|
||||
const client = new WorkshopBackend("https://workshop.example.com");
|
||||
expect(client.authToken).toBeNull();
|
||||
client.pocketBase.authStore.save("workshop-token", { id: "author-id", name: "octocat" } as never);
|
||||
mocks.send.mockResolvedValue({
|
||||
limit: 50,
|
||||
used: 7,
|
||||
remaining: 43,
|
||||
resetsAt: "2026-08-12T00:00:00Z",
|
||||
});
|
||||
|
||||
await expect(client.getAssistantUsage()).resolves.toMatchObject({ limit: 50, remaining: 43 });
|
||||
expect(client.authToken).toBe("workshop-token");
|
||||
expect(mocks.send).toHaveBeenCalledWith(
|
||||
"/api/ai/usage",
|
||||
expect.objectContaining({ method: "GET", requestKey: null, signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
test("loads only valid anonymous provider activity records", async () => {
|
||||
mocks.getList.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "activity-id",
|
||||
status: "completed",
|
||||
model: "homarr/deepseek-v4-flash-latest",
|
||||
requestUnits: 2,
|
||||
inputTokens: 12,
|
||||
outputTokens: 7,
|
||||
totalTokens: 19,
|
||||
durationMs: 250,
|
||||
cost: 0.0001,
|
||||
created: "2026-08-11 10:00:00.000Z",
|
||||
updated: "2026-08-11 10:00:00.250Z",
|
||||
},
|
||||
{ id: "invalid" },
|
||||
],
|
||||
});
|
||||
const client = new WorkshopBackend("https://workshop.example.com");
|
||||
|
||||
await expect(client.listAssistantActivity()).resolves.toHaveLength(1);
|
||||
expect(mocks.getList).toHaveBeenCalledWith(
|
||||
1,
|
||||
10,
|
||||
expect.objectContaining({ sort: "-created", requestKey: null, signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
test("sends the exported schema while leaving initial revision ownership to PocketBase", async () => {
|
||||
const client = new WorkshopBackend("https://workshop.example.com");
|
||||
client.pocketBase.authStore.save("token", { id: "author-id" } as never);
|
||||
|
||||
@@ -102,6 +102,29 @@ export const workshopUserSchema = z.object({
|
||||
});
|
||||
export type WorkshopUser = z.infer<typeof workshopUserSchema>;
|
||||
|
||||
export const workshopAssistantUsageSchema = z.object({
|
||||
limit: z.number().int().nonnegative(),
|
||||
used: z.number().int().nonnegative(),
|
||||
remaining: z.number().int().nonnegative(),
|
||||
resetsAt: z.string().datetime({ offset: true }),
|
||||
});
|
||||
export type WorkshopAssistantUsage = z.infer<typeof workshopAssistantUsageSchema>;
|
||||
|
||||
export const workshopAssistantActivitySchema = z.object({
|
||||
id: z.string(),
|
||||
status: z.enum(["processing", "completed", "failed"]),
|
||||
model: z.string(),
|
||||
requestUnits: z.number().int().positive(),
|
||||
inputTokens: z.number().int().nonnegative().default(0),
|
||||
outputTokens: z.number().int().nonnegative().default(0),
|
||||
totalTokens: z.number().int().nonnegative().default(0),
|
||||
durationMs: z.number().int().nonnegative().default(0),
|
||||
cost: z.number().nonnegative().default(0),
|
||||
created: z.string(),
|
||||
updated: z.string(),
|
||||
});
|
||||
export type WorkshopAssistantActivity = z.infer<typeof workshopAssistantActivitySchema>;
|
||||
|
||||
const normalizeWorkshopSubmissionRecord = (value: unknown) => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
|
||||
const record = value as Record<string, unknown>;
|
||||
|
||||
Reference in new issue
Block a user