mirror of
https://github.com/willchen96/mike.git
synced 2026-07-24 23:41:04 +02:00
feat: refresh workflows and workflow editor UI
This commit is contained in:
parent
0ed5050b21
commit
fa21ac8fd7
11 changed files with 745 additions and 585 deletions
|
|
@ -22,9 +22,12 @@ Thanks for helping improve Mike. Please keep contributions small, focused, and e
|
|||
|
||||
## System Workflows
|
||||
|
||||
System workflows live in `mike-workflows/system/`. Put structured metadata in
|
||||
the YAML frontmatter at the top of `SKILL.md`, put workflow instructions in the
|
||||
body of `SKILL.md`, and use `table-config.yaml` for tabular review columns.
|
||||
System workflows live in the sibling
|
||||
[`Open-Legal-Products/mike-workflows`](https://github.com/Open-Legal-Products/mike-workflows)
|
||||
repository under `assistant-workflows/` and `tabular-review-workflows/`. Put
|
||||
structured metadata in the YAML frontmatter at the top of `SKILL.md`, set
|
||||
`metadata.mike-availability` to `system`, put workflow instructions in the body
|
||||
of `SKILL.md`, and use `table-columns.yaml` for tabular review columns.
|
||||
|
||||
After changing system workflows, regenerate the app files:
|
||||
|
||||
|
|
@ -34,7 +37,7 @@ node scripts/build-workflows.js
|
|||
|
||||
## Security
|
||||
|
||||
Do not open a public issue for security vulnerabilities. Use [GitHub's private vulnerability reporting](https://github.com/willchen96/mike/security/advisories/new) instead.
|
||||
Do not open a public issue for security vulnerabilities. Use [GitHub's private vulnerability reporting](https://github.com/Open-Legal-Products/mike/security/advisories/new) instead.
|
||||
|
||||
We will aim to respond promptly and coordinate a disclosure timeline with you.
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ Website: [mikeoss.com](https://mikeoss.com)
|
|||
- `backend/schema.sql` - Supabase schema for fresh databases
|
||||
- `backend/migrations/` - dated, incremental schema migrations; on an existing database, apply the files dated after the Mike version you deployed
|
||||
|
||||
## System Workflows
|
||||
|
||||
Mike's system assistant and tabular review workflows are maintained in the
|
||||
[`Open-Legal-Products/mike-workflows`](https://github.com/Open-Legal-Products/mike-workflows)
|
||||
repository.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 20 or newer
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -44,13 +44,10 @@
|
|||
<stop offset="0.5" stop-color="#D9A94E"/>
|
||||
<stop offset="1" stop-color="#B07E2C"/>
|
||||
</linearGradient>
|
||||
<filter id="bookShadow" x="-30%" y="-10%" width="170%" height="130%">
|
||||
<feDropShadow dx="1.2" dy="1.4" stdDeviation="1.1" flood-color="#111827" flood-opacity="0.28"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- brown book -->
|
||||
<g filter="url(#bookShadow)">
|
||||
<g>
|
||||
<rect x="6" y="3.8" width="11" height="49.2" rx="3" fill="url(#leatherBrown)" stroke="url(#rim)" stroke-width="1.4"/>
|
||||
<rect x="6.7" y="4.5" width="9.6" height="47.8" rx="2.4" fill="url(#spineGloss)"/>
|
||||
<path d="M8.1 8V49" stroke="#F0C7A2" stroke-opacity="0.28" stroke-width="0.65"/>
|
||||
|
|
@ -62,7 +59,7 @@
|
|||
</g>
|
||||
|
||||
<!-- blue book -->
|
||||
<g filter="url(#bookShadow)">
|
||||
<g>
|
||||
<rect x="19" y="8" width="11.5" height="45" rx="3" fill="url(#leatherBlue)" stroke="url(#rim)" stroke-width="1.4"/>
|
||||
<rect x="19.7" y="8.7" width="10.1" height="43.6" rx="2.4" fill="url(#spineGloss)"/>
|
||||
<path d="M21.2 12V49" stroke="#B9DCFF" stroke-opacity="0.3" stroke-width="0.65"/>
|
||||
|
|
@ -74,7 +71,7 @@
|
|||
</g>
|
||||
|
||||
<!-- red book -->
|
||||
<g filter="url(#bookShadow)">
|
||||
<g>
|
||||
<rect x="32.5" y="5" width="11" height="48" rx="3" fill="url(#leatherRed)" stroke="url(#rim)" stroke-width="1.4"/>
|
||||
<rect x="33.2" y="5.7" width="9.6" height="46.6" rx="2.4" fill="url(#spineGloss)"/>
|
||||
<path d="M34.6 9V49" stroke="#FFD0D2" stroke-opacity="0.3" stroke-width="0.65"/>
|
||||
|
|
@ -86,7 +83,7 @@
|
|||
</g>
|
||||
|
||||
<!-- green book, leaning against the red book -->
|
||||
<g transform="rotate(-9 62.1 52)" filter="url(#bookShadow)">
|
||||
<g transform="rotate(-9 62.1 52)">
|
||||
<rect x="51.6" y="5.2" width="10.5" height="46.8" rx="3" fill="url(#leatherGreen)" stroke="url(#rim)" stroke-width="1.4"/>
|
||||
<rect x="52.3" y="5.9" width="9.1" height="45.4" rx="2.4" fill="url(#spineGloss)"/>
|
||||
<path d="M53.7 9.5V48.5" stroke="#FFFFFF" stroke-opacity="0.24" stroke-width="0.65"/>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.4 KiB |
|
|
@ -110,6 +110,18 @@ export function MarkdownContent({
|
|||
{...withoutMarkdownNode(props)}
|
||||
/>
|
||||
),
|
||||
h5: (props) => (
|
||||
<h5
|
||||
className="text-base font-semibold mt-3 mb-2"
|
||||
{...withoutMarkdownNode(props)}
|
||||
/>
|
||||
),
|
||||
h6: (props) => (
|
||||
<h6
|
||||
className="text-sm font-semibold mt-3 mb-2"
|
||||
{...withoutMarkdownNode(props)}
|
||||
/>
|
||||
),
|
||||
p: ({ node, ...props }) => {
|
||||
const parent =
|
||||
node && typeof node === "object" && "parent" in node
|
||||
|
|
@ -275,4 +287,3 @@ export function MarkdownContent({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,11 @@ export function useSmoothedReveal(text: string, active: boolean): string {
|
|||
};
|
||||
}, [text.length, active]);
|
||||
|
||||
// Once the stream ends, render the authoritative text immediately. The
|
||||
// effect above keeps the animation cursor in sync, but updating a ref does
|
||||
// not trigger a render; slicing with the last animated state here could
|
||||
// otherwise leave the final few characters permanently hidden.
|
||||
if (!active) return text;
|
||||
|
||||
return text.slice(0, Math.min(revealedInt, text.length));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -164,14 +164,33 @@ interface Props {
|
|||
onClose: () => void;
|
||||
onCreated: (workflow: Workflow) => void;
|
||||
editWorkflow?: Workflow;
|
||||
readOnly?: boolean;
|
||||
onUpdated?: (workflow: Workflow) => void;
|
||||
}
|
||||
|
||||
function getWorkflowSourceLabel(workflow: Workflow) {
|
||||
if (workflow.is_system) return "System";
|
||||
if (workflow.is_owner === false) {
|
||||
return workflow.shared_by_name?.trim() || "Shared";
|
||||
}
|
||||
return "User";
|
||||
}
|
||||
|
||||
const OPEN_SOURCE_STATUS_LABELS: Record<
|
||||
NonNullable<Workflow["open_source_submission"]>["status"],
|
||||
string
|
||||
> = {
|
||||
pending: "Pending review",
|
||||
approved: "Approved",
|
||||
rejected: "Rejected",
|
||||
};
|
||||
|
||||
export function NewWorkflowModal({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
editWorkflow,
|
||||
readOnly = false,
|
||||
onUpdated,
|
||||
}: Props) {
|
||||
const [title, setTitle] = useState("");
|
||||
|
|
@ -199,6 +218,41 @@ export function NewWorkflowModal({
|
|||
const markdownInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isEditing = !!editWorkflow;
|
||||
const viewOnly = isEditing && readOnly;
|
||||
const workflowDetails = editWorkflow
|
||||
? [
|
||||
{
|
||||
label: "Type",
|
||||
value:
|
||||
editWorkflow.metadata.type === "tabular"
|
||||
? "Tabular"
|
||||
: "Assistant",
|
||||
},
|
||||
{
|
||||
label: "Source",
|
||||
value: getWorkflowSourceLabel(editWorkflow),
|
||||
},
|
||||
...(editWorkflow.metadata.version
|
||||
? [
|
||||
{
|
||||
label: "Version",
|
||||
value: editWorkflow.metadata.version,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(editWorkflow.open_source_submission
|
||||
? [
|
||||
{
|
||||
label: "Open source",
|
||||
value:
|
||||
OPEN_SOURCE_STATUS_LABELS[
|
||||
editWorkflow.open_source_submission.status
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: [];
|
||||
const isOtherLanguage = language === "Other";
|
||||
const isOtherPractice = practice === "Other";
|
||||
const isOtherJurisdiction = jurisdiction === "Other";
|
||||
|
|
@ -332,6 +386,7 @@ export function NewWorkflowModal({
|
|||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (viewOnly) return;
|
||||
if (!title.trim()) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
|
@ -417,20 +472,24 @@ export function NewWorkflowModal({
|
|||
onClose={handleClose}
|
||||
breadcrumbs={[
|
||||
"Workflows",
|
||||
isEditing ? "Edit workflow" : "New workflow",
|
||||
isEditing ? "View and Edit details" : "New workflow",
|
||||
]}
|
||||
primaryAction={{
|
||||
label: loading
|
||||
? isEditing
|
||||
? "Saving…"
|
||||
: "Creating…"
|
||||
: isEditing
|
||||
? "Save changes"
|
||||
: "Create workflow",
|
||||
type: "submit",
|
||||
form: formId,
|
||||
disabled: !title.trim() || loading,
|
||||
}}
|
||||
primaryAction={
|
||||
viewOnly
|
||||
? undefined
|
||||
: {
|
||||
label: loading
|
||||
? isEditing
|
||||
? "Saving…"
|
||||
: "Creating…"
|
||||
: isEditing
|
||||
? "Save changes"
|
||||
: "Create workflow",
|
||||
type: "submit",
|
||||
form: formId,
|
||||
disabled: !title.trim() || loading,
|
||||
}
|
||||
}
|
||||
secondaryAction={
|
||||
!isEditing && type === "assistant"
|
||||
? {
|
||||
|
|
@ -445,9 +504,23 @@ export function NewWorkflowModal({
|
|||
<form
|
||||
id={formId}
|
||||
onSubmit={handleSubmit}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
className="flex min-h-0 flex-1 flex-col overflow-y-auto pb-5"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{workflowDetails.length > 0 && (
|
||||
<dl className="grid grid-cols-2 gap-x-5 gap-y-4 rounded-2xl border border-white/70 bg-white/45 p-4 text-sm shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] backdrop-blur-xl">
|
||||
{workflowDetails.map((detail) => (
|
||||
<div key={detail.label} className="min-w-0">
|
||||
<dt className="text-xs text-gray-400">
|
||||
{detail.label}
|
||||
</dt>
|
||||
<dd className="mt-0.5 truncate text-gray-700">
|
||||
{detail.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
<div>
|
||||
<ModalFieldLabel htmlFor="workflow-title">
|
||||
Title
|
||||
|
|
@ -459,7 +532,8 @@ export function NewWorkflowModal({
|
|||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Add workflow name"
|
||||
variant="minimal"
|
||||
autoFocus
|
||||
disabled={viewOnly}
|
||||
autoFocus={!viewOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -494,6 +568,7 @@ export function NewWorkflowModal({
|
|||
id="workflow-language"
|
||||
value={language}
|
||||
options={languageOptions}
|
||||
disabled={viewOnly}
|
||||
open={openDropdown === "language"}
|
||||
onOpenChange={(nextOpen) =>
|
||||
setOpenDropdown((current) =>
|
||||
|
|
@ -517,6 +592,7 @@ export function NewWorkflowModal({
|
|||
ref={customLanguageInputRef}
|
||||
type="text"
|
||||
value={customLanguage}
|
||||
disabled={viewOnly}
|
||||
onChange={(e) =>
|
||||
setCustomLanguage(e.target.value)
|
||||
}
|
||||
|
|
@ -534,6 +610,7 @@ export function NewWorkflowModal({
|
|||
id="workflow-practice"
|
||||
value={practice}
|
||||
options={PRACTICE_OPTIONS}
|
||||
disabled={viewOnly}
|
||||
open={openDropdown === "practice"}
|
||||
onOpenChange={(nextOpen) =>
|
||||
setOpenDropdown((current) =>
|
||||
|
|
@ -557,6 +634,7 @@ export function NewWorkflowModal({
|
|||
ref={customInputRef}
|
||||
type="text"
|
||||
value={customPractice}
|
||||
disabled={viewOnly}
|
||||
onChange={(e) =>
|
||||
setCustomPractice(e.target.value)
|
||||
}
|
||||
|
|
@ -575,6 +653,7 @@ export function NewWorkflowModal({
|
|||
id="workflow-jurisdiction"
|
||||
value={jurisdiction}
|
||||
options={jurisdictionOptions}
|
||||
disabled={viewOnly}
|
||||
open={openDropdown === "jurisdiction"}
|
||||
onOpenChange={(nextOpen) =>
|
||||
setOpenDropdown((current) =>
|
||||
|
|
@ -600,6 +679,7 @@ export function NewWorkflowModal({
|
|||
className="mt-2"
|
||||
value={jurisdictionRegion}
|
||||
options={jurisdictionRegionOptions}
|
||||
disabled={viewOnly}
|
||||
placeholder={
|
||||
jurisdiction === "United States"
|
||||
? "Select state..."
|
||||
|
|
@ -626,6 +706,7 @@ export function NewWorkflowModal({
|
|||
ref={customJurisdictionInputRef}
|
||||
type="text"
|
||||
value={customJurisdiction}
|
||||
disabled={viewOnly}
|
||||
onChange={(e) =>
|
||||
setCustomJurisdiction(e.target.value)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,8 +47,25 @@ import { PeopleModal } from "@/app/components/modals/PeopleModal";
|
|||
import { OpenSourceWorkflowModal } from "@/app/components/workflows/OpenSourceWorkflowModal";
|
||||
import { PageHeader } from "@/app/components/shared/PageHeader";
|
||||
import { PillButton } from "@/app/components/ui/pill-button";
|
||||
import { TabPillButton } from "@/app/components/ui/tab-pill-button";
|
||||
import { LIQUID_TABLE_SURFACE_CLASS } from "@/app/components/ui/liquid-surface";
|
||||
import { NewWorkflowModal } from "@/app/components/workflows/NewWorkflowModal";
|
||||
import { TabularReviewSkeuoIcon } from "@/app/components/shared/AppSidebarSkeuoIcons";
|
||||
import {
|
||||
TABLE_CHECKBOX_CLASS,
|
||||
SkeletonDot,
|
||||
SkeletonLine,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableEmptyState,
|
||||
TableHeaderCell,
|
||||
TableHeaderRow,
|
||||
TablePrimaryCell,
|
||||
TableRow,
|
||||
TableScrollArea,
|
||||
TableStickyCell,
|
||||
} from "@/app/components/shared/TablePrimitive";
|
||||
import { TableToolbar } from "@/app/components/shared/TableToolbar";
|
||||
import { useAuth } from "@/app/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/app/contexts/UserProfileContext";
|
||||
import { downloadWorkflowZip } from "./workflowZipExport";
|
||||
|
|
@ -71,7 +88,6 @@ type DeleteStatus = "idle" | "loading" | "complete";
|
|||
type WorkflowShare = Awaited<ReturnType<typeof listWorkflowShares>>[number];
|
||||
|
||||
const NAME_COL_W = "w-[332px] shrink-0";
|
||||
const CHECKBOX_GUTTER = "h-2.5 w-2.5 shrink-0";
|
||||
const WORKFLOW_CONTRIBUTIONS_ENABLED =
|
||||
process.env.NEXT_PUBLIC_WORKFLOW_CONTRIBUTIONS_ENABLED === "true";
|
||||
|
||||
|
|
@ -82,8 +98,6 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
|
|||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const { profile } = useUserProfile();
|
||||
const stickyCellBg = "bg-[#fafbfc]";
|
||||
|
||||
const [workflow, setWorkflow] = useState<Workflow | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
|
|
@ -308,6 +322,16 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
|
|||
setAddColumnOpen(false);
|
||||
}
|
||||
|
||||
function handleDeleteSelectedColumns() {
|
||||
const next = columns
|
||||
.filter((column) => !selectedColIndices.includes(column.index))
|
||||
.map((column, index) => ({ ...column, index }));
|
||||
setColumns(next);
|
||||
saveColumns(next);
|
||||
setSelectedColIndices([]);
|
||||
setColActionsOpen(false);
|
||||
}
|
||||
|
||||
function handleColumnSaved(updated: ColumnConfig) {
|
||||
const next = columns.map((c) =>
|
||||
c.index === updated.index ? updated : c,
|
||||
|
|
@ -335,7 +359,6 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
|
|||
]}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<WorkflowMetadataSkeleton />
|
||||
{workflowType === "tabular" ? (
|
||||
<TabularWorkflowEditorSkeleton />
|
||||
) : (
|
||||
|
|
@ -364,15 +387,14 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
|
|||
icon: Download,
|
||||
onSelect: () => downloadWorkflowZip(workflow, promptMd, columns),
|
||||
},
|
||||
{
|
||||
label: "View and Edit details",
|
||||
icon: Pencil,
|
||||
onSelect: () => setDetailsOpen(true),
|
||||
},
|
||||
];
|
||||
|
||||
if (!readOnly) {
|
||||
workflowActionItems.push({
|
||||
label: "Edit details",
|
||||
icon: Pencil,
|
||||
onSelect: () => setDetailsOpen(true),
|
||||
});
|
||||
|
||||
if (canOpenSource) {
|
||||
workflowActionItems.push({
|
||||
label: "Open source this",
|
||||
|
|
@ -467,6 +489,7 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
|
|||
<NewWorkflowModal
|
||||
open={detailsOpen}
|
||||
editWorkflow={workflow}
|
||||
readOnly={readOnly}
|
||||
onClose={() => setDetailsOpen(false)}
|
||||
onCreated={() => undefined}
|
||||
onUpdated={(updated) => {
|
||||
|
|
@ -537,12 +560,9 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
|
|||
|
||||
{/* Body */}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{/* Metadata */}
|
||||
<WorkflowMetadata workflow={workflow} />
|
||||
|
||||
{workflow.metadata.type === "assistant" ? (
|
||||
/* ── Assistant: WYSIWYG editor ── */
|
||||
<div className="flex-1 min-h-0 px-4 pb-2 pt-4 md:px-10 md:pb-3">
|
||||
<div className="flex-1 min-h-0 px-4 pb-2 pt-4 md:px-6 md:pb-3">
|
||||
<WorkflowPromptEditor
|
||||
value={promptMd}
|
||||
onChange={readOnly ? undefined : handlePromptChange}
|
||||
|
|
@ -552,51 +572,62 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
|
|||
) : (
|
||||
/* ── Tabular: Column table ── */
|
||||
<div className="flex flex-col flex-1 min-h-0 pt-2">
|
||||
{/* Toolbar */}
|
||||
{!readOnly && (
|
||||
<div className="flex items-center justify-between h-10 shrink-0 border-b border-gray-200 px-4 md:px-10">
|
||||
{visibleColumns.length > 0 &&
|
||||
selectedColIndices.length > 0 && (
|
||||
<div ref={colActionsRef} className="relative">
|
||||
<button
|
||||
onClick={() => setColActionsOpen((v) => !v)}
|
||||
className="flex items-center gap-1 text-xs font-medium text-gray-700 hover:text-gray-900 transition-colors"
|
||||
<TableToolbar
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{visibleColumns.length > 0 &&
|
||||
selectedColIndices.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
ref={colActionsRef}
|
||||
className="relative max-md:hidden"
|
||||
>
|
||||
<TabPillButton
|
||||
onClick={() =>
|
||||
setColActionsOpen(
|
||||
(open) =>
|
||||
!open,
|
||||
)
|
||||
}
|
||||
>
|
||||
Actions
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</TabPillButton>
|
||||
{colActionsOpen && (
|
||||
<div className="absolute top-full right-0 mt-1 w-36 rounded-lg border border-gray-100 bg-white shadow-lg z-50 overflow-hidden">
|
||||
<button
|
||||
onClick={
|
||||
handleDeleteSelectedColumns
|
||||
}
|
||||
className="w-full px-3 py-1.5 text-left text-xs text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TabPillButton
|
||||
onClick={
|
||||
handleDeleteSelectedColumns
|
||||
}
|
||||
className="text-red-600 md:hidden"
|
||||
>
|
||||
Delete
|
||||
</TabPillButton>
|
||||
</>
|
||||
)}
|
||||
<TabPillButton
|
||||
onClick={() =>
|
||||
setAddColumnOpen(true)
|
||||
}
|
||||
>
|
||||
Actions
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{colActionsOpen && (
|
||||
<div className="absolute top-full right-0 mt-1 w-36 rounded-lg border border-gray-100 bg-white shadow-lg z-50 overflow-hidden">
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = columns
|
||||
.filter((c) => !selectedColIndices.includes(c.index))
|
||||
.map((c, i) => ({ ...c, index: i }));
|
||||
setColumns(next);
|
||||
saveColumns(next);
|
||||
setSelectedColIndices([]);
|
||||
setColActionsOpen(false);
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-left text-xs text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Column
|
||||
</TabPillButton>
|
||||
</div>
|
||||
)}
|
||||
{(visibleColumns.length === 0 ||
|
||||
selectedColIndices.length === 0) && (
|
||||
<span aria-hidden="true" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => setAddColumnOpen(true)}
|
||||
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Column
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{readOnly && (
|
||||
<div className="flex h-10 shrink-0 items-center bg-gray-50 px-4 md:px-10">
|
||||
|
|
@ -606,36 +637,63 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<div className="min-w-max flex min-h-full flex-col">
|
||||
{/* Table header */}
|
||||
<div className={`flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200 text-xs text-gray-500 font-medium shrink-0 select-none ${readOnly ? "border-t" : ""}`}>
|
||||
<div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}>
|
||||
{visibleColumns.length > 0 ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedColIndices.length === visibleColumns.length}
|
||||
ref={(el) => { if (el) el.indeterminate = selectedColIndices.length > 0 && selectedColIndices.length < visibleColumns.length; }}
|
||||
onChange={() => setSelectedColIndices(selectedColIndices.length === visibleColumns.length ? [] : visibleColumns.map((c) => c.index))}
|
||||
className={`${CHECKBOX_GUTTER} rounded border-gray-200 cursor-pointer accent-black`}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={CHECKBOX_GUTTER}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span>Column Title</span>
|
||||
</div>
|
||||
<div className="ml-auto w-36 shrink-0">Format</div>
|
||||
<div className="flex-1 min-w-0">Prompt</div>
|
||||
{!readOnly && <div className="w-8 shrink-0" />}
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
<div className="flex-1">
|
||||
<TableScrollArea
|
||||
header={
|
||||
<TableHeaderRow className="md:pr-10">
|
||||
<TableStickyCell
|
||||
header
|
||||
widthClassName={NAME_COL_W}
|
||||
>
|
||||
{visibleColumns.length > 0 ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
selectedColIndices.length ===
|
||||
visibleColumns.length
|
||||
}
|
||||
ref={(el) => {
|
||||
if (el)
|
||||
el.indeterminate =
|
||||
selectedColIndices.length >
|
||||
0 &&
|
||||
selectedColIndices.length <
|
||||
visibleColumns.length;
|
||||
}}
|
||||
onChange={() =>
|
||||
setSelectedColIndices(
|
||||
selectedColIndices.length ===
|
||||
visibleColumns.length
|
||||
? []
|
||||
: visibleColumns.map(
|
||||
(column) =>
|
||||
column.index,
|
||||
),
|
||||
)
|
||||
}
|
||||
className={TABLE_CHECKBOX_CLASS}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="mr-4 h-2.5 w-2.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span>Column Title</span>
|
||||
</TableStickyCell>
|
||||
<TableHeaderCell className="ml-auto w-36">
|
||||
Format
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="min-w-[240px] flex-1">
|
||||
Prompt
|
||||
</TableHeaderCell>
|
||||
{!readOnly && (
|
||||
<TableHeaderCell className="w-8" />
|
||||
)}
|
||||
</TableHeaderRow>
|
||||
}
|
||||
>
|
||||
{visibleColumns.length === 0 ? (
|
||||
<div className="flex flex-col items-start py-24 w-full max-w-xs mx-auto">
|
||||
<TableEmptyState>
|
||||
<TabularReviewSkeuoIcon className="mb-4 h-8 w-8" />
|
||||
<p className="text-2xl font-medium font-serif text-gray-900">
|
||||
Columns
|
||||
|
|
@ -654,68 +712,100 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
|
|||
Add Column
|
||||
</PillButton>
|
||||
)}
|
||||
</div>
|
||||
</TableEmptyState>
|
||||
) : (
|
||||
visibleColumns.map((col) => {
|
||||
const FormatIcon = formatIcon(col.format ?? "text");
|
||||
const isChecked = selectedColIndices.includes(col.index);
|
||||
return (
|
||||
<div
|
||||
key={col.index}
|
||||
onClick={() => readOnly ? setViewingColumn(col) : setEditingColumn(col)}
|
||||
className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-100/70 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className={`sticky left-0 z-[60] ${NAME_COL_W} py-2 pl-4 pr-2 ${isChecked ? "bg-gray-50" : stickyCellBg} transition-colors group-hover:bg-gray-100/70`}>
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => setSelectedColIndices((prev) => prev.includes(col.index) ? prev.filter((i) => i !== col.index) : [...prev, col.index])}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`${CHECKBOX_GUTTER} rounded border-gray-200 cursor-pointer accent-black`}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-gray-800">
|
||||
{col.name}
|
||||
<TableBody>
|
||||
{visibleColumns.map((col) => {
|
||||
const FormatIcon = formatIcon(
|
||||
col.format ?? "text",
|
||||
);
|
||||
const isChecked =
|
||||
selectedColIndices.includes(
|
||||
col.index,
|
||||
);
|
||||
return (
|
||||
<TableRow
|
||||
key={col.index}
|
||||
selected={isChecked}
|
||||
onClick={() =>
|
||||
readOnly
|
||||
? setViewingColumn(col)
|
||||
: setEditingColumn(col)
|
||||
}
|
||||
className="md:pr-10"
|
||||
>
|
||||
<TablePrimaryCell
|
||||
widthClassName={NAME_COL_W}
|
||||
selected={isChecked}
|
||||
onSelectionChange={() =>
|
||||
setSelectedColIndices(
|
||||
(previous) =>
|
||||
previous.includes(
|
||||
col.index,
|
||||
)
|
||||
? previous.filter(
|
||||
(index) =>
|
||||
index !==
|
||||
col.index,
|
||||
)
|
||||
: [
|
||||
...previous,
|
||||
col.index,
|
||||
],
|
||||
)
|
||||
}
|
||||
label={col.name}
|
||||
/>
|
||||
<TableCell className="ml-auto w-36">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-gray-600">
|
||||
<FormatIcon
|
||||
className={`h-3.5 w-3.5 ${formatIconClassName(col.format ?? "text")}`}
|
||||
/>
|
||||
{formatLabel(
|
||||
col.format ??
|
||||
"text",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto w-36 shrink-0">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-gray-600">
|
||||
<FormatIcon
|
||||
className={`h-3.5 w-3.5 ${formatIconClassName(col.format ?? "text")}`}
|
||||
/>
|
||||
{formatLabel(col.format ?? "text")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pr-4">
|
||||
<span className="text-xs text-gray-500 truncate block">
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[240px] flex-1 pr-4 text-xs">
|
||||
{col.prompt}
|
||||
</span>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="w-8 shrink-0 flex justify-end">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const next = columns
|
||||
.filter((c) => c.index !== col.index)
|
||||
.map((c, i) => ({ ...c, index: i }));
|
||||
setColumns(next);
|
||||
saveColumns(next);
|
||||
}}
|
||||
className="p-1 text-gray-300 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
</TableCell>
|
||||
{!readOnly && (
|
||||
<div className="w-8 shrink-0 flex justify-end">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const next =
|
||||
columns
|
||||
.filter(
|
||||
(column) =>
|
||||
column.index !==
|
||||
col.index,
|
||||
)
|
||||
.map(
|
||||
(
|
||||
column,
|
||||
index,
|
||||
) => ({
|
||||
...column,
|
||||
index,
|
||||
}),
|
||||
);
|
||||
setColumns(next);
|
||||
saveColumns(next);
|
||||
}}
|
||||
className="p-1 text-gray-300 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -753,81 +843,12 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
|
|||
);
|
||||
}
|
||||
|
||||
function WorkflowMetadata({ workflow }: { workflow: Workflow }) {
|
||||
const fields: { label: string; value: string }[] = [
|
||||
{ label: "Type", value: workflow.metadata.type === "tabular" ? "Tabular" : "Assistant" },
|
||||
{ label: "Source", value: getWorkflowSourceLabel(workflow) },
|
||||
];
|
||||
if (workflow.metadata.language) fields.push({ label: "Language", value: workflow.metadata.language });
|
||||
if (workflow.metadata.version) fields.push({ label: "Version", value: workflow.metadata.version });
|
||||
if (workflow.metadata.practice) fields.push({ label: "Practice", value: workflow.metadata.practice });
|
||||
if (workflow.metadata.jurisdictions?.length) {
|
||||
fields.push({ label: "Jurisdiction", value: workflow.metadata.jurisdictions.join(", ") });
|
||||
}
|
||||
if (workflow.open_source_submission) {
|
||||
const statusLabels: Record<
|
||||
NonNullable<Workflow["open_source_submission"]>["status"],
|
||||
string
|
||||
> = {
|
||||
pending: "Pending review",
|
||||
approved: "Approved",
|
||||
rejected: "Rejected",
|
||||
};
|
||||
fields.push({
|
||||
label: "Open source",
|
||||
value: statusLabels[workflow.open_source_submission.status],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-3 px-4 pb-3 pt-1 text-xs shrink-0 md:px-10">
|
||||
{fields.map(({ label, value }) => (
|
||||
<div key={label} className="flex flex-col gap-0.5">
|
||||
<span className="text-gray-400">{label}</span>
|
||||
<span className="text-gray-700">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowMetadataSkeleton() {
|
||||
const fields = [
|
||||
{ labelWidth: "w-8", valueWidth: "w-16" },
|
||||
{ labelWidth: "w-10", valueWidth: "w-14" },
|
||||
{ labelWidth: "w-12", valueWidth: "w-20" },
|
||||
{ labelWidth: "w-10", valueWidth: "w-12" },
|
||||
{ labelWidth: "w-12", valueWidth: "w-24" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 flex-wrap gap-x-8 gap-y-3 px-4 pb-3 pt-1 md:px-10">
|
||||
{fields.map((field, index) => (
|
||||
<div key={index} className="flex flex-col gap-0.5">
|
||||
<div
|
||||
className={`h-4 ${field.labelWidth} animate-pulse rounded bg-gray-100`}
|
||||
/>
|
||||
<div
|
||||
className={`h-4 ${field.valueWidth} animate-pulse rounded bg-gray-100`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getWorkflowSourceLabel(workflow: Workflow) {
|
||||
if (workflow.is_system) return "System";
|
||||
if (workflow.is_owner === false) {
|
||||
return workflow.shared_by_name?.trim() || "Shared";
|
||||
}
|
||||
return "User";
|
||||
}
|
||||
|
||||
function AssistantWorkflowEditorSkeleton() {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 px-4 pb-2 pt-4 md:px-10 md:pb-3">
|
||||
<div className="h-full rounded-md border border-gray-200 bg-gray-50 px-5 py-4">
|
||||
<div className="min-h-0 flex-1 px-4 pb-2 pt-4 md:px-6 md:pb-3">
|
||||
<div
|
||||
className={`h-full px-5 py-4 ${LIQUID_TABLE_SURFACE_CLASS}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="h-3 w-24 animate-pulse rounded bg-gray-100" />
|
||||
<div className="h-3 w-5/6 animate-pulse rounded bg-gray-100" />
|
||||
|
|
@ -851,60 +872,68 @@ function AssistantWorkflowEditorSkeleton() {
|
|||
}
|
||||
|
||||
function TabularWorkflowEditorSkeleton() {
|
||||
const titleWidths = ["w-36", "w-44", "w-40", "w-52", "w-48"];
|
||||
const promptWidths = ["w-64", "w-80", "w-72", "w-96", "w-60"];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col pt-2">
|
||||
<div className="flex h-10 shrink-0 items-center justify-end border-b border-gray-200 px-4 md:px-10">
|
||||
<div className="h-3 w-20 animate-pulse rounded bg-gray-100" />
|
||||
</div>
|
||||
|
||||
<div className="flex h-8 shrink-0 items-center border-b border-gray-200 pr-3 md:pr-10">
|
||||
<div
|
||||
className={`${NAME_COL_W} flex shrink-0 items-center gap-4 self-stretch pl-4 pr-2`}
|
||||
>
|
||||
<div
|
||||
className={`${CHECKBOX_GUTTER} animate-pulse rounded bg-gray-100`}
|
||||
/>
|
||||
<div className="h-2.5 w-20 animate-pulse rounded bg-gray-100" />
|
||||
</div>
|
||||
<div className="w-36 shrink-0">
|
||||
<div className="h-2.5 w-14 animate-pulse rounded bg-gray-100" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="h-2.5 w-12 animate-pulse rounded bg-gray-100" />
|
||||
</div>
|
||||
<div className="w-8 shrink-0" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex h-10 items-center border-b border-gray-50 pr-3 md:pr-10"
|
||||
>
|
||||
<div
|
||||
className={`${NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}
|
||||
<TableToolbar
|
||||
actions={
|
||||
<SkeletonLine className="h-7 w-24 rounded-full" />
|
||||
}
|
||||
/>
|
||||
<TableScrollArea
|
||||
header={
|
||||
<TableHeaderRow className="md:pr-10">
|
||||
<TableStickyCell
|
||||
header
|
||||
hover={false}
|
||||
widthClassName={NAME_COL_W}
|
||||
>
|
||||
<div
|
||||
className={`${CHECKBOX_GUTTER} animate-pulse rounded bg-gray-100`}
|
||||
/>
|
||||
<div
|
||||
className="h-3 animate-pulse rounded bg-gray-100"
|
||||
style={{ width: `${40 + (i * 13) % 35}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-36 shrink-0">
|
||||
<div className="h-3 w-16 animate-pulse rounded bg-gray-100" />
|
||||
</div>
|
||||
<div className="flex-1 pr-4">
|
||||
<div
|
||||
className="h-3 animate-pulse rounded bg-gray-100"
|
||||
style={{ width: `${50 + (i * 17) % 35}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-8 shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<SkeletonDot className="mr-4" />
|
||||
<SkeletonLine className="h-2.5 w-20" />
|
||||
</TableStickyCell>
|
||||
<TableHeaderCell className="ml-auto w-36">
|
||||
<SkeletonLine className="h-2.5 w-14" />
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="min-w-[240px] flex-1">
|
||||
<SkeletonLine className="h-2.5 w-12" />
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="w-8" />
|
||||
</TableHeaderRow>
|
||||
}
|
||||
>
|
||||
<TableBody>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<TableRow
|
||||
key={i}
|
||||
interactive={false}
|
||||
className="md:pr-10"
|
||||
>
|
||||
<TableStickyCell
|
||||
hover={false}
|
||||
widthClassName={NAME_COL_W}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<SkeletonDot className="mr-4" />
|
||||
<SkeletonLine
|
||||
className={`h-3 ${titleWidths[i - 1]}`}
|
||||
/>
|
||||
</div>
|
||||
</TableStickyCell>
|
||||
<TableCell className="ml-auto w-36">
|
||||
<SkeletonLine className="w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[240px] flex-1 pr-4">
|
||||
<SkeletonLine
|
||||
className={promptWidths[i - 1]}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="w-8" />
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</TableScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
Table2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/app/components/ui/button";
|
||||
import { LIQUID_TABLE_SURFACE_CLASS } from "@/app/components/ui/liquid-surface";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
|
|
@ -352,14 +353,10 @@ export function WorkflowPromptEditor({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-full flex-col overflow-hidden bg-white ${
|
||||
readOnly
|
||||
? "rounded-md border border-gray-200"
|
||||
: "rounded-md border border-gray-200"
|
||||
}`}
|
||||
className={`flex h-full flex-col overflow-hidden ${LIQUID_TABLE_SURFACE_CLASS}`}
|
||||
>
|
||||
{!readOnly && editor && (
|
||||
<div className="flex items-center gap-0.5 px-2 py-1.5 border-b border-gray-100 bg-gray-50 shrink-0">
|
||||
<div className="flex shrink-0 items-center gap-0.5 border-b border-white/70 bg-app-surface px-2 py-1.5 backdrop-blur-xl">
|
||||
<AppToolbarButton
|
||||
onClick={() =>
|
||||
rawMode
|
||||
|
|
@ -558,7 +555,7 @@ export function WorkflowPromptEditor({
|
|||
</div>
|
||||
)}
|
||||
{readOnly && (
|
||||
<div className="flex h-9 shrink-0 items-center justify-between bg-gray-50 px-5">
|
||||
<div className="flex h-9 shrink-0 items-center justify-between bg-app-surface px-5 backdrop-blur-xl">
|
||||
<span className="text-xs font-medium text-gray-500">
|
||||
Read-only
|
||||
</span>
|
||||
|
|
@ -591,7 +588,7 @@ export function WorkflowPromptEditor({
|
|||
}
|
||||
readOnly={readOnly}
|
||||
spellCheck={false}
|
||||
className="h-full min-h-full w-full resize-none bg-white px-5 py-4 font-mono text-xs leading-6 text-gray-800 outline-none placeholder:text-gray-400 read-only:cursor-default"
|
||||
className="h-full min-h-full w-full resize-none bg-transparent px-5 py-4 font-mono text-xs leading-6 text-gray-800 outline-none placeholder:text-gray-400 read-only:cursor-default"
|
||||
aria-label="Raw Markdown"
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -380,11 +380,16 @@ export function useAssistantChat({
|
|||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
if (done) {
|
||||
// Flush any bytes still held by TextDecoder. A response is allowed
|
||||
// to close without a final newline, so the remaining buffer must be
|
||||
// parsed as the last SSE record instead of being discarded.
|
||||
buffer += decoder.decode();
|
||||
} else {
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
}
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
buffer = done ? "" : lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
|
@ -1181,6 +1186,8 @@ export function useAssistantChat({
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (done) break;
|
||||
}
|
||||
|
||||
finalizeStreamingReasoning();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ const path = require("path");
|
|||
const ROOT_DIR = path.resolve(__dirname, "..");
|
||||
const WORKSPACE_DIR = path.resolve(ROOT_DIR, "..");
|
||||
const WORKFLOWS_DIR = path.join(WORKSPACE_DIR, "mike-workflows");
|
||||
const SYSTEM_WORKFLOWS_DIR = path.join(WORKFLOWS_DIR, "system");
|
||||
const WORKFLOW_COLLECTIONS = [
|
||||
{ directory: "assistant-workflows", type: "assistant" },
|
||||
{ directory: "tabular-review-workflows", type: "tabular" },
|
||||
];
|
||||
const BACKEND_OUT = path.join(ROOT_DIR, "backend/src/lib/systemWorkflows.ts");
|
||||
const LANDING_OUT = path.join(ROOT_DIR, "landing/app/generated-workflows.ts");
|
||||
|
||||
|
|
@ -75,6 +78,7 @@ function parseSimpleYaml(source, label) {
|
|||
|
||||
const scalarItems = [];
|
||||
const objectItems = [];
|
||||
const properties = {};
|
||||
let mode = null;
|
||||
i++;
|
||||
for (; i < lines.length; i++) {
|
||||
|
|
@ -116,6 +120,19 @@ function parseSimpleYaml(source, label) {
|
|||
continue;
|
||||
}
|
||||
|
||||
const childPropMatch = child.match(/^ ([A-Za-z_][A-Za-z0-9_-]*):(.*)$/);
|
||||
if (childPropMatch) {
|
||||
mode ??= "properties";
|
||||
if (mode !== "properties") {
|
||||
fail(`${label}.${key} mixes mapping and list values`);
|
||||
}
|
||||
properties[childPropMatch[1]] = parseScalar(
|
||||
childPropMatch[2],
|
||||
`${label}.${key}.${childPropMatch[1]}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const propMatch = child.match(/^ ([A-Za-z_][A-Za-z0-9_-]*):(.*)$/);
|
||||
if (!propMatch || mode !== "objects" || objectItems.length === 0) {
|
||||
fail(`${label}:${i + 1} has unsupported frontmatter structure`);
|
||||
|
|
@ -126,7 +143,12 @@ function parseSimpleYaml(source, label) {
|
|||
);
|
||||
}
|
||||
|
||||
result[key] = mode === "objects" ? objectItems : scalarItems;
|
||||
result[key] =
|
||||
mode === "objects"
|
||||
? objectItems
|
||||
: mode === "properties"
|
||||
? properties
|
||||
: scalarItems;
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
@ -152,7 +174,7 @@ function readSkillFile(filePath) {
|
|||
};
|
||||
}
|
||||
|
||||
function parseTableConfigYaml(filePath) {
|
||||
function parseTableColumnsYaml(filePath) {
|
||||
const lines = readText(filePath).replace(/\r\n/g, "\n").split("\n");
|
||||
const result = { columns_config: [] };
|
||||
let i = 0;
|
||||
|
|
@ -171,8 +193,8 @@ function parseTableConfigYaml(filePath) {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (line !== "columns_config:") {
|
||||
fail(`${relative(filePath)}:${i + 1} is not valid table config YAML`);
|
||||
if (line !== "columns:") {
|
||||
fail(`${relative(filePath)}:${i + 1} is not valid table columns YAML`);
|
||||
}
|
||||
i++;
|
||||
break;
|
||||
|
|
@ -300,9 +322,8 @@ function assertColumnConfig(columns, label) {
|
|||
});
|
||||
}
|
||||
|
||||
function readWorkflow(category, dirent) {
|
||||
const slug = dirent.name;
|
||||
const workflowDir = path.join(SYSTEM_WORKFLOWS_DIR, category, slug);
|
||||
function readWorkflow(category, workflowDir) {
|
||||
const slug = path.basename(workflowDir);
|
||||
const metadataPath = path.join(workflowDir, "metadata.json");
|
||||
if (fs.existsSync(metadataPath)) {
|
||||
fail(`${relative(metadataPath)} is no longer supported; use SKILL.md frontmatter`);
|
||||
|
|
@ -311,103 +332,93 @@ function readWorkflow(category, dirent) {
|
|||
if (!fs.existsSync(skillPath)) {
|
||||
fail(`${relative(skillPath)} is required`);
|
||||
}
|
||||
const { metadata, body: skillMd, fullText: sourceSkillMd } = readSkillFile(skillPath);
|
||||
const { metadata: frontmatter, body: skillMd, fullText: sourceSkillMd } =
|
||||
readSkillFile(skillPath);
|
||||
const label = `${relative(skillPath)} frontmatter`;
|
||||
const metadata = frontmatter.metadata;
|
||||
const id = `builtin-${slug}`;
|
||||
|
||||
if (metadata.id !== undefined) {
|
||||
fail(`${label}.id is not supported; the ID is generated from the directory name`);
|
||||
}
|
||||
if (metadata.$schema !== undefined) {
|
||||
fail(`${label}.$schema is not supported in SKILL.md frontmatter`);
|
||||
}
|
||||
if (metadata.title !== undefined) {
|
||||
fail(`${label}.title is not supported; use name`);
|
||||
}
|
||||
if (metadata.order !== undefined) {
|
||||
fail(`${label}.order is not supported`);
|
||||
}
|
||||
assertString(metadata.name, `${label}.name`);
|
||||
if (metadata.name !== slug) {
|
||||
assertString(frontmatter.name, `${label}.name`);
|
||||
if (frontmatter.name !== slug) {
|
||||
fail(`${label}.name must match the folder name "${slug}"`);
|
||||
}
|
||||
assertString(metadata.display_name, `${label}.display_name`);
|
||||
assertString(metadata.description, `${label}.description`);
|
||||
const contributors = normalizeContributors(
|
||||
metadata.contributors,
|
||||
`${label}.contributors`,
|
||||
);
|
||||
assertString(frontmatter.description, `${label}.description`);
|
||||
assertString(frontmatter.license, `${label}.license`);
|
||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||
fail(`${label}.metadata must be a mapping`);
|
||||
}
|
||||
assertString(metadata.author, `${label}.metadata.author`);
|
||||
assertString(metadata.language, `${label}.language`);
|
||||
assertString(metadata.version, `${label}.version`);
|
||||
if (metadata.type !== category) {
|
||||
fail(`${label}.type must be "${category}"`);
|
||||
assertString(metadata["mike-display-name"], `${label}.metadata.mike-display-name`);
|
||||
if (metadata["mike-type"] !== category) {
|
||||
fail(`${label}.metadata.mike-type must be "${category}"`);
|
||||
}
|
||||
if (metadata.category !== undefined) {
|
||||
fail(`${label}.category is not supported`);
|
||||
if (!["system", "add-on"].includes(metadata["mike-availability"])) {
|
||||
fail(`${label}.metadata.mike-availability must be "system" or "add-on"`);
|
||||
}
|
||||
if (metadata.action !== undefined) {
|
||||
fail(`${label}.action is not supported`);
|
||||
}
|
||||
assertOptionalString(metadata.practice, `${label}.practice`);
|
||||
assertOptionalStringArray(metadata.jurisdictions, `${label}.jurisdictions`);
|
||||
assertString(metadata.practice, `${label}.metadata.practice`);
|
||||
assertString(metadata.jurisdictions, `${label}.metadata.jurisdictions`);
|
||||
|
||||
const normalizedMetadata = {
|
||||
title: metadata["mike-display-name"],
|
||||
description: frontmatter.description,
|
||||
type: metadata["mike-type"],
|
||||
contributors: [
|
||||
{
|
||||
name: metadata.author.trim(),
|
||||
organisation: null,
|
||||
role: null,
|
||||
linkedin: null,
|
||||
},
|
||||
],
|
||||
language: metadata.language,
|
||||
version: metadata.version,
|
||||
practice: metadata.practice,
|
||||
jurisdictions: metadata.jurisdictions
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
};
|
||||
|
||||
if (category === "assistant") {
|
||||
if (!skillMd.trim()) {
|
||||
fail(`${relative(skillPath)} must include instructions after frontmatter`);
|
||||
}
|
||||
const tableConfigPath = path.join(workflowDir, "table-config.yaml");
|
||||
if (fs.existsSync(tableConfigPath)) {
|
||||
fail(`${relative(tableConfigPath)} is only supported for tabular workflows`);
|
||||
const tableColumnsPath = path.join(workflowDir, "table-columns.yaml");
|
||||
if (fs.existsSync(tableColumnsPath)) {
|
||||
fail(`${relative(tableColumnsPath)} is only supported for tabular workflows`);
|
||||
}
|
||||
return {
|
||||
id,
|
||||
metadata: {
|
||||
title: metadata.display_name,
|
||||
description: metadata.description,
|
||||
type: metadata.type,
|
||||
contributors,
|
||||
language: metadata.language,
|
||||
version: metadata.version,
|
||||
practice: metadata.practice ?? null,
|
||||
jurisdictions: metadata.jurisdictions ?? null,
|
||||
},
|
||||
availability: metadata["mike-availability"],
|
||||
metadata: normalizedMetadata,
|
||||
skill_md: skillMd,
|
||||
source_skill_md: sourceSkillMd,
|
||||
columns_config: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (metadata.columns_config !== undefined) {
|
||||
fail(`${label}.columns_config is not supported; use table-config.yaml`);
|
||||
const tableColumnsPath = path.join(workflowDir, "table-columns.yaml");
|
||||
if (!fs.existsSync(tableColumnsPath)) {
|
||||
fail(`${relative(tableColumnsPath)} is required for tabular workflows`);
|
||||
}
|
||||
const legacyTableConfigPath = path.join(workflowDir, "table-config.json");
|
||||
if (fs.existsSync(legacyTableConfigPath)) {
|
||||
fail(`${relative(legacyTableConfigPath)} is no longer supported; use table-config.yaml`);
|
||||
}
|
||||
const tableConfigPath = path.join(workflowDir, "table-config.yaml");
|
||||
if (!fs.existsSync(tableConfigPath)) {
|
||||
fail(`${relative(tableConfigPath)} is required for tabular workflows`);
|
||||
}
|
||||
const tableConfig = parseTableConfigYaml(tableConfigPath);
|
||||
const tableConfigLabel = relative(tableConfigPath);
|
||||
const expectedTableConfigSchema = "../../../schema/table-config.schema.yaml";
|
||||
if (tableConfig.$schema !== expectedTableConfigSchema) {
|
||||
fail(`${tableConfigLabel}.$schema must be "${expectedTableConfigSchema}"`);
|
||||
const tableConfig = parseTableColumnsYaml(tableColumnsPath);
|
||||
const tableConfigLabel = relative(tableColumnsPath);
|
||||
const expectedSchemaPath = path.join(
|
||||
WORKFLOWS_DIR,
|
||||
"workflow-schema/table-columns.schema.yaml",
|
||||
);
|
||||
const actualSchemaPath = path.resolve(workflowDir, tableConfig.$schema ?? "");
|
||||
if (actualSchemaPath !== expectedSchemaPath) {
|
||||
fail(`${tableConfigLabel}.$schema must point to workflow-schema/table-columns.schema.yaml`);
|
||||
}
|
||||
assertColumnConfig(tableConfig.columns_config, tableConfigLabel);
|
||||
|
||||
return {
|
||||
id,
|
||||
metadata: {
|
||||
title: metadata.display_name,
|
||||
description: metadata.description,
|
||||
type: metadata.type,
|
||||
contributors,
|
||||
language: metadata.language,
|
||||
version: metadata.version,
|
||||
practice: metadata.practice ?? null,
|
||||
jurisdictions: metadata.jurisdictions ?? null,
|
||||
},
|
||||
availability: metadata["mike-availability"],
|
||||
metadata: normalizedMetadata,
|
||||
skill_md: skillMd || null,
|
||||
source_skill_md: sourceSkillMd,
|
||||
columns_config: tableConfig.columns_config,
|
||||
|
|
@ -418,17 +429,30 @@ function loadWorkflows() {
|
|||
const workflows = [];
|
||||
const seenIds = new Set();
|
||||
|
||||
for (const category of ["assistant", "tabular"]) {
|
||||
const categoryDir = path.join(SYSTEM_WORKFLOWS_DIR, category);
|
||||
if (!fs.existsSync(categoryDir)) continue;
|
||||
for (const collection of WORKFLOW_COLLECTIONS) {
|
||||
const collectionDir = path.join(WORKFLOWS_DIR, collection.directory);
|
||||
if (!fs.existsSync(collectionDir)) continue;
|
||||
const workflowDirs = fs
|
||||
.readdirSync(collectionDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
|
||||
.flatMap((entry) => {
|
||||
const entryDir = path.join(collectionDir, entry.name);
|
||||
if (fs.existsSync(path.join(entryDir, "SKILL.md"))) return [entryDir];
|
||||
if (!fs.existsSync(path.join(entryDir, "pack.json"))) return [];
|
||||
return fs
|
||||
.readdirSync(entryDir, { withFileTypes: true })
|
||||
.filter(
|
||||
(child) =>
|
||||
child.isDirectory() &&
|
||||
fs.existsSync(path.join(entryDir, child.name, "SKILL.md")),
|
||||
)
|
||||
.map((child) => path.join(entryDir, child.name));
|
||||
})
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const entries = fs
|
||||
.readdirSync(categoryDir, { withFileTypes: true })
|
||||
.filter((dirent) => dirent.isDirectory() && !dirent.name.startsWith("."))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const entry of entries) {
|
||||
const workflow = readWorkflow(category, entry);
|
||||
for (const workflowDir of workflowDirs) {
|
||||
const workflow = readWorkflow(collection.type, workflowDir);
|
||||
if (workflow.availability !== "system") continue;
|
||||
if (seenIds.has(workflow.id)) {
|
||||
fail(`Duplicate workflow id: ${workflow.id}`);
|
||||
}
|
||||
|
|
@ -483,8 +507,11 @@ function main() {
|
|||
if (!fs.existsSync(WORKFLOWS_DIR)) {
|
||||
fail(`Workflow source directory not found: ${relative(WORKFLOWS_DIR)}`);
|
||||
}
|
||||
if (!fs.existsSync(SYSTEM_WORKFLOWS_DIR)) {
|
||||
fail(`System workflow source directory not found: ${relative(SYSTEM_WORKFLOWS_DIR)}`);
|
||||
for (const collection of WORKFLOW_COLLECTIONS) {
|
||||
const collectionDir = path.join(WORKFLOWS_DIR, collection.directory);
|
||||
if (!fs.existsSync(collectionDir)) {
|
||||
fail(`Workflow collection not found: ${relative(collectionDir)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const workflows = loadWorkflows();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue