mirror of
https://github.com/willchen96/mike.git
synced 2026-07-24 23:41:04 +02:00
feat: workflow, UI and document support updates
This commit is contained in:
parent
a5fe6d6e04
commit
82dcaefc43
139 changed files with 12554 additions and 2233 deletions
|
|
@ -539,7 +539,7 @@ export async function buildWorkflowStore(
|
|||
|
||||
// Seed system workflows first.
|
||||
for (const wf of SYSTEM_ASSISTANT_WORKFLOWS) {
|
||||
store.set(wf.id, { title: wf.title, prompt_md: wf.prompt_md });
|
||||
store.set(wf.id, { title: wf.title, skill_md: wf.skill_md });
|
||||
}
|
||||
|
||||
// Then overlay user-owned assistant workflows.
|
||||
|
|
@ -550,7 +550,7 @@ export async function buildWorkflowStore(
|
|||
.eq("type", "assistant");
|
||||
for (const wf of workflows ?? []) {
|
||||
if (wf.prompt_md) {
|
||||
store.set(wf.id, { title: wf.title, prompt_md: wf.prompt_md });
|
||||
store.set(wf.id, { title: wf.title, skill_md: wf.prompt_md });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -573,7 +573,7 @@ export async function buildWorkflowStore(
|
|||
if (wf.prompt_md) {
|
||||
store.set(wf.id, {
|
||||
title: wf.title,
|
||||
prompt_md: wf.prompt_md,
|
||||
skill_md: wf.prompt_md,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ Citation rules:
|
|||
- Keep quotes short, ideally 25 words or fewer, and tightly matched to the claim.
|
||||
- "page" means the sequential [Page N] marker in the provided text, not printed page numbers inside the document. Non-spreadsheet unpaginated files may have no [Page N] markers; omit "page" (or use 1) when none is present.
|
||||
- For spreadsheet sources (content shown as "## Sheet: <name>" markdown tables with a "Row" column and column-letter headers), cite by cell instead of page: set "sheet" to the sheet name and "cell" to the A1 address or range you are quoting (e.g. "B7" or "B7:C9", combining the column-letter header with the "Row" number). Put the plain cell value in "quote" with no "Row"/column-letter labels or "|" separators. Omit "page" for spreadsheet citations.
|
||||
- A cell tagged "⟨merged A1:C1⟩" spans that whole range: its value belongs to the anchor cell and the other covered cells are shown blank. When citing anything in a merged range, set "cell" to the full range from the tag (e.g. "A1:C1"), not a covered cell like "B1". Do not include the "⟨merged ...⟩" tag text in "quote".
|
||||
- For a continuous quote crossing two pages, set "page" to "N-M" and include [[PAGE_BREAK]] at the page break. Otherwise, use separate quote objects.
|
||||
- For legacy compatibility, you may also include top-level "page" and "quote" matching the first quote.
|
||||
- Omit the <CITATIONS> block when there are no citations.
|
||||
|
|
|
|||
|
|
@ -777,7 +777,7 @@ export async function runToolCalls(
|
|||
toolResults.push({
|
||||
role: "tool",
|
||||
tool_call_id: tc.id,
|
||||
content: wf ? wf.prompt_md : `Workflow '${wfId}' not found.`,
|
||||
content: wf ? wf.skill_md : `Workflow '${wfId}' not found.`,
|
||||
});
|
||||
} else if (tc.function.name === "read_table_cells" && tabularStore) {
|
||||
const colIndices = args.col_indices as number[] | undefined;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export type DocStore = Map<
|
|||
{ storage_path: string; file_type: string; filename: string }
|
||||
>;
|
||||
|
||||
export type WorkflowStore = Map<string, { title: string; prompt_md: string }>;
|
||||
export type WorkflowStore = Map<string, { title: string; skill_md: string }>;
|
||||
|
||||
export type DocIndex = Record<
|
||||
string,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,16 @@ function renderSheet(sheetName: string, ws: XLSX.WorkSheet): string | null {
|
|||
if (!ref) return null;
|
||||
const range = XLSX.utils.decode_range(ref);
|
||||
|
||||
// Map each merged range's top-left (anchor) address to its encoded range so we
|
||||
// can tag the anchor inline (e.g. `Amount ⟨merged B2:C2⟩`). The covered cells
|
||||
// stay blank, so the model never reads a covered address (e.g. B1 inside
|
||||
// A1:C1) as its own value; the tag tells it the anchor spans that range, and
|
||||
// to cite the whole range for anything in it.
|
||||
const mergeAnchors = new Map<string, string>();
|
||||
for (const m of ws["!merges"] ?? []) {
|
||||
mergeAnchors.set(XLSX.utils.encode_cell(m.s), XLSX.utils.encode_range(m));
|
||||
}
|
||||
|
||||
// Build a trimmed grid: capture formatted text for every cell in the used
|
||||
// range, then drop trailing empty columns and fully empty rows so we don't
|
||||
// emit oceans of blank cells.
|
||||
|
|
@ -44,7 +54,13 @@ function renderSheet(sheetName: string, ws: XLSX.WorkSheet): string | null {
|
|||
let rowHasContent = false;
|
||||
for (let c = range.s.c; c <= range.e.c; c++) {
|
||||
const addr = XLSX.utils.encode_cell({ r, c });
|
||||
const text = sanitizeCellText(cellDisplayText(ws[addr]));
|
||||
let text = sanitizeCellText(cellDisplayText(ws[addr]));
|
||||
const mergeRange = mergeAnchors.get(addr);
|
||||
if (mergeRange) {
|
||||
text = text
|
||||
? `${text} ⟨merged ${mergeRange}⟩`
|
||||
: `⟨merged ${mergeRange}⟩`;
|
||||
}
|
||||
cells[c - range.s.c] = text;
|
||||
if (text) {
|
||||
rowHasContent = true;
|
||||
|
|
@ -72,13 +88,6 @@ function renderSheet(sheetName: string, ws: XLSX.WorkSheet): string | null {
|
|||
|
||||
const lines = [`## Sheet: ${sheetName}`, "", headerRow, separator, ...bodyRows];
|
||||
|
||||
// Note merged ranges once so the model understands spanned headers/labels.
|
||||
const merges = ws["!merges"];
|
||||
if (merges && merges.length > 0) {
|
||||
const encoded = merges.map((m) => XLSX.utils.encode_range(m));
|
||||
lines.push("", `Merged ranges: ${encoded.join(", ")}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
113
backend/src/lib/userLookup.ts
Normal file
113
backend/src/lib/userLookup.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
type Db = SupabaseClient<any, "public", any>;
|
||||
|
||||
export type ProfileUserInfo = {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
};
|
||||
|
||||
export function normalizeEmail(value: unknown) {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
export function normalizeDisplayName(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
export async function loadProfileUsersByEmail(db: Db) {
|
||||
const { data, error } = await db
|
||||
.from("user_profiles")
|
||||
.select("user_id, email, display_name")
|
||||
.not("email", "is", null);
|
||||
if (error) throw error;
|
||||
|
||||
const userByEmail = new Map<string, ProfileUserInfo>();
|
||||
const userById = new Map<string, ProfileUserInfo>();
|
||||
for (const row of data ?? []) {
|
||||
const email = normalizeEmail(row.email);
|
||||
if (!email) continue;
|
||||
const info = {
|
||||
id: row.user_id as string,
|
||||
email,
|
||||
display_name: normalizeDisplayName(row.display_name),
|
||||
};
|
||||
userByEmail.set(email, info);
|
||||
userById.set(info.id, info);
|
||||
}
|
||||
|
||||
return { userByEmail, userById };
|
||||
}
|
||||
|
||||
export async function findProfileUserByEmail(db: Db, email: string) {
|
||||
const normalized = normalizeEmail(email);
|
||||
if (!normalized) return null;
|
||||
|
||||
const { data, error } = await db
|
||||
.from("user_profiles")
|
||||
.select("user_id, email, display_name")
|
||||
.eq("email", normalized)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
if (!data) return null;
|
||||
|
||||
return {
|
||||
id: data.user_id as string,
|
||||
email: normalized,
|
||||
display_name: normalizeDisplayName(data.display_name),
|
||||
};
|
||||
}
|
||||
|
||||
export async function findMissingUserEmails(db: Db, emails: string[]) {
|
||||
const normalizedEmails = [...new Set(emails.map(normalizeEmail).filter(Boolean))];
|
||||
if (normalizedEmails.length === 0) return [];
|
||||
|
||||
const { data, error } = await db
|
||||
.from("user_profiles")
|
||||
.select("email")
|
||||
.in("email", normalizedEmails);
|
||||
if (error) throw error;
|
||||
|
||||
const found = new Set(
|
||||
(data ?? [])
|
||||
.map((row) => normalizeEmail(row.email))
|
||||
.filter(Boolean),
|
||||
);
|
||||
return normalizedEmails.filter((email) => !found.has(email));
|
||||
}
|
||||
|
||||
export async function syncProfileEmail(
|
||||
db: Db,
|
||||
userId: string,
|
||||
email: string | null | undefined,
|
||||
) {
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
if (!userId || !normalizedEmail) return null;
|
||||
|
||||
const { data: existing, error: loadError } = await db
|
||||
.from("user_profiles")
|
||||
.select("email")
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
if (loadError) return loadError;
|
||||
|
||||
if (!existing) {
|
||||
const { error } = await db.from("user_profiles").insert({
|
||||
user_id: userId,
|
||||
email: normalizedEmail,
|
||||
});
|
||||
return error;
|
||||
}
|
||||
|
||||
if (normalizeEmail(existing.email) === normalizedEmail) return null;
|
||||
|
||||
const { error } = await db
|
||||
.from("user_profiles")
|
||||
.update({
|
||||
email: normalizedEmail,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("user_id", userId);
|
||||
return error;
|
||||
}
|
||||
|
|
@ -32,12 +32,25 @@ type WorkflowRecord = {
|
|||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type WorkflowType = "assistant" | "tabular";
|
||||
|
||||
type WorkflowContributor = {
|
||||
name: string;
|
||||
organisation: string | null;
|
||||
role: string | null;
|
||||
linkedin: string | null;
|
||||
};
|
||||
|
||||
type WorkflowMetadata = {
|
||||
title: string;
|
||||
description: string | null;
|
||||
type: WorkflowType;
|
||||
contributors: WorkflowContributor[];
|
||||
language: string;
|
||||
version: string | null;
|
||||
practice: string | null;
|
||||
jurisdictions: string[] | null;
|
||||
};
|
||||
type OpenSourceSubmissionStatus = "pending" | "approved" | "rejected";
|
||||
|
||||
type OpenSourceSubmissionRow = {
|
||||
|
|
@ -52,7 +65,6 @@ type OpenSourceSubmissionRow = {
|
|||
submitted_at: string;
|
||||
updated_at: string;
|
||||
reviewed_at?: string | null;
|
||||
reviewed_by_user_id?: string | null;
|
||||
review_notes?: string | null;
|
||||
};
|
||||
|
||||
|
|
@ -120,9 +132,42 @@ function withSystemWorkflowAccess(workflow: SystemWorkflow) {
|
|||
});
|
||||
}
|
||||
|
||||
function withDatabaseWorkflow<T extends object>(workflow: T) {
|
||||
function workflowTypeFrom(value: unknown): WorkflowType {
|
||||
return value === "tabular" ? "tabular" : "assistant";
|
||||
}
|
||||
|
||||
function metadataFromWorkflowRecord(workflow: WorkflowRecord): WorkflowMetadata {
|
||||
return {
|
||||
...workflow,
|
||||
title: workflow.title ?? "",
|
||||
description: null,
|
||||
type: workflowTypeFrom(workflow.type),
|
||||
contributors:
|
||||
normalizeContributors(workflow.contributors) ?? [
|
||||
DEFAULT_WORKFLOW_CONTRIBUTOR,
|
||||
],
|
||||
language: workflow.language ?? DEFAULT_WORKFLOW_LANGUAGE,
|
||||
version: workflow.version ?? null,
|
||||
practice: workflow.practice ?? DEFAULT_WORKFLOW_PRACTICE,
|
||||
jurisdictions: workflow.jurisdictions ?? DEFAULT_WORKFLOW_JURISDICTIONS,
|
||||
};
|
||||
}
|
||||
|
||||
function withDatabaseWorkflow(workflow: WorkflowRecord) {
|
||||
const {
|
||||
title: _title,
|
||||
type: _type,
|
||||
contributors: _contributors,
|
||||
language: _language,
|
||||
version: _version,
|
||||
practice: _practice,
|
||||
jurisdictions: _jurisdictions,
|
||||
prompt_md,
|
||||
...rest
|
||||
} = workflow;
|
||||
return {
|
||||
...rest,
|
||||
metadata: metadataFromWorkflowRecord(workflow),
|
||||
skill_md: prompt_md ?? null,
|
||||
is_system: false,
|
||||
};
|
||||
}
|
||||
|
|
@ -179,7 +224,7 @@ async function resolveWorkflowAccess(
|
|||
.eq("id", workflowId)
|
||||
.single();
|
||||
if (!workflow) return null;
|
||||
const workflowRecord = withDatabaseWorkflow(workflow as WorkflowRecord);
|
||||
const workflowRecord = workflow as WorkflowRecord;
|
||||
if (workflowRecord.user_id === userId) {
|
||||
return { workflow: workflowRecord, allowEdit: true, isOwner: true };
|
||||
}
|
||||
|
|
@ -234,16 +279,13 @@ function buildOpenSourceSnapshot(
|
|||
) {
|
||||
return {
|
||||
workflow_id: workflow.id,
|
||||
title: workflow.title ?? "",
|
||||
type: workflow.type ?? "",
|
||||
prompt_md: workflow.prompt_md ?? null,
|
||||
metadata: {
|
||||
...metadataFromWorkflowRecord(workflow),
|
||||
contributors,
|
||||
},
|
||||
skill_md: workflow.prompt_md ?? null,
|
||||
columns_config: workflow.columns_config ?? null,
|
||||
contributors,
|
||||
contributor_mode: contributorMode,
|
||||
language: workflow.language ?? DEFAULT_WORKFLOW_LANGUAGE,
|
||||
version: workflow.version ?? null,
|
||||
practice: workflow.practice ?? DEFAULT_WORKFLOW_PRACTICE,
|
||||
jurisdictions: workflow.jurisdictions ?? DEFAULT_WORKFLOW_JURISDICTIONS,
|
||||
created_at: workflow.created_at ?? null,
|
||||
};
|
||||
}
|
||||
|
|
@ -280,11 +322,11 @@ workflowsRouter.get("/", requireAuth, asyncRoute(async (req, res) => {
|
|||
}
|
||||
|
||||
const systemWorkflows = SYSTEM_WORKFLOWS.filter(
|
||||
(workflow) => !workflowType || workflow.type === workflowType,
|
||||
(workflow) => !workflowType || workflow.metadata.type === workflowType,
|
||||
).map(withSystemWorkflowAccess);
|
||||
const databaseWorkflows = ((data ?? []) as WorkflowRecord[]).filter(
|
||||
(workflow) => !SYSTEM_WORKFLOW_IDS.has(workflow.id),
|
||||
);
|
||||
).map(withDatabaseWorkflow);
|
||||
|
||||
res.json([...systemWorkflows, ...databaseWorkflows]);
|
||||
}));
|
||||
|
|
@ -293,40 +335,36 @@ workflowsRouter.get("/", requireAuth, asyncRoute(async (req, res) => {
|
|||
workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => {
|
||||
const userId = res.locals.userId as string;
|
||||
const {
|
||||
title,
|
||||
type,
|
||||
prompt_md,
|
||||
metadata,
|
||||
skill_md,
|
||||
columns_config,
|
||||
language,
|
||||
practice,
|
||||
jurisdictions,
|
||||
} = req.body as {
|
||||
title: string;
|
||||
type: string;
|
||||
prompt_md?: string;
|
||||
metadata?: Partial<WorkflowMetadata>;
|
||||
skill_md?: string;
|
||||
columns_config?: unknown;
|
||||
language?: unknown;
|
||||
practice?: string | null;
|
||||
jurisdictions?: unknown;
|
||||
};
|
||||
const title = metadata?.title;
|
||||
const type = metadata?.type;
|
||||
if (!title?.trim())
|
||||
return void res.status(400).json({ detail: "title is required" });
|
||||
if (!["assistant", "tabular"].includes(type))
|
||||
return void res.status(400).json({ detail: "metadata.title is required" });
|
||||
if (type !== "assistant" && type !== "tabular")
|
||||
return void res
|
||||
.status(400)
|
||||
.json({ detail: "type must be 'assistant' or 'tabular'" });
|
||||
.json({ detail: "metadata.type must be 'assistant' or 'tabular'" });
|
||||
|
||||
const db = createServerSupabase();
|
||||
devLog("[workflows/create] request", {
|
||||
userId,
|
||||
title: title.trim(),
|
||||
type,
|
||||
hasPrompt: typeof prompt_md === "string" && prompt_md.length > 0,
|
||||
hasSkill: typeof skill_md === "string" && skill_md.length > 0,
|
||||
columnCount: Array.isArray(columns_config) ? columns_config.length : null,
|
||||
language: normalizeOptionalString(language) ?? DEFAULT_WORKFLOW_LANGUAGE,
|
||||
practice: practice ?? null,
|
||||
language:
|
||||
normalizeOptionalString(metadata?.language) ?? DEFAULT_WORKFLOW_LANGUAGE,
|
||||
practice: metadata?.practice ?? null,
|
||||
jurisdictions:
|
||||
normalizeJurisdictions(jurisdictions) ?? DEFAULT_WORKFLOW_JURISDICTIONS,
|
||||
normalizeJurisdictions(metadata?.jurisdictions) ??
|
||||
DEFAULT_WORKFLOW_JURISDICTIONS,
|
||||
});
|
||||
const { data, error } = await db
|
||||
.from("workflows")
|
||||
|
|
@ -334,13 +372,15 @@ workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => {
|
|||
user_id: userId,
|
||||
title: title.trim(),
|
||||
type,
|
||||
prompt_md: prompt_md ?? null,
|
||||
prompt_md: skill_md ?? null,
|
||||
columns_config: columns_config ?? null,
|
||||
language: normalizeOptionalString(language) ?? DEFAULT_WORKFLOW_LANGUAGE,
|
||||
language:
|
||||
normalizeOptionalString(metadata?.language) ?? DEFAULT_WORKFLOW_LANGUAGE,
|
||||
practice:
|
||||
normalizeOptionalString(practice) ?? DEFAULT_WORKFLOW_PRACTICE,
|
||||
normalizeOptionalString(metadata?.practice) ?? DEFAULT_WORKFLOW_PRACTICE,
|
||||
jurisdictions:
|
||||
normalizeJurisdictions(jurisdictions) ?? DEFAULT_WORKFLOW_JURISDICTIONS,
|
||||
normalizeJurisdictions(metadata?.jurisdictions) ??
|
||||
DEFAULT_WORKFLOW_JURISDICTIONS,
|
||||
})
|
||||
.select("*")
|
||||
.single();
|
||||
|
|
@ -362,7 +402,7 @@ workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => {
|
|||
title: data?.title,
|
||||
type: data?.type,
|
||||
});
|
||||
res.status(201).json(withDatabaseWorkflow(data));
|
||||
res.status(201).json(withDatabaseWorkflow(data as WorkflowRecord));
|
||||
}));
|
||||
|
||||
async function handleWorkflowUpdate(req: Request, res: Response) {
|
||||
|
|
@ -370,15 +410,17 @@ async function handleWorkflowUpdate(req: Request, res: Response) {
|
|||
const userEmail = res.locals.userEmail as string | undefined;
|
||||
const { workflowId } = req.params;
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (req.body.title != null) updates.title = req.body.title;
|
||||
if (req.body.prompt_md != null) updates.prompt_md = req.body.prompt_md;
|
||||
const metadata = req.body.metadata as Partial<WorkflowMetadata> | undefined;
|
||||
if (metadata?.title != null) updates.title = metadata.title;
|
||||
if (req.body.skill_md != null) updates.prompt_md = req.body.skill_md;
|
||||
if (req.body.columns_config != null)
|
||||
updates.columns_config = req.body.columns_config;
|
||||
if ("language" in req.body)
|
||||
updates.language = normalizeOptionalString(req.body.language);
|
||||
if ("practice" in req.body) updates.practice = req.body.practice ?? null;
|
||||
if ("jurisdictions" in req.body)
|
||||
updates.jurisdictions = normalizeJurisdictions(req.body.jurisdictions);
|
||||
if (metadata && "language" in metadata)
|
||||
updates.language = normalizeOptionalString(metadata.language);
|
||||
if (metadata && "practice" in metadata)
|
||||
updates.practice = metadata.practice ?? null;
|
||||
if (metadata && "jurisdictions" in metadata)
|
||||
updates.jurisdictions = normalizeJurisdictions(metadata.jurisdictions);
|
||||
|
||||
const db = createServerSupabase();
|
||||
const access = await resolveWorkflowAccess(workflowId, userId, userEmail, db);
|
||||
|
|
@ -398,7 +440,7 @@ async function handleWorkflowUpdate(req: Request, res: Response) {
|
|||
.status(404)
|
||||
.json({ detail: "Workflow not found or not editable" });
|
||||
res.json(
|
||||
withWorkflowAccess(withDatabaseWorkflow(data), {
|
||||
withWorkflowAccess(withDatabaseWorkflow(data as WorkflowRecord), {
|
||||
allowEdit: access.allowEdit,
|
||||
isOwner: access.isOwner,
|
||||
}),
|
||||
|
|
@ -506,7 +548,7 @@ workflowsRouter.post("/:workflowId/open-source", requireAuth, asyncRoute(async (
|
|||
.json({ detail: "Workflow not found or not open-sourceable" });
|
||||
}
|
||||
|
||||
const workflowRecord = withDatabaseWorkflow(workflow as WorkflowRecord);
|
||||
const workflowRecord = workflow as WorkflowRecord;
|
||||
const validationError = validateOpenSourceWorkflow(workflowRecord);
|
||||
if (validationError) {
|
||||
return void res.status(400).json({ detail: validationError });
|
||||
|
|
@ -620,7 +662,7 @@ workflowsRouter.get("/:workflowId", requireAuth, asyncRoute(async (req, res) =>
|
|||
: null;
|
||||
res.json(
|
||||
withOpenSourceSubmission(
|
||||
withWorkflowAccess(access.workflow, {
|
||||
withWorkflowAccess(withDatabaseWorkflow(access.workflow), {
|
||||
allowEdit: access.allowEdit,
|
||||
isOwner: access.isOwner,
|
||||
}),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue