feat: analysis pipeline with staged SSE
Adds @openuidev/lang-core (one-line reason: the prompt-side library must build in a non-React Node runtime; react-lang's createContext crashes the route's server bundle). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
13bd2e6bfd
commit
ac4cbdd9e6
+24
-3
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod/v4";
|
||||
import { digestForModel, type Digest } from "./digest.ts";
|
||||
import { openAiTextDeltas } from "./stream.ts";
|
||||
import type { AnalystConfig } from "./settings.ts";
|
||||
|
||||
export class AnalystError extends Error {}
|
||||
@@ -36,11 +37,11 @@ export function extractJson(text: string): unknown {
|
||||
}
|
||||
|
||||
export async function proposeCandidates(cfg: AnalystConfig, digest: Digest, note: string,
|
||||
fetchImpl: typeof fetch = fetch): Promise<Candidate[]> {
|
||||
fetchImpl: typeof fetch = fetch, signal?: AbortSignal): Promise<Candidate[]> {
|
||||
const res = await fetchImpl(`${cfg.baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
method: "POST", signal,
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}` },
|
||||
body: JSON.stringify({ model: cfg.model, temperature: 0.6, ...cfg.extra, stream: false,
|
||||
body: JSON.stringify({ model: cfg.model, temperature: 0.6, max_tokens: 2048, ...cfg.extra, stream: false,
|
||||
messages: [{ role: "system", content: CANDIDATE_SYSTEM_PROMPT },
|
||||
{ role: "user", content: JSON.stringify({ digest: digestForModel(digest), user_note: note || null }) }] }),
|
||||
}).catch((e) => { throw new AnalystError(`Could not reach ${cfg.baseUrl}: ${(e as Error).message}`); });
|
||||
@@ -56,3 +57,23 @@ export async function proposeCandidates(cfg: AnalystConfig, digest: Digest, note
|
||||
}
|
||||
return parsed.data.candidates;
|
||||
}
|
||||
|
||||
export const REPORT_SYSTEM_PROMPT = (openuiPrompt: string) => `${openuiPrompt}
|
||||
|
||||
You are writing the final answer for a DBA holding one SQL Server execution plan. The ranking you are given was
|
||||
computed by Jev from typed judgments. It is not your opinion: do not re-rank it, soften it, or hide it. Quote each
|
||||
option's scores as given. A rewrite must return the same rows as the original — when in doubt keep the original text
|
||||
and say why. Never invent a number that is not in the input.`;
|
||||
|
||||
export async function* streamReport(cfg: AnalystConfig, payload: unknown, openuiPrompt: string,
|
||||
fetchImpl: typeof fetch = fetch, signal?: AbortSignal): AsyncGenerator<string> {
|
||||
const res = await fetchImpl(`${cfg.baseUrl}/chat/completions`, {
|
||||
method: "POST", signal,
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}` },
|
||||
body: JSON.stringify({ model: cfg.model, temperature: 0.3, max_tokens: 2048, ...cfg.extra, stream: true,
|
||||
messages: [{ role: "system", content: REPORT_SYSTEM_PROMPT(openuiPrompt) },
|
||||
{ role: "user", content: JSON.stringify(payload) }] }),
|
||||
}).catch((e) => { throw new AnalystError(`Could not reach ${cfg.baseUrl}: ${(e as Error).message}`); });
|
||||
if (!res.ok || !res.body) throw new AnalystError(`Analyst model returned HTTP ${res.status} while writing the report.`);
|
||||
yield* openAiTextDeltas(res);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { defineComponent, createLibrary } from "@openuidev/lang-core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
// The single source of truth for what the analyst model is allowed to emit.
|
||||
// This module must never import React or @openuidev/react-lang: the API route runs it in a plain
|
||||
// Node runtime to build the prompt, and React's server build throws on createContext there.
|
||||
// lib/report-ui.ts attaches the React renderers to these same specs, so prompt and renderer cannot drift.
|
||||
|
||||
const noop = () => null;
|
||||
|
||||
export const Headline = defineComponent({ props: z.object({ title: z.string(), subtitle: z.string().optional(),
|
||||
severity: z.enum(["critical", "warning", "info"]).optional() }),
|
||||
name: "Headline", description: "Report title: the one-line finding, with severity.", component: noop });
|
||||
|
||||
export const Metric = defineComponent({ props: z.object({ label: z.string(), value: z.string(), note: z.string().optional() }),
|
||||
name: "Metric", description: "One plan metric.", component: noop });
|
||||
|
||||
export const MetricGrid = defineComponent({ props: z.object({ items: z.array(Metric.ref) }),
|
||||
name: "MetricGrid", description: "Row of plan metrics.", component: noop });
|
||||
|
||||
export const Finding = defineComponent({ props: z.object({ where: z.string(), issue: z.string(), evidence: z.string(), impact: z.string() }),
|
||||
name: "Finding", description: "One problem, the plan evidence for it, its impact.", component: noop });
|
||||
|
||||
export const Findings = defineComponent({ props: z.object({ items: z.array(Finding.ref) }),
|
||||
name: "Findings", description: "Everything wrong with the plan.", component: noop });
|
||||
|
||||
export const Step = defineComponent({ props: z.object({ text: z.string(), effort: z.enum(["low", "medium", "high"]).optional(),
|
||||
owner: z.enum(["dba", "developer", "both"]).optional(), changeControl: z.boolean().optional() }),
|
||||
name: "Step", description: "One action step.", component: noop });
|
||||
|
||||
export const StepList = defineComponent({ props: z.object({ title: z.string(), steps: z.array(Step.ref) }),
|
||||
name: "StepList", description: "Ordered action plan for one option.", component: noop });
|
||||
|
||||
export const SqlBlock = defineComponent({ props: z.object({ title: z.string(), sql: z.string(), caption: z.string().optional() }),
|
||||
name: "SqlBlock", description: "A SQL snippet the user can copy.", component: noop });
|
||||
|
||||
export const ScoreBar = defineComponent({ props: z.object({ label: z.string(), value: z.number(),
|
||||
confidence: z.number().optional(), note: z.string().optional() }),
|
||||
name: "ScoreBar", description: "One Jev dimension: normalised value plus confidence.", component: noop });
|
||||
|
||||
export const OptionRow = defineComponent({ props: z.object({ rank: z.number(), title: z.string(), composite: z.number(),
|
||||
headline: z.boolean(), flags: z.string().optional() }),
|
||||
name: "OptionRow", description: "One ranked option.", component: noop });
|
||||
|
||||
export const OptionTable = defineComponent({ props: z.object({ title: z.string(), weights: z.string(), rows: z.array(OptionRow.ref) }),
|
||||
name: "OptionTable", description: "All options ranked by composite score.", component: noop });
|
||||
|
||||
export const VerdictBlock = defineComponent({ props: z.object({ headline: z.string(), why: z.string(),
|
||||
source: z.enum(["jev", "composite"]), confidence: z.number() }),
|
||||
name: "VerdictBlock", description: "What Jev decided, and how sure it was.", component: noop });
|
||||
|
||||
export const Callout = defineComponent({ props: z.object({ level: z.enum(["info", "warn", "danger", "success"]),
|
||||
title: z.string(), text: z.string() }),
|
||||
name: "Callout", description: "A warning or note the user must not miss.", component: noop });
|
||||
|
||||
export const Report = defineComponent({ props: z.object({ blocks: z.array(z.union([Headline.ref, VerdictBlock.ref,
|
||||
MetricGrid.ref, Findings.ref, OptionTable.ref, StepList.ref, SqlBlock.ref, ScoreBar.ref, Callout.ref])) }),
|
||||
name: "Report", description: "Root. A query-plan report in reading order.", component: noop });
|
||||
|
||||
export const specs = [Headline, Metric, MetricGrid, Finding, Findings, Step, StepList, SqlBlock, ScoreBar,
|
||||
OptionRow, OptionTable, VerdictBlock, Callout, Report];
|
||||
|
||||
/** Prompt-side library: same specs, no renderers. Safe in a non-React Node runtime. */
|
||||
export const promptLibrary = createLibrary({ root: "Report", components: specs });
|
||||
|
||||
export const reportPromptOptions = {
|
||||
preamble: "You output only OpenUI Lang. No prose, no markdown fences, no commentary before or after.",
|
||||
additionalRules: [
|
||||
"root = Report([...]) and it must list every block you define.",
|
||||
"Block order: Headline, VerdictBlock, MetricGrid, Findings, OptionTable, then per option its StepList and SqlBlock, then ScoreBars, then Callout.",
|
||||
"Use ONLY numbers present in the input. Never invent a cost, row count, impact, or confidence.",
|
||||
"One SqlBlock per rewrite, titled with that option's key. T-SQL only, no markdown.",
|
||||
"Every option flagged verify_semantics gets a Callout level danger: compare result rows before deploying.",
|
||||
"If verdict.source is composite, say plainly that Jev did not decide.",
|
||||
],
|
||||
examples: [`root = Report([head, verdict])
|
||||
head = Headline("Clustered Index Scan on Sales.Orders is 84% of the plan", "No usable key on CustomerId", "critical")
|
||||
verdict = VerdictBlock("Create the covering index", "bottleneck_fit 1.00, semantic_safety 1.00, ease 0.50.", "jev", 0.82)`],
|
||||
};
|
||||
|
||||
export const reportPrompt = () => promptLibrary.prompt(reportPromptOptions);
|
||||
+41
-83
@@ -1,127 +1,85 @@
|
||||
import { createElement as el, type ReactNode } from "react";
|
||||
import { createElement as el } from "react";
|
||||
import { defineComponent, createLibrary } from "@openuidev/react-lang";
|
||||
import { z } from "zod/v4";
|
||||
import { specs } from "./report-spec.ts";
|
||||
|
||||
// No JSX in this file on purpose: `node --test` imports it, and Node strips types but not JSX.
|
||||
// el(type, props, ...children) is createElement with the noise removed.
|
||||
const p = (style: Record<string, string>, ...kids: ReactNode[]) => el("p", { style }, ...kids);
|
||||
const muted = { color: "var(--qai-ink-muted)" } as const;
|
||||
const small = { fontSize: "var(--qai-text-xs)", color: "var(--qai-ink-muted)" } as const;
|
||||
const disp = { fontFamily: "var(--qai-font-display)" } as const;
|
||||
// Props, descriptions and the prompt come from lib/report-spec.ts (React-free, usable in the API route).
|
||||
// This file only says what each component looks like. Names and schemas cannot drift: they are imported.
|
||||
// No JSX here either — `node --test` imports modules and Node does not strip JSX.
|
||||
const p = (style: Record<string, string | number>, ...kids: any[]) => el("p", { style }, ...kids);
|
||||
const muted = { color: "var(--qai-ink-muted)" };
|
||||
const small = { fontSize: "var(--qai-text-xs)", color: "var(--qai-ink-muted)" };
|
||||
const disp = { fontFamily: "var(--qai-font-display)" };
|
||||
|
||||
const Headline = defineComponent({ name: "Headline",
|
||||
description: "Report title: the one-line finding, with severity.",
|
||||
props: z.object({ title: z.string(), subtitle: z.string().optional(),
|
||||
severity: z.enum(["critical", "warning", "info"]).optional() }),
|
||||
component: ({ props }) => el("header", null,
|
||||
const renderers: Record<string, (x: any) => any> = {
|
||||
Headline: ({ props }) => el("header", null,
|
||||
el("p", { className: "label" }, props.severity === "critical" ? "Critical" : props.severity === "warning" ? "Warning" : "Note"),
|
||||
el("h2", { style: { ...disp, fontSize: "var(--qai-text-2xl)", margin: "var(--qai-space-s) 0" } }, props.title),
|
||||
props.subtitle ? p(muted, props.subtitle) : null) });
|
||||
props.subtitle ? p(muted, props.subtitle) : null),
|
||||
|
||||
const Metric = defineComponent({ name: "Metric", description: "One plan metric.",
|
||||
props: z.object({ label: z.string(), value: z.string(), note: z.string().optional() }),
|
||||
component: ({ props }) => el("div", { className: "card" },
|
||||
Metric: ({ props }) => el("div", { className: "card" },
|
||||
el("p", { className: "label" }, props.label),
|
||||
p({ ...disp, fontSize: "var(--qai-text-xl)" }, props.value),
|
||||
props.note ? p(small, props.note) : null) });
|
||||
props.note ? p(small, props.note) : null),
|
||||
|
||||
const MetricGrid = defineComponent({ name: "MetricGrid", description: "Row of plan metrics.",
|
||||
props: z.object({ items: z.array(Metric.ref) }),
|
||||
component: ({ props, renderNode }) => el("div", { className: "grid-metrics" }, renderNode(props.items)) });
|
||||
MetricGrid: ({ props, renderNode }: any) => el("div", { className: "grid-metrics" }, renderNode(props.items)),
|
||||
|
||||
const Finding = defineComponent({ name: "Finding", description: "One problem, the plan evidence for it, its impact.",
|
||||
props: z.object({ where: z.string(), issue: z.string(), evidence: z.string(), impact: z.string() }),
|
||||
component: ({ props }) => el("div", { className: "card" },
|
||||
Finding: ({ props }) => el("div", { className: "card" },
|
||||
el("p", { className: "label" }, props.where),
|
||||
p({ margin: "var(--qai-space-s) 0" }, el("strong", null, props.issue)),
|
||||
el("p", { className: "sql" }, props.evidence),
|
||||
p(muted, props.impact)) });
|
||||
p(muted, props.impact)),
|
||||
|
||||
const Findings = defineComponent({ name: "Findings", description: "Everything wrong with the plan.",
|
||||
props: z.object({ items: z.array(Finding.ref) }),
|
||||
component: ({ props, renderNode }) => el("section", { className: "report" }, renderNode(props.items)) });
|
||||
Findings: ({ props, renderNode }: any) => el("section", { className: "report" }, renderNode(props.items)),
|
||||
|
||||
const Step = defineComponent({ name: "Step", description: "One action step.",
|
||||
props: z.object({ text: z.string(), effort: z.enum(["low", "medium", "high"]).optional(),
|
||||
owner: z.enum(["dba", "developer", "both"]).optional(), changeControl: z.boolean().optional() }),
|
||||
component: ({ props }) => el("li", { className: "card", style: { display: "flex", gap: "var(--qai-space-md)",
|
||||
Step: ({ props }) => el("li", { className: "card", style: { display: "flex", gap: "var(--qai-space-md)",
|
||||
alignItems: "center", flexWrap: "wrap" } },
|
||||
el("span", { style: { flex: "1 1 200px", minWidth: 0 } }, props.text),
|
||||
props.effort ? el("span", { className: "pill" }, `effort ${props.effort}`) : null,
|
||||
props.owner ? el("span", { className: "pill" }, props.owner) : null,
|
||||
props.changeControl ? el("span", { className: "pill" }, "change control") : null) });
|
||||
props.changeControl ? el("span", { className: "pill" }, "change control") : null),
|
||||
|
||||
const StepList = defineComponent({ name: "StepList", description: "Ordered action plan for one option.",
|
||||
props: z.object({ title: z.string(), steps: z.array(Step.ref) }),
|
||||
component: ({ props, renderNode }) => el("section", null,
|
||||
StepList: ({ props, renderNode }: any) => el("section", null,
|
||||
el("h3", { style: disp }, props.title),
|
||||
el("ol", { style: { listStyle: "none", padding: 0, margin: 0, display: "grid", gap: "var(--qai-space-md)" } },
|
||||
renderNode(props.steps))) });
|
||||
renderNode(props.steps))),
|
||||
|
||||
const SqlBlock = defineComponent({ name: "SqlBlock", description: "A SQL snippet the user can copy.",
|
||||
props: z.object({ title: z.string(), sql: z.string(), caption: z.string().optional() }),
|
||||
component: ({ props }) => el("figure", { className: "card", style: { margin: 0 } },
|
||||
SqlBlock: ({ props }) => el("figure", { className: "card", style: { margin: 0 } },
|
||||
el("figcaption", { className: "label" }, props.title),
|
||||
el("pre", { className: "sql" }, props.sql),
|
||||
el("button", { className: "btn no-print", onClick: () => navigator.clipboard.writeText(props.sql) }, "Copy SQL"),
|
||||
props.caption ? el("p", { style: small }, props.caption) : null) });
|
||||
props.caption ? el("p", { style: small }, props.caption) : null),
|
||||
|
||||
const ScoreBar = defineComponent({ name: "ScoreBar", description: "One Jev dimension: normalised value plus confidence.",
|
||||
props: z.object({ label: z.string(), value: z.number(), confidence: z.number().optional(), note: z.string().optional() }),
|
||||
component: ({ props }) => el("div", null,
|
||||
ScoreBar: ({ props }) => el("div", null,
|
||||
el("p", { className: "label" }, props.label + (props.confidence !== undefined
|
||||
? ` · confidence ${props.confidence.toFixed(2)}` : " · no confidence reported")),
|
||||
el("div", { className: "bar", style: { width: `${Math.round(props.value * 100)}%` } }),
|
||||
props.note ? el("p", { style: small }, props.note) : null) });
|
||||
props.note ? el("p", { style: small }, props.note) : null),
|
||||
|
||||
const OptionRow = defineComponent({ name: "OptionRow", description: "One ranked option.",
|
||||
props: z.object({ rank: z.number(), title: z.string(), composite: z.number(), headline: z.boolean(),
|
||||
flags: z.string().optional() }),
|
||||
component: ({ props }) => el("tr", { "data-headline": props.headline || undefined },
|
||||
OptionRow: ({ props }) => el("tr", { "data-headline": props.headline || undefined },
|
||||
el("td", null, String(props.rank)), el("td", null, props.title),
|
||||
el("td", { className: "sql" }, props.composite.toFixed(2)),
|
||||
el("td", null, props.flags || "—")) });
|
||||
el("td", null, props.flags || "—")),
|
||||
|
||||
const OptionTable = defineComponent({ name: "OptionTable", description: "All options ranked by composite score.",
|
||||
props: z.object({ title: z.string(), weights: z.string(), rows: z.array(OptionRow.ref) }),
|
||||
component: ({ props, renderNode }) => el("section", { className: "card" },
|
||||
OptionTable: ({ props, renderNode }: any) => el("section", { className: "card" },
|
||||
el("h3", { style: disp }, props.title),
|
||||
el("p", { style: small }, props.weights),
|
||||
el("table", { style: { width: "100%", borderCollapse: "collapse" } }, el("tbody", null, renderNode(props.rows)))) });
|
||||
el("table", { style: { width: "100%", borderCollapse: "collapse" } }, el("tbody", null, renderNode(props.rows)))),
|
||||
|
||||
const VerdictBlock = defineComponent({ name: "VerdictBlock", description: "What Jev decided, and how sure it was.",
|
||||
props: z.object({ headline: z.string(), why: z.string(), source: z.enum(["jev", "composite"]), confidence: z.number() }),
|
||||
component: ({ props }) => el("section", { className: "card", "data-verdict": "" },
|
||||
VerdictBlock: ({ props }) => el("section", { className: "card", "data-verdict": "" },
|
||||
el("p", { className: "label" }, "First move · " + (props.source === "jev"
|
||||
? `Jev, confidence ${props.confidence.toFixed(2)}` : "composite order — Jev did not decide")),
|
||||
p({ ...disp, fontSize: "var(--qai-text-lg)" }, props.headline),
|
||||
p({}, props.why)) });
|
||||
p({}, props.why)),
|
||||
|
||||
const Callout = defineComponent({ name: "Callout", description: "A warning or note the user must not miss.",
|
||||
props: z.object({ level: z.enum(["info", "warn", "danger", "success"]), title: z.string(), text: z.string() }),
|
||||
component: ({ props }) => el("div", { className: "card", "data-level": props.level,
|
||||
Callout: ({ props }) => el("div", { className: "card", "data-level": props.level,
|
||||
role: props.level === "danger" ? "alert" : "note" },
|
||||
el("p", null, el("strong", null, props.title)), el("p", null, props.text)) });
|
||||
el("p", null, el("strong", null, props.title)), el("p", null, props.text)),
|
||||
|
||||
const Report = defineComponent({ name: "Report", description: "Root. A query-plan report in reading order.",
|
||||
props: z.object({ blocks: z.array(z.union([Headline.ref, VerdictBlock.ref, MetricGrid.ref, Findings.ref,
|
||||
OptionTable.ref, StepList.ref, SqlBlock.ref, ScoreBar.ref, Callout.ref])) }),
|
||||
component: ({ props, renderNode }) => el("div", { className: "report", id: "report" }, renderNode(props.blocks)) });
|
||||
|
||||
export const library = createLibrary({ root: "Report", components: [Headline, Metric, MetricGrid, Finding, Findings,
|
||||
Step, StepList, SqlBlock, ScoreBar, OptionRow, OptionTable, VerdictBlock, Callout, Report] });
|
||||
|
||||
export const reportPromptOptions = {
|
||||
preamble: "You output only OpenUI Lang. No prose, no markdown fences, no commentary before or after.",
|
||||
additionalRules: [
|
||||
"root = Report([...]) and it must list every block you define.",
|
||||
"Block order: Headline, VerdictBlock, MetricGrid, Findings, OptionTable, then per option its StepList and SqlBlock, then ScoreBars, then Callout.",
|
||||
"Use ONLY numbers present in the input. Never invent a cost, row count, impact, or confidence.",
|
||||
"One SqlBlock per rewrite, titled with that option's key. T-SQL only, no markdown.",
|
||||
"Every option flagged verify_semantics gets a Callout level danger: compare result rows before deploying.",
|
||||
"If verdict.source is composite, say plainly that Jev did not decide.",
|
||||
],
|
||||
examples: [`root = Report([head, verdict])
|
||||
head = Headline("Clustered Index Scan on Sales.Orders is 84% of the plan", "No usable key on CustomerId", "critical")
|
||||
verdict = VerdictBlock("Create the covering index", "bottleneck_fit 1.00, semantic_safety 1.00, ease 0.50.", "jev", 0.82)`],
|
||||
Report: ({ props, renderNode }: any) => el("div", { className: "report", id: "report" }, renderNode(props.blocks)),
|
||||
};
|
||||
|
||||
export const library = createLibrary({
|
||||
root: "Report",
|
||||
components: specs.map((s) => defineComponent({ name: s.name, props: s.props, description: s.description,
|
||||
component: renderers[s.name] })),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
export const sse = (event: string, data: unknown) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
|
||||
export async function* readSse(body: ReadableStream<Uint8Array>): AsyncGenerator<{ event: string; data: any }> {
|
||||
const reader = body.getReader(); const dec = new TextDecoder(); let buf = "";
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
let at: number;
|
||||
while ((at = buf.indexOf("\n\n")) !== -1) {
|
||||
const frame = buf.slice(0, at); buf = buf.slice(at + 2);
|
||||
const event = /^event: (.+)$/m.exec(frame)?.[1] ?? "message";
|
||||
const raw = /^data: (.*)$/m.exec(frame)?.[1] ?? "";
|
||||
try { yield { event, data: JSON.parse(raw) }; } catch { /* keep-alive or unparsable frame */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function* openAiTextDeltas(res: Response): AsyncGenerator<string> {
|
||||
const reader = res.body!.getReader(); const dec = new TextDecoder(); let buf = "";
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
let at: number;
|
||||
while ((at = buf.indexOf("\n\n")) !== -1) {
|
||||
const line = (buf.slice(0, at).split("\n").find((l) => l.startsWith("data:")) ?? "").slice(5).trim();
|
||||
buf = buf.slice(at + 2);
|
||||
if (!line) continue;
|
||||
if (line === "[DONE]") return;
|
||||
let delta: unknown;
|
||||
try { delta = JSON.parse(line)?.choices?.[0]?.delta?.content; } catch { continue; }
|
||||
if (typeof delta === "string" && delta) yield delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user