Skip to content

Commit fd3f54d

Browse files
committed
Markdown & docs
1 parent 53af9eb commit fd3f54d

3 files changed

Lines changed: 254 additions & 4 deletions

File tree

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Same code. Same tools. **10× fewer tokens · 3.5× faster · deterministic · i
1414
[![npm](https://img.shields.io/npm/v/oyadotai?color=black&label=oyadotai)](https://www.npmjs.com/package/oyadotai)
1515
 ·  TypeScript · Bun · MIT ·  **Drop-in for Mastra**
1616

17-
[Quickstart](#quickstart) · [The numbers](#the-numbers) · [Why](#why) · [Migrate in 2 lines](#migrate-from-mastra-in-2-lines) · [Studio](#studio)
17+
[Quickstart](#quickstart) · [The numbers](#the-numbers) · [Why](#why) · [Migrate in 2 lines](#migrate-from-mastra-in-2-lines) · [Studio](#studio) · [Docs](#documentation)
1818

1919
</div>
2020

@@ -214,6 +214,20 @@ Bun server, a worker, the edge — `await agent.generate(prompt)`. Stream it wit
214214
`oya/react`'s `usePlan` / `useChat`, or serve SSE with `oyadotai-server`. Managed
215215
hosting (Oya Cloud) is coming.
216216

217+
## Documentation
218+
219+
Full docs live in [`docs/`](./docs) (served locally with `make docs`):
220+
221+
**Guide**
222+
- [Getting Started](./docs/guide/getting-started.md) — install and write your first agent
223+
- [Creating an Agent](./docs/guide/creating-agents.md) — tools, instructions, and the plan-once model
224+
- [Configuring the Sandbox](./docs/guide/sandbox.md) — where and how each tool's `execute` runs
225+
- [Studio](./docs/guide/studio.md) — chat with your agents and watch the plan execute live
226+
227+
**Concepts**
228+
- [Projection Types](./docs/concepts/projection-types.md) — the `OPAQUE` / `SUMMARY` / `TRANSPARENT` lattice, and why it closes prompt injection at the root
229+
- [The Plan IR](./docs/concepts/plan-ir.md) — the typed dataflow graph the planner emits and the runtime executes
230+
217231
## Install
218232

219233
```bash

packages/core/studio/Markdown.tsx

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { Fragment, type ReactNode } from "react";
2+
3+
/**
4+
* A tiny, dependency-free Markdown renderer sized for agent chat output.
5+
* Handles headings, bold/italic, inline & fenced code, links, blockquotes,
6+
* horizontal rules, and ordered/unordered lists. Not CommonMark-complete —
7+
* just the subset LLMs actually emit — but safe: all text is escaped by React,
8+
* so no raw HTML is ever injected.
9+
*/
10+
11+
type Block =
12+
| { t: "h"; level: number; text: string }
13+
| { t: "p"; text: string }
14+
| { t: "code"; lang: string; text: string }
15+
| { t: "quote"; text: string }
16+
| { t: "hr" }
17+
| { t: "ul"; items: string[] }
18+
| { t: "ol"; items: string[] };
19+
20+
const HEAD = /^(#{1,6})\s+(.*)$/;
21+
const FENCE = /^```(\w*)\s*$/;
22+
const UL = /^[-*+]\s+(.*)$/;
23+
const OL = /^\d+[.)]\s+(.*)$/;
24+
const HR = /^(?:---+|\*\*\*+|___+)\s*$/;
25+
26+
function parse(src: string): Block[] {
27+
const lines = src.replace(/\r\n?/g, "\n").split("\n");
28+
const blocks: Block[] = [];
29+
let i = 0;
30+
31+
while (i < lines.length) {
32+
const line = lines[i];
33+
34+
// fenced code block
35+
const fence = FENCE.exec(line);
36+
if (fence) {
37+
const lang = fence[1] ?? "";
38+
const buf: string[] = [];
39+
i++;
40+
while (i < lines.length && !FENCE.test(lines[i])) buf.push(lines[i++]);
41+
i++; // consume closing fence
42+
blocks.push({ t: "code", lang, text: buf.join("\n") });
43+
continue;
44+
}
45+
46+
if (!line.trim()) {
47+
i++;
48+
continue;
49+
}
50+
51+
if (HR.test(line)) {
52+
blocks.push({ t: "hr" });
53+
i++;
54+
continue;
55+
}
56+
57+
const head = HEAD.exec(line);
58+
if (head) {
59+
blocks.push({ t: "h", level: head[1].length, text: head[2] });
60+
i++;
61+
continue;
62+
}
63+
64+
if (UL.test(line)) {
65+
const items: string[] = [];
66+
while (i < lines.length && UL.test(lines[i])) items.push(UL.exec(lines[i++])![1]);
67+
blocks.push({ t: "ul", items });
68+
continue;
69+
}
70+
71+
if (OL.test(line)) {
72+
const items: string[] = [];
73+
while (i < lines.length && OL.test(lines[i])) items.push(OL.exec(lines[i++])![1]);
74+
blocks.push({ t: "ol", items });
75+
continue;
76+
}
77+
78+
if (line.startsWith(">")) {
79+
const buf: string[] = [];
80+
while (i < lines.length && lines[i].startsWith(">")) buf.push(lines[i++].replace(/^>\s?/, ""));
81+
blocks.push({ t: "quote", text: buf.join("\n") });
82+
continue;
83+
}
84+
85+
// paragraph: gather until blank line or a line that starts a new block
86+
const buf: string[] = [];
87+
while (
88+
i < lines.length &&
89+
lines[i].trim() &&
90+
!HEAD.test(lines[i]) &&
91+
!FENCE.test(lines[i]) &&
92+
!UL.test(lines[i]) &&
93+
!OL.test(lines[i]) &&
94+
!HR.test(lines[i]) &&
95+
!lines[i].startsWith(">")
96+
) {
97+
buf.push(lines[i++]);
98+
}
99+
blocks.push({ t: "p", text: buf.join("\n") });
100+
}
101+
102+
return blocks;
103+
}
104+
105+
// Inline: code spans first (so their contents aren't further parsed), then links,
106+
// bold, italic. Returns an array of React nodes.
107+
function inline(text: string, keyBase: string): ReactNode[] {
108+
const out: ReactNode[] = [];
109+
const re = /(`[^`]+`)|(\[[^\]]+\]\([^)\s]+\))|(\*\*[^*]+\*\*|__[^_]+__)|(\*[^*]+\*|_[^_]+_)/g;
110+
let last = 0;
111+
let m: RegExpExecArray | null;
112+
let k = 0;
113+
114+
while ((m = re.exec(text))) {
115+
if (m.index > last) out.push(text.slice(last, m.index));
116+
const key = `${keyBase}-${k++}`;
117+
if (m[1]) {
118+
out.push(
119+
<code key={key} className="mono rounded bg-surface2 px-1 py-0.5 text-[0.85em] text-fg">
120+
{m[1].slice(1, -1)}
121+
</code>,
122+
);
123+
} else if (m[2]) {
124+
const lm = /\[([^\]]+)\]\(([^)\s]+)\)/.exec(m[2])!;
125+
out.push(
126+
<a
127+
key={key}
128+
href={lm[2]}
129+
target="_blank"
130+
rel="noreferrer"
131+
className="text-brand underline decoration-brand/40 underline-offset-2 hover:decoration-brand"
132+
>
133+
{lm[1]}
134+
</a>,
135+
);
136+
} else if (m[3]) {
137+
out.push(
138+
<strong key={key} className="font-semibold text-fg">
139+
{inline(m[3].slice(2, -2), key)}
140+
</strong>,
141+
);
142+
} else if (m[4]) {
143+
out.push(
144+
<em key={key} className="italic">
145+
{inline(m[4].slice(1, -1), key)}
146+
</em>,
147+
);
148+
}
149+
last = re.lastIndex;
150+
}
151+
if (last < text.length) out.push(text.slice(last));
152+
return out;
153+
}
154+
155+
const HSIZE = ["text-[20px]", "text-[18px]", "text-[16px]", "text-[15px]", "text-[14px]", "text-[13px]"];
156+
157+
export function Markdown({ text, className }: { text: string; className?: string }) {
158+
const blocks = parse(text);
159+
return (
160+
<div className={className}>
161+
{blocks.map((b, i) => {
162+
switch (b.t) {
163+
case "h": {
164+
const size = HSIZE[b.level - 1] ?? HSIZE[5];
165+
return (
166+
<div key={i} className={`mb-1.5 mt-3 font-semibold text-fg first:mt-0 ${size}`}>
167+
{inline(b.text, `h${i}`)}
168+
</div>
169+
);
170+
}
171+
case "p":
172+
return (
173+
<p key={i} className="mb-2.5 whitespace-pre-wrap leading-relaxed last:mb-0">
174+
{inline(b.text, `p${i}`)}
175+
</p>
176+
);
177+
case "code":
178+
return (
179+
<pre
180+
key={i}
181+
className="scrollbar-thin mono mb-2.5 overflow-x-auto rounded-lg border border-line bg-surface2/60 p-3 text-[12px] leading-6"
182+
>
183+
<code>{b.text}</code>
184+
</pre>
185+
);
186+
case "quote":
187+
return (
188+
<blockquote key={i} className="mb-2.5 border-l-2 border-brand/50 pl-3 text-muted italic">
189+
{b.text.split("\n").map((l, j) => (
190+
<Fragment key={j}>
191+
{inline(l, `q${i}-${j}`)}
192+
<br />
193+
</Fragment>
194+
))}
195+
</blockquote>
196+
);
197+
case "hr":
198+
return <hr key={i} className="my-4 border-line" />;
199+
case "ul":
200+
return (
201+
<ul key={i} className="mb-2.5 ml-1 list-disc space-y-1 pl-4 marker:text-faint">
202+
{b.items.map((it, j) => (
203+
<li key={j} className="leading-relaxed">
204+
{inline(it, `ul${i}-${j}`)}
205+
</li>
206+
))}
207+
</ul>
208+
);
209+
case "ol":
210+
return (
211+
<ol key={i} className="mb-2.5 ml-1 list-decimal space-y-1 pl-4 marker:text-faint">
212+
{b.items.map((it, j) => (
213+
<li key={j} className="leading-relaxed">
214+
{inline(it, `ol${i}-${j}`)}
215+
</li>
216+
))}
217+
</ol>
218+
);
219+
}
220+
})}
221+
</div>
222+
);
223+
}

packages/core/studio/Studio.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import clsx from "clsx";
55
import { applyEvent, initialPlanState, type NodeState, type PlanState } from "../src/react/index.js";
66
import type { OyaEvent } from "../src/stream.js";
77
import { Dag, type RawPlan } from "./Dag";
8+
import { Markdown } from "./Markdown";
89

910
type Msg = { role: "user" | "bot"; content: string };
1011
type Usage = { inputTokens: number; outputTokens: number; modelCalls: number };
@@ -240,8 +241,18 @@ export function Studio() {
240241
>
241242
{m.role === "user" ? "you" : "◆"}
242243
</div>
243-
<div className="flex-1 whitespace-pre-wrap leading-relaxed">
244-
{m.content || (busy && i === msgs.length - 1 ? <span className="text-faint"></span> : "")}
244+
<div className="min-w-0 flex-1 leading-relaxed">
245+
{m.content ? (
246+
m.role === "bot" ? (
247+
<Markdown text={m.content} />
248+
) : (
249+
<div className="whitespace-pre-wrap">{m.content}</div>
250+
)
251+
) : busy && i === msgs.length - 1 ? (
252+
<span className="text-faint"></span>
253+
) : (
254+
""
255+
)}
245256
</div>
246257
</div>
247258
))
@@ -312,7 +323,9 @@ export function Studio() {
312323
/>
313324
</div>
314325
{view.text && (
315-
<div className="scrollbar-thin max-h-[38%] overflow-auto border-t border-line p-4 text-[13px] leading-relaxed text-fg">{view.text}</div>
326+
<div className="scrollbar-thin max-h-[38%] overflow-auto border-t border-line p-4 text-[13px] leading-relaxed text-fg">
327+
<Markdown text={view.text} />
328+
</div>
316329
)}
317330
</div>
318331
) : tab === "trace" ? (

0 commit comments

Comments
 (0)