seqmachines commited on
Commit
5fd58dc
·
verified ·
1 Parent(s): f48fffa

Deploy Seqcolyte Studio

Browse files
studio/deploy/hf/demo-seed/10x-pbmc-3-adapter-dimer-qc-demo-d13261/project.json CHANGED
@@ -1,7 +1,7 @@
1
  {
2
  "schemaVersion": "studio.project.v1",
3
  "id": "10x-pbmc-3-adapter-dimer-qc-demo-d13261",
4
- "name": "10x Chromium 3\u2032 \u2014 adapter dimers",
5
  "assay": "10x Chromium Single Cell 3' Gene Expression",
6
  "specId": "10x_3p_v3",
7
  "createdAt": "2026-07-09T00:09:57.059Z",
@@ -24,5 +24,6 @@
24
  "phase": "complete",
25
  "demo": true,
26
  "specConfirmed": true,
27
- "demoBlurb": "A 10x short-read library with adapter dimers \u2014 QC flags the TSO-at-R2 and poly-G signatures and explains the fix."
 
28
  }
 
1
  {
2
  "schemaVersion": "studio.project.v1",
3
  "id": "10x-pbmc-3-adapter-dimer-qc-demo-d13261",
4
+ "name": "10x Chromium 3′ — adapter dimers",
5
  "assay": "10x Chromium Single Cell 3' Gene Expression",
6
  "specId": "10x_3p_v3",
7
  "createdAt": "2026-07-09T00:09:57.059Z",
 
24
  "phase": "complete",
25
  "demo": true,
26
  "specConfirmed": true,
27
+ "demoBlurb": "A 10x short-read library with adapter dimers — QC flags the TSO-at-R2 and poly-G signatures and explains the fix.",
28
+ "specMarkdownPath": "spec/spec.md"
29
  }
studio/deploy/hf/demo-seed/10x-pbmc-3-adapter-dimer-qc-demo-d13261/spec/spec.md ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 10x 3' scRNA-seq (v3)
2
+
3
+ **Assay:** 10x Chromium Single Cell 3' Gene Expression
4
+ **Platform:** illumina · **Chemistry:** v3/v3.1
5
+
6
+ Droplet-based single-cell 3' gene-expression profiling on Illumina. Cells are partitioned into gel-bead emulsions (GEMs); mRNA is barcoded during reverse transcription with a per-cell 16 nt barcode + 12 nt UMI, and the amplified cDNA is fragmented and built into a P5/P7-indexed short-read library. Profiles the transcriptomes of thousands of single cells per run.
7
+
8
+ ## Read structure
9
+
10
+ - **R1** (bottom strand, 28 cycles): `cell_barcode:16` + `umi:12`
11
+ - **I1** (bottom strand, 8 cycles): `sample_index_i7:8`
12
+ - **R2** (top strand, 90 cycles): `cdna_insert`
13
+
14
+ **R1 segments**
15
+
16
+ | segment | type | length |
17
+ | --- | --- | --- |
18
+ | cell_barcode | barcode | 16 |
19
+ | umi | umi | 12 |
20
+
21
+ **I1 segments**
22
+
23
+ | segment | type | length |
24
+ | --- | --- | --- |
25
+ | sample_index_i7 | index | 8 |
26
+
27
+ **R2 segments**
28
+
29
+ | segment | type | length |
30
+ | --- | --- | --- |
31
+ | cdna_insert | insert | |
32
+
33
+ ## Oligos & adapters
34
+
35
+ | id | role | sequence |
36
+ | --- | --- | --- |
37
+ | oligo_beads_oligo_dt | oligo | `CTACACGACGCTCTTCCGATCT[CELL_BARCODE:16][UMI:12]TTTTTTTTTTTTT…` |
38
+ | oligo_template_switching_oligo_tso | oligo | `AAGCAGTGGTATCAACGCAGAGTACATGGG` |
39
+ | oligo_cdna_forward_primer | primer | `CTACACGACGCTCTTCCGATCT` |
40
+ | oligo_cdna_reverse_primer | primer | `AAGCAGTGGTATCAACGCAGAG` |
41
+ | oligo_illumina_truseq_read_1_primer | primer | `ACACTCTTTCCCTACACGACGCTCTTCCGATCT` |
42
+ | oligo_illumina_truseq_read_2_primer | primer | `GTGACTGGAGTTCAGACGTGTGCTCTTCCGATCT` |
43
+ | oligo_truseq_adapter | adapter | |
44
+ | oligo_library_pcr_primer_1 | primer | `AATGATACGGCGACCACCGAGATCTACACTCTTTCCCTACACGACGCTC` |
45
+ | oligo_library_pcr_primer_2 | primer | `CAAGCAGAAGACGGCATACGAGAT[SAMPLE_INDEX:8]GTGACTGGAGTTCAGACGTG…` |
46
+ | oligo_sample_index_sequencing_primer | primer | `GATCGGAAGAGCACACGTCTGAACTCCAGTCAC` |
47
+ | oligo_illumina_p5_adapter | adapter | `AATGATACGGCGACCACCGAGATCTACAC` |
48
+ | oligo_illumina_p7_adapter | adapter | `CAAGCAGAAGACGGCATACGAGAT` |
49
+ | oligo_r2_readthrough_adapter | read_through_adapter | `AGATCGGAAGAGCACACGTCTGAACTCCAGTCAC` |
50
+ | oligo_r1_readinto_adapter | read_through_adapter | `AGATCGGAAGAGCGTCGTGTAGGGAAAGAGTGT` |
51
+ | oligo_p5_rc | adapter_rc | `GTGTAGATCTCGGTGGTCGCCGTATCATT` |
52
+
53
+ ## Library preparation
54
+
55
+ 1. **mRNA capture using Beads-oligo-dT in the droplets, and reverse transcription using MMLV** — Poly(dT) beads capture mRNA in droplets; MMLV reverse-transcribes it into barcoded cDNA.
56
+ 2. **The terminal tranferase acitivity of MMLV adds extra Cs** — MMLV's terminal-transferase activity adds a few untemplated C's to the cDNA 3' end.
57
+ 3. **Adding TSO for second strand synthesis** — The TSO base-pairs with the C overhang to prime second-strand synthesis.
58
+ 4. **Adding cDNA Forward and Reverse primers to amplify full length cDNA** — cDNA Forward/Reverse primers PCR-amplify the full-length cDNA.
59
+ 5. **Use Fragmentase to fragment cDNA and perform A-tailing** — Fragmentase shears the cDNA and A-tails the fragment ends.
60
+ 6. **Add double stranded Illumina Truseq adapter (with a T overhang) for ligation** — A T-overhang TruSeq adapter is ligated onto the A-tailed fragments.
61
+ 7. **Add Library PCR Primers 1 & 2 to amplify library** — Library PCR primers 1 & 2 add P5/P7 and the i7 sample index.
62
+ 8. **Final library structure** — The final P5/P7-indexed Illumina paired-end library.
63
+
64
+ ## Final library (5'→3')
65
+
66
+ ```
67
+ Illumina P5 | TruSeq Read 1 | cell barcode | UMI | poly(dT)VN | cDNA | TruSeq Read 2 | sample index (i7) | Illumina P7
68
+ ```
69
+
70
+ ---
71
+ *Generated by Seqcolyte from the extracted library spec. Review it in the workspace, then click **Confirm spec** to run QC on your reads.*
studio/deploy/hf/demo-seed/10x-pbmc-3-healthy-library-demo-9ec7eb/project.json CHANGED
@@ -1,7 +1,7 @@
1
  {
2
  "schemaVersion": "studio.project.v1",
3
  "id": "10x-pbmc-3-healthy-library-demo-9ec7eb",
4
- "name": "10x Chromium 3\u2032 \u2014 clean library",
5
  "assay": "10x Chromium Single Cell 3' Gene Expression",
6
  "specId": "10x_3p_v3",
7
  "createdAt": "2026-07-10T03:16:53.342Z",
@@ -24,5 +24,6 @@
24
  "20260710T031653Z-82a2fa"
25
  ],
26
  "demo": true,
27
- "demoBlurb": "A clean 10x PBMC v3 short-read library \u2014 every QC check lands in range. The baseline for comparison."
 
28
  }
 
1
  {
2
  "schemaVersion": "studio.project.v1",
3
  "id": "10x-pbmc-3-healthy-library-demo-9ec7eb",
4
+ "name": "10x Chromium 3′ — clean library",
5
  "assay": "10x Chromium Single Cell 3' Gene Expression",
6
  "specId": "10x_3p_v3",
7
  "createdAt": "2026-07-10T03:16:53.342Z",
 
24
  "20260710T031653Z-82a2fa"
25
  ],
26
  "demo": true,
27
+ "demoBlurb": "A clean 10x PBMC v3 short-read library — every QC check lands in range. The baseline for comparison.",
28
+ "specMarkdownPath": "spec/spec.md"
29
  }
studio/deploy/hf/demo-seed/10x-pbmc-3-healthy-library-demo-9ec7eb/spec/spec.md ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 10x 3' scRNA-seq (v3)
2
+
3
+ **Assay:** 10x Chromium Single Cell 3' Gene Expression
4
+ **Platform:** illumina · **Chemistry:** v3/v3.1
5
+
6
+ Droplet-based single-cell 3' gene-expression profiling on Illumina. Cells are partitioned into gel-bead emulsions (GEMs); mRNA is barcoded during reverse transcription with a per-cell 16 nt barcode + 12 nt UMI, and the amplified cDNA is fragmented and built into a P5/P7-indexed short-read library. Profiles the transcriptomes of thousands of single cells per run.
7
+
8
+ ## Read structure
9
+
10
+ - **R1** (bottom strand, 28 cycles): `cell_barcode:16` + `umi:12`
11
+ - **I1** (bottom strand, 8 cycles): `sample_index_i7:8`
12
+ - **R2** (top strand, 90 cycles): `cdna_insert`
13
+
14
+ **R1 segments**
15
+
16
+ | segment | type | length |
17
+ | --- | --- | --- |
18
+ | cell_barcode | barcode | 16 |
19
+ | umi | umi | 12 |
20
+
21
+ **I1 segments**
22
+
23
+ | segment | type | length |
24
+ | --- | --- | --- |
25
+ | sample_index_i7 | index | 8 |
26
+
27
+ **R2 segments**
28
+
29
+ | segment | type | length |
30
+ | --- | --- | --- |
31
+ | cdna_insert | insert | |
32
+
33
+ ## Oligos & adapters
34
+
35
+ | id | role | sequence |
36
+ | --- | --- | --- |
37
+ | oligo_beads_oligo_dt | oligo | `CTACACGACGCTCTTCCGATCT[CELL_BARCODE:16][UMI:12]TTTTTTTTTTTTT…` |
38
+ | oligo_template_switching_oligo_tso | oligo | `AAGCAGTGGTATCAACGCAGAGTACATGGG` |
39
+ | oligo_cdna_forward_primer | primer | `CTACACGACGCTCTTCCGATCT` |
40
+ | oligo_cdna_reverse_primer | primer | `AAGCAGTGGTATCAACGCAGAG` |
41
+ | oligo_illumina_truseq_read_1_primer | primer | `ACACTCTTTCCCTACACGACGCTCTTCCGATCT` |
42
+ | oligo_illumina_truseq_read_2_primer | primer | `GTGACTGGAGTTCAGACGTGTGCTCTTCCGATCT` |
43
+ | oligo_truseq_adapter | adapter | |
44
+ | oligo_library_pcr_primer_1 | primer | `AATGATACGGCGACCACCGAGATCTACACTCTTTCCCTACACGACGCTC` |
45
+ | oligo_library_pcr_primer_2 | primer | `CAAGCAGAAGACGGCATACGAGAT[SAMPLE_INDEX:8]GTGACTGGAGTTCAGACGTG…` |
46
+ | oligo_sample_index_sequencing_primer | primer | `GATCGGAAGAGCACACGTCTGAACTCCAGTCAC` |
47
+ | oligo_illumina_p5_adapter | adapter | `AATGATACGGCGACCACCGAGATCTACAC` |
48
+ | oligo_illumina_p7_adapter | adapter | `CAAGCAGAAGACGGCATACGAGAT` |
49
+ | oligo_r2_readthrough_adapter | read_through_adapter | `AGATCGGAAGAGCACACGTCTGAACTCCAGTCAC` |
50
+ | oligo_r1_readinto_adapter | read_through_adapter | `AGATCGGAAGAGCGTCGTGTAGGGAAAGAGTGT` |
51
+ | oligo_p5_rc | adapter_rc | `GTGTAGATCTCGGTGGTCGCCGTATCATT` |
52
+
53
+ ## Library preparation
54
+
55
+ 1. **mRNA capture using Beads-oligo-dT in the droplets, and reverse transcription using MMLV** — Poly(dT) beads capture mRNA in droplets; MMLV reverse-transcribes it into barcoded cDNA.
56
+ 2. **The terminal tranferase acitivity of MMLV adds extra Cs** — MMLV's terminal-transferase activity adds a few untemplated C's to the cDNA 3' end.
57
+ 3. **Adding TSO for second strand synthesis** — The TSO base-pairs with the C overhang to prime second-strand synthesis.
58
+ 4. **Adding cDNA Forward and Reverse primers to amplify full length cDNA** — cDNA Forward/Reverse primers PCR-amplify the full-length cDNA.
59
+ 5. **Use Fragmentase to fragment cDNA and perform A-tailing** — Fragmentase shears the cDNA and A-tails the fragment ends.
60
+ 6. **Add double stranded Illumina Truseq adapter (with a T overhang) for ligation** — A T-overhang TruSeq adapter is ligated onto the A-tailed fragments.
61
+ 7. **Add Library PCR Primers 1 & 2 to amplify library** — Library PCR primers 1 & 2 add P5/P7 and the i7 sample index.
62
+ 8. **Final library structure** — The final P5/P7-indexed Illumina paired-end library.
63
+
64
+ ## Final library (5'→3')
65
+
66
+ ```
67
+ Illumina P5 | TruSeq Read 1 | cell barcode | UMI | poly(dT)VN | cDNA | TruSeq Read 2 | sample index (i7) | Illumina P7
68
+ ```
69
+
70
+ ---
71
+ *Generated by Seqcolyte from the extracted library spec. Review it in the workspace, then click **Confirm spec** to run QC on your reads.*
studio/deploy/hf/demo-seed/sc-nanopore-3-tso-concatemer-demo/project.json CHANGED
@@ -1,7 +1,7 @@
1
  {
2
  "schemaVersion": "studio.project.v1",
3
  "id": "sc-nanopore-3-tso-concatemer-demo",
4
- "name": "sc-Nanopore 3\u2032 \u2014 TSO concatemers",
5
  "assay": "10x Chromium Single Cell 3' Gene Expression (Nanopore long-read)",
6
  "specId": "nanopore_10x_3p_direct_ligation",
7
  "createdAt": "2026-07-10T16:13:33.075625Z",
@@ -25,5 +25,6 @@
25
  "runIds": [
26
  "20260710T161333Z-28dc0a"
27
  ],
28
- "demoBlurb": "A long-read sc-Nanopore library where 25.7% of reads are TSO concatemers \u2014 QC finds them and recommends read-splitting / biotin enrichment."
 
29
  }
 
1
  {
2
  "schemaVersion": "studio.project.v1",
3
  "id": "sc-nanopore-3-tso-concatemer-demo",
4
+ "name": "sc-Nanopore 3′ — TSO concatemers",
5
  "assay": "10x Chromium Single Cell 3' Gene Expression (Nanopore long-read)",
6
  "specId": "nanopore_10x_3p_direct_ligation",
7
  "createdAt": "2026-07-10T16:13:33.075625Z",
 
25
  "runIds": [
26
  "20260710T161333Z-28dc0a"
27
  ],
28
+ "demoBlurb": "A long-read sc-Nanopore library where 25.7% of reads are TSO concatemers — QC finds them and recommends read-splitting / biotin enrichment.",
29
+ "specMarkdownPath": "spec/spec.md"
30
  }
studio/deploy/hf/demo-seed/sc-nanopore-3-tso-concatemer-demo/spec/spec.md ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # sc-Nanopore 10x 3' cDNA (direct ligation)
2
+
3
+ **Assay:** 10x Chromium Single Cell 3' Gene Expression amplified cDNA — baseline Oxford Nanopore direct-ligation simulation
4
+ **Platform:** nanopore · **Chemistry:** v3.1/v4
5
+
6
+ Full-length single-cell 3' cDNA sequenced on Oxford Nanopore. Amplified 10x cDNA (16 nt cell barcode + 12 nt UMI) is taken through ONT end-prep and ligation-adapter attachment and read end-to-end as one long read per molecule. This is a baseline direct-ligation simulation model; it omits the biotin-enrichment / streptavidin pull-down steps of the optimized ONT workflow.
7
+
8
+ ## Read structure
9
+
10
+ - **L1** (normalized adapter1-first strand strand): `r1_handle:22` + `cell_barcode:16` + `umi:12` + `polyt` + `vn_anchor:2` + `cdna_insert` + `tso_rc:30`
11
+
12
+ **L1 segments**
13
+
14
+ | segment | type | length |
15
+ | --- | --- | --- |
16
+ | r1_handle | constant | 22 |
17
+ | cell_barcode | barcode | 16 |
18
+ | umi | umi | 12 |
19
+ | polyt | homopolymer | |
20
+ | vn_anchor | anchor | 2 |
21
+ | cdna_insert | insert | |
22
+ | tso_rc | constant | 30 |
23
+
24
+ ## Oligos & adapters
25
+
26
+ | id | role | sequence |
27
+ | --- | --- | --- |
28
+ | oligo_beads_oligo_dt | Gel-bead-tethered RT primer: capture of poly(A) mRNA and addition of the 10x Barcode, UMI and partial TruSeq Read 1 handle | `CTACACGACGCTCTTCCGATCT[CELL_BARCODE:16][UMI:12]TTTTTTTTTTTTT…` |
29
+ | oligo_template_switching_oligo_tso | Template switching at the cDNA 3' end; adds a constant handle used as the cDNA Reverse primer binding site | `AAGCAGTGGTATCAACGCAGAGTACATGGG` |
30
+ | oligo_cdna_forward_primer | Bulk cDNA amplification (PCR), anneals to the partial TruSeq Read 1 handle | `CTACACGACGCTCTTCCGATCT` |
31
+ | oligo_cdna_reverse_primer | Bulk cDNA amplification (PCR), anneals to the TSO-derived handle | `AAGCAGTGGTATCAACGCAGAG` |
32
+
33
+ ## Library preparation
34
+
35
+ 1. **mRNA capture on the gel-bead poly(dT) primer (GEM formation)** — mRNA is captured on the gel-bead poly(dT) primer as GEMs form.
36
+ 2. **Reverse transcription (GEM-RT, 53 C 45 min)** — GEM-RT makes barcoded first-strand cDNA.
37
+ 3. **Template Switch Oligo annealing** — The TSO anneals to the cDNA 3' C-overhang.
38
+ 4. **Template switching & transcript extension** — Template switching extends the transcript with the TSO handle into full-length cDNA.
39
+ 5. **cDNA amplification (bulk PCR with cDNA Primers, 11-13 cycles)** — Bulk PCR amplifies the full-length cDNA to build mass.
40
+ 6. **End repair & dA-tailing (ONT end-prep, NEBNext Ultra II)** — End repair + 3' dA-tailing prepares the amplicon for ligation (no fragmentation).
41
+ 7. **ONT ligation-adapter attachment (SQK-LSK114)** — ONT sequencing adapters (SQK-LSK114) are ligated directly to the full-length cDNA.
42
+ 8. **Nanopore sequencing — single-strand read, either orientation** — Each molecule is read end-to-end on the flow cell, in either orientation.
43
+
44
+ ## Final library (5'→3')
45
+
46
+ ```
47
+ CTACACGACGCTCTTCCGATCT = R1 handle (partial TruSeq Read 1; 10x bead oligo)
48
+ [CELL_BARCODE:16] = 16 bp 10x cell barcode
49
+ [UMI:12] = 12 bp UMI
50
+ TTTTTTTTTTTTTTTTTTTTTTTTTTTTTT = poly(dT) 30 nt
51
+ VN = poly(dT)VN anchor (V=A/C/G, N=any)
52
+ [CDNA] = full-length cDNA insert
53
+ CCCATGTACTCTGCGTTGATACCACTGCTT = reverse complement of the TSO (DERIVED = reverse_complement(TSO)); 3' end
54
+ ```
55
+
56
+ ---
57
+ *Generated by Seqcolyte from the extracted library spec. Review it in the workspace, then click **Confirm spec** to run QC on your reads.*
studio/src/app/api/projects/[id]/generate-spec/route.ts ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { promises as fs } from "node:fs";
2
+ import { NextResponse } from "next/server";
3
+ import { getProject, updateProject } from "@/lib/store";
4
+ import { inProject } from "@/lib/paths";
5
+ import { readConversation } from "@/lib/chat";
6
+ import { startExtract } from "@/lib/extractRunner";
7
+
8
+ export const runtime = "nodejs";
9
+ export const dynamic = "force-dynamic";
10
+
11
+ /**
12
+ * Build a spec from the user's free-text library description (typed in chat)
13
+ * instead of an uploaded protocol file. Saves the description as the project's
14
+ * lab notes and runs the same extractor `startExtract` uses, so the result flows
15
+ * into the normal review → Confirm-spec → run-QC path. The chat assistant is
16
+ * read-only and cannot do this itself — this is the workspace "Generate spec"
17
+ * button's endpoint.
18
+ */
19
+ export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) {
20
+ const { id } = await ctx.params;
21
+ let project;
22
+ try {
23
+ project = await getProject(id);
24
+ } catch {
25
+ return NextResponse.json({ error: "project not found" }, { status: 404 });
26
+ }
27
+
28
+ // The description is everything the user typed in chat (minus upload notices).
29
+ const conv = await readConversation(id);
30
+ const description = conv
31
+ .filter((e) => e.role === "user" && !e.text.startsWith("📎"))
32
+ .map((e) => e.text.trim())
33
+ .filter(Boolean)
34
+ .join("\n\n")
35
+ .trim();
36
+
37
+ if (description.length < 20) {
38
+ return NextResponse.json(
39
+ {
40
+ error:
41
+ "Describe your library structure in the chat first — read layout (R1/R2), UMI/barcode positions and lengths, and any adapters — then click Generate spec.",
42
+ },
43
+ { status: 400 },
44
+ );
45
+ }
46
+
47
+ // Persist as the project's lab notes (shown under Files) and extract from it.
48
+ const rel = project.inputs.notesPath || "inputs/notes.md";
49
+ await fs.mkdir(inProject(id, "inputs"), { recursive: true });
50
+ await fs.writeFile(inProject(id, rel), `${description}\n`, "utf8");
51
+ await updateProject(id, { inputs: { ...project.inputs, notesPath: rel } });
52
+
53
+ await startExtract(id, rel);
54
+ return NextResponse.json({ ok: true });
55
+ }
studio/src/components/chat/chat.tsx CHANGED
@@ -167,6 +167,8 @@ export function Chat({
167
  const [input, setInput] = useState("");
168
  const [uploading, setUploading] = useState(false);
169
  const [upload, setUpload] = useState<{ name: string; loaded: number; total: number } | null>(null);
 
 
170
  const [extract, setExtract] = useState<{ status: string; log: string; doc: string } | null>(null);
171
  const scrollRef = useRef<HTMLDivElement>(null);
172
  const fileRef = useRef<HTMLInputElement>(null);
@@ -242,10 +244,7 @@ export function Chat({
242
  };
243
  }
244
 
245
- async function onPickFile(e: React.ChangeEvent<HTMLInputElement>) {
246
- const file = e.target.files?.[0];
247
- e.target.value = "";
248
- if (!file) return;
249
  setUploading(true);
250
  setUpload({ name: file.name, loaded: 0, total: file.size });
251
  try {
@@ -268,8 +267,65 @@ export function Chat({
268
  }
269
  }
270
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  return (
272
- <div className="flex h-full min-h-0 flex-col overflow-hidden">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  <header className="border-border/60 flex shrink-0 items-center gap-2 border-b px-3 py-2.5">
274
  <button
275
  onClick={onToggleRail}
@@ -349,6 +405,7 @@ export function Chat({
349
  <input
350
  ref={fileRef}
351
  type="file"
 
352
  accept={UPLOAD_ACCEPT}
353
  className="hidden"
354
  onChange={onPickFile}
 
167
  const [input, setInput] = useState("");
168
  const [uploading, setUploading] = useState(false);
169
  const [upload, setUpload] = useState<{ name: string; loaded: number; total: number } | null>(null);
170
+ const [dragActive, setDragActive] = useState(false);
171
+ const dragDepth = useRef(0);
172
  const [extract, setExtract] = useState<{ status: string; log: string; doc: string } | null>(null);
173
  const scrollRef = useRef<HTMLDivElement>(null);
174
  const fileRef = useRef<HTMLInputElement>(null);
 
244
  };
245
  }
246
 
247
+ async function uploadFile(file: File) {
 
 
 
248
  setUploading(true);
249
  setUpload({ name: file.name, loaded: 0, total: file.size });
250
  try {
 
267
  }
268
  }
269
 
270
+ /** Upload one or more files sequentially (keeps R1/R2 slotting deterministic). */
271
+ async function uploadFiles(files: File[]) {
272
+ for (const f of files) await uploadFile(f);
273
+ }
274
+
275
+ async function onPickFile(e: React.ChangeEvent<HTMLInputElement>) {
276
+ const files = Array.from(e.target.files ?? []);
277
+ e.target.value = "";
278
+ if (files.length) await uploadFiles(files);
279
+ }
280
+
281
+ // --- drag-and-drop upload onto the whole chat surface ---
282
+ function hasFiles(e: React.DragEvent): boolean {
283
+ return Array.from(e.dataTransfer?.types ?? []).includes("Files");
284
+ }
285
+ function onDragEnter(e: React.DragEvent) {
286
+ if (!hasFiles(e)) return;
287
+ e.preventDefault();
288
+ dragDepth.current += 1;
289
+ setDragActive(true);
290
+ }
291
+ function onDragOver(e: React.DragEvent) {
292
+ if (hasFiles(e)) e.preventDefault(); // required for the drop event to fire
293
+ }
294
+ function onDragLeave(e: React.DragEvent) {
295
+ if (!hasFiles(e)) return;
296
+ dragDepth.current -= 1;
297
+ if (dragDepth.current <= 0) {
298
+ dragDepth.current = 0;
299
+ setDragActive(false);
300
+ }
301
+ }
302
+ async function onDrop(e: React.DragEvent) {
303
+ if (!hasFiles(e)) return;
304
+ e.preventDefault();
305
+ dragDepth.current = 0;
306
+ setDragActive(false);
307
+ if (uploading) return;
308
+ const files = Array.from(e.dataTransfer.files ?? []);
309
+ if (files.length) await uploadFiles(files);
310
+ }
311
+
312
  return (
313
+ <div
314
+ className="relative flex h-full min-h-0 flex-col overflow-hidden"
315
+ onDragEnter={onDragEnter}
316
+ onDragOver={onDragOver}
317
+ onDragLeave={onDragLeave}
318
+ onDrop={onDrop}
319
+ >
320
+ {dragActive && (
321
+ <div className="border-primary/50 bg-background/80 pointer-events-none absolute inset-2 z-20 flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed backdrop-blur-sm">
322
+ <Paperclip className="text-primary size-6" />
323
+ <div className="text-sm font-medium">Drop to upload</div>
324
+ <div className="text-muted-foreground text-xs">
325
+ protocol (PDF / txt / md), FASTQ reads, or design tables
326
+ </div>
327
+ </div>
328
+ )}
329
  <header className="border-border/60 flex shrink-0 items-center gap-2 border-b px-3 py-2.5">
330
  <button
331
  onClick={onToggleRail}
 
405
  <input
406
  ref={fileRef}
407
  type="file"
408
+ multiple
409
  accept={UPLOAD_ACCEPT}
410
  className="hidden"
411
  onChange={onPickFile}
studio/src/components/files/files-panel.tsx CHANGED
@@ -112,6 +112,16 @@ export function FilesPanel({
112
  downloadHref: `${fileHref(project.activeSpecPath)}?download`,
113
  });
114
  }
 
 
 
 
 
 
 
 
 
 
115
  for (const run of runs) {
116
  if (run.steps.qc?.status === "succeeded" || run.overall != null) {
117
  rows.push({
 
112
  downloadHref: `${fileHref(project.activeSpecPath)}?download`,
113
  });
114
  }
115
+ if (project.specMarkdownPath) {
116
+ const rel = project.specMarkdownPath;
117
+ rows.push({
118
+ icon: FileText,
119
+ label: "Spec (markdown)",
120
+ sub: "readable library summary",
121
+ open: { kind: "markdown", path: rel, title: "Spec" },
122
+ downloadHref: `${fileHref(rel)}?download`,
123
+ });
124
+ }
125
  for (const run of runs) {
126
  if (run.steps.qc?.status === "succeeded" || run.overall != null) {
127
  rows.push({
studio/src/components/workspace.tsx CHANGED
@@ -2,7 +2,7 @@
2
 
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
  import { useRouter } from "next/navigation";
5
- import { Check, Loader2 } from "lucide-react";
6
  import { toast } from "sonner";
7
  import type { ImperativePanelHandle } from "react-resizable-panels";
8
  import type { ProjectManifest, RunRecord, StepStatus } from "@/lib/types";
@@ -35,6 +35,7 @@ export function Workspace({
35
  const [openFile, setOpenFile] = useState<OpenFile | null>(null);
36
  const [traceRunId, setTraceRunId] = useState<string | null>(initialRuns[0]?.id ?? null);
37
  const [confirming, setConfirming] = useState(false);
 
38
 
39
  const railRef = useRef<ImperativePanelHandle>(null);
40
  const viewerRef = useRef<ImperativePanelHandle>(null);
@@ -98,6 +99,52 @@ export function Workspace({
98
  }
99
  }, [project.id, router]);
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  const onRunDone = useCallback(
102
  async (status: StepStatus, live: boolean) => {
103
  await refreshRuns();
@@ -150,12 +197,31 @@ export function Workspace({
150
  </button>
151
  ) : null;
152
 
 
 
153
  return (
154
- <ResizablePanelGroup
155
- direction="horizontal"
156
- autoSaveId="project-workspace"
157
- className={cn("min-h-0", className)}
158
- >
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  <ResizablePanel
160
  id="rail"
161
  order={1}
@@ -214,6 +280,7 @@ export function Workspace({
214
  onRunDone={onRunDone}
215
  />
216
  </ResizablePanel>
217
- </ResizablePanelGroup>
 
218
  );
219
  }
 
2
 
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
  import { useRouter } from "next/navigation";
5
+ import { Check, Loader2, Sparkles } from "lucide-react";
6
  import { toast } from "sonner";
7
  import type { ImperativePanelHandle } from "react-resizable-panels";
8
  import type { ProjectManifest, RunRecord, StepStatus } from "@/lib/types";
 
35
  const [openFile, setOpenFile] = useState<OpenFile | null>(null);
36
  const [traceRunId, setTraceRunId] = useState<string | null>(initialRuns[0]?.id ?? null);
37
  const [confirming, setConfirming] = useState(false);
38
+ const [generating, setGenerating] = useState(false);
39
 
40
  const railRef = useRef<ImperativePanelHandle>(null);
41
  const viewerRef = useRef<ImperativePanelHandle>(null);
 
99
  }
100
  }, [project.id, router]);
101
 
102
+ // Build a spec from the user's chat description (no uploaded protocol needed),
103
+ // then poll until extraction resolves and reveal the spec for review.
104
+ const onGenerateSpec = useCallback(async () => {
105
+ setGenerating(true);
106
+ try {
107
+ const res = await fetch(`/api/projects/${project.id}/generate-spec`, { method: "POST" });
108
+ const data = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string };
109
+ if (!res.ok) {
110
+ toast.error(data.error ?? "Could not generate a spec");
111
+ setGenerating(false);
112
+ return;
113
+ }
114
+ const deadline = Date.now() + 3 * 60 * 1000;
115
+ const tick = async () => {
116
+ try {
117
+ const p = (await (
118
+ await fetch(`/api/projects/${project.id}`, { cache: "no-store" })
119
+ ).json()) as ProjectManifest;
120
+ if (p.phase === "awaiting_spec_review") {
121
+ setGenerating(false);
122
+ router.refresh();
123
+ openSpec();
124
+ return;
125
+ }
126
+ if (p.phase === "awaiting_inputs") {
127
+ setGenerating(false);
128
+ toast.error("Spec generation failed — see the chat for details");
129
+ router.refresh();
130
+ return;
131
+ }
132
+ } catch {
133
+ /* transient — keep polling */
134
+ }
135
+ if (Date.now() < deadline) setTimeout(tick, 1500);
136
+ else {
137
+ setGenerating(false);
138
+ toast.error("Spec generation timed out");
139
+ }
140
+ };
141
+ setTimeout(tick, 1500);
142
+ } catch {
143
+ setGenerating(false);
144
+ toast.error("Could not generate a spec");
145
+ }
146
+ }, [project.id, router, openSpec]);
147
+
148
  const onRunDone = useCallback(
149
  async (status: StepStatus, live: boolean) => {
150
  await refreshRuns();
 
197
  </button>
198
  ) : null;
199
 
200
+ const needsSpec = (project.phase ?? "awaiting_inputs") === "awaiting_inputs" && !project.activeSpecPath;
201
+
202
  return (
203
+ <div className={cn("flex min-h-0 flex-col", className)}>
204
+ {needsSpec && (
205
+ <div className="border-border/60 bg-muted/30 flex shrink-0 items-center gap-3 border-b px-4 py-2">
206
+ <Sparkles className="text-muted-foreground size-3.5 shrink-0" />
207
+ <span className="text-muted-foreground text-xs">
208
+ Described your library in the chat? Turn it into a reviewable spec, then confirm to run QC.
209
+ </span>
210
+ <button
211
+ onClick={onGenerateSpec}
212
+ disabled={generating}
213
+ className="border-primary/40 bg-primary/10 text-primary hover:bg-primary/20 ml-auto inline-flex shrink-0 items-center gap-1 rounded-md border px-2.5 py-1 text-xs font-medium disabled:opacity-50"
214
+ >
215
+ {generating ? <Loader2 className="size-3 animate-spin" /> : <Sparkles className="size-3" />}
216
+ {generating ? "Generating spec…" : "Generate spec"}
217
+ </button>
218
+ </div>
219
+ )}
220
+ <ResizablePanelGroup
221
+ direction="horizontal"
222
+ autoSaveId="project-workspace"
223
+ className="min-h-0 flex-1"
224
+ >
225
  <ResizablePanel
226
  id="rail"
227
  order={1}
 
280
  onRunDone={onRunDone}
281
  />
282
  </ResizablePanel>
283
+ </ResizablePanelGroup>
284
+ </div>
285
  );
286
  }
studio/src/lib/chat.ts CHANGED
@@ -188,6 +188,7 @@ export function buildGatingPreamble(project: ProjectManifest): string {
188
  hasOligoInfo ? (hasSpec ? "extracted into the spec" : "present in inputs") : "MISSING"
189
  }`,
190
  `- Reads: ${hasReads ? reads : "not chosen (a labeled demo dataset is available)"}`,
 
191
  ];
192
 
193
  switch (phase) {
@@ -196,8 +197,8 @@ export function buildGatingPreamble(project: ProjectManifest): string {
196
  missing.length
197
  ? `GATE: Inputs are incomplete. Ask the user specifically for the MISSING item(s): ${missing.join(
198
  "; ",
199
- )}. They can attach files with the 📎 button or describe the assay in chat. Do NOT start any analysis yet.`
200
- : "GATE: Inputs look complete — offer to extract the spec / proceed.",
201
  );
202
  break;
203
  case "extracting":
 
188
  hasOligoInfo ? (hasSpec ? "extracted into the spec" : "present in inputs") : "MISSING"
189
  }`,
190
  `- Reads: ${hasReads ? reads : "not chosen (a labeled demo dataset is available)"}`,
191
+ "CAPABILITY: You are a READ-ONLY assistant. You CANNOT run QC, extract, or confirm/commit a spec yourself — each of those is a button the user clicks in the workspace. NEVER claim you have run QC, 'confirmed'/'locked' a spec, or produced results, and never invent numbers. When the user asks you to run or confirm, name the exact button to click.",
192
  ];
193
 
194
  switch (phase) {
 
197
  missing.length
198
  ? `GATE: Inputs are incomplete. Ask the user specifically for the MISSING item(s): ${missing.join(
199
  "; ",
200
+ )}. They can attach files with the 📎 button, OR describe the library structure in chat and then click the "Generate spec" button at the top of the workspace to turn that description into a reviewable spec — describing it in chat alone does NOT create a spec. Do NOT claim to have built or run anything.`
201
+ : 'GATE: Inputs look complete. Point the user to the "Generate spec" button (or the 📎 protocol upload) to build the spec — you cannot build it yourself.',
202
  );
203
  break;
204
  case "extracting":
studio/src/lib/extractRunner.ts CHANGED
@@ -186,6 +186,92 @@ function summarizeSpec(spec: SpecDoc): string {
186
  return lines.join("\n");
187
  }
188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  export async function startExtract(projectId: string, docRel: string): Promise<void> {
190
  if (live.has(projectId)) return;
191
  live.add(projectId);
@@ -286,6 +372,14 @@ async function driveExtract(
286
  phase: "awaiting_spec_review",
287
  specConfirmed: false,
288
  };
 
 
 
 
 
 
 
 
289
  if (spec.assay) patch.assay = spec.assay;
290
  // Adopt the identified id only when it's a known structure; a custom library
291
  // keeps the neutral "new" tag rather than inventing a fake catalog id.
 
186
  return lines.join("\n");
187
  }
188
 
189
+ /** Human-readable markdown of the extracted spec — saved beside spec.json and shown under Files. */
190
+ function renderSpecMarkdown(spec: SpecDoc): string {
191
+ const L: string[] = [];
192
+ const push = (s = "") => L.push(s);
193
+ const cell = (s: unknown) => String(s ?? "").replace(/\|/g, "\\|").replace(/\s*\n\s*/g, " ").trim();
194
+
195
+ const title = spec.title || spec.assay || "Sequencing library spec";
196
+ push(`# ${title}`);
197
+ push();
198
+ if (spec.assay && spec.assay !== title) push(`**Assay:** ${spec.assay}`);
199
+ const meta: string[] = [];
200
+ if (spec.platform) meta.push(`**Platform:** ${spec.platform}`);
201
+ if (spec.chemistry_version) meta.push(`**Chemistry:** ${spec.chemistry_version}`);
202
+ if (spec.modality) meta.push(`**Modality:** ${spec.modality}`);
203
+ if (spec.method_type) meta.push(`**Method:** ${spec.method_type}`);
204
+ if (meta.length) push(meta.join(" · "));
205
+ push();
206
+ if (spec.description) {
207
+ push(spec.description);
208
+ push();
209
+ }
210
+
211
+ const reads = spec.read_structure?.reads ?? [];
212
+ if (reads.length) {
213
+ push("## Read structure");
214
+ push();
215
+ for (const r of reads) {
216
+ const segs = (r.segments ?? [])
217
+ .map((s) => `\`${s.name}${s.length ? `:${s.length}` : ""}\``)
218
+ .join(" + ");
219
+ const tail = [r.template ? `${r.template} strand` : "", r.cycles ? `${r.cycles} cycles` : ""]
220
+ .filter(Boolean)
221
+ .join(", ");
222
+ push(`- **${r.read}**${tail ? ` (${tail})` : ""}${segs ? `: ${segs}` : ""}`);
223
+ }
224
+ push();
225
+ for (const r of reads) {
226
+ if (!r.segments?.length) continue;
227
+ push(`**${r.read} segments**`);
228
+ push();
229
+ push("| segment | type | length |");
230
+ push("| --- | --- | --- |");
231
+ for (const s of r.segments) push(`| ${cell(s.name)} | ${cell(s.type)} | ${cell(s.length)} |`);
232
+ push();
233
+ }
234
+ }
235
+
236
+ if (spec.oligos?.length) {
237
+ push("## Oligos & adapters");
238
+ push();
239
+ push("| id | role | sequence |");
240
+ push("| --- | --- | --- |");
241
+ for (const o of spec.oligos) {
242
+ const seq = o.sequence
243
+ ? `\`${o.sequence.length > 60 ? o.sequence.slice(0, 60) + "…" : o.sequence}\``
244
+ : "";
245
+ push(`| ${cell(o.oligo_id)} | ${cell(o.role ?? o.kind)} | ${seq} |`);
246
+ }
247
+ push();
248
+ }
249
+
250
+ if (spec.library_generation?.length) {
251
+ push("## Library preparation");
252
+ push();
253
+ for (const st of spec.library_generation) {
254
+ push(`${st.step}. **${st.title}**${st.summary ? ` — ${st.summary}` : ""}`);
255
+ }
256
+ push();
257
+ }
258
+
259
+ if (spec.final_library?.annotation_lines?.length) {
260
+ push("## Final library (5'→3')");
261
+ push();
262
+ push("```");
263
+ for (const line of spec.final_library.annotation_lines) push(line);
264
+ push("```");
265
+ push();
266
+ }
267
+
268
+ push("---");
269
+ push(
270
+ "*Generated by Seqcolyte from the extracted library spec. Review it in the workspace, then click **Confirm spec** to run QC on your reads.*",
271
+ );
272
+ return L.join("\n");
273
+ }
274
+
275
  export async function startExtract(projectId: string, docRel: string): Promise<void> {
276
  if (live.has(projectId)) return;
277
  live.add(projectId);
 
372
  phase: "awaiting_spec_review",
373
  specConfirmed: false,
374
  };
375
+ // Save a readable markdown of the spec beside the JSON, surfaced under Files.
376
+ try {
377
+ const mdRel = "spec/spec.md";
378
+ await fs.writeFile(inProject(projectId, mdRel), renderSpecMarkdown(spec), "utf8");
379
+ patch.specMarkdownPath = mdRel;
380
+ } catch {
381
+ /* markdown is a convenience artifact — never fail extraction over it */
382
+ }
383
  if (spec.assay) patch.assay = spec.assay;
384
  // Adopt the identified id only when it's a known structure; a custom library
385
  // keeps the neutral "new" tag rather than inventing a fake catalog id.
studio/src/lib/types.ts CHANGED
@@ -88,6 +88,8 @@ export interface ProjectManifest {
88
  updatedAt: string;
89
  /** relative-to-project path of the active spec (extract output or reference copy) */
90
  activeSpecPath: string | null;
 
 
91
  /** Current lifecycle phase (defaults to awaiting_inputs when absent). */
92
  phase?: ProjectPhase;
93
  /** The user reviewed and confirmed the extracted spec. */
 
88
  updatedAt: string;
89
  /** relative-to-project path of the active spec (extract output or reference copy) */
90
  activeSpecPath: string | null;
91
+ /** relative-to-project path of the human-readable spec markdown (written beside the spec JSON) */
92
+ specMarkdownPath?: string | null;
93
  /** Current lifecycle phase (defaults to awaiting_inputs when absent). */
94
  phase?: ProjectPhase;
95
  /** The user reviewed and confirmed the extracted spec. */
studio/tsconfig.tsbuildinfo CHANGED
The diff for this file is too large to render. See raw diff