feat(editor): embed studio with data files and agent-aware editing
Browse filesEditor
- New Embed Studio panel for AI-assisted chart creation: upload and
manage data files (3MB limit), browse them in a dedicated viewer,
and iterate on the generated HTML via a chat wired to a dedicated
embed agent (embed-chat + embed-system-prompt + embed-tools +
tiptap-catalog) that can create, rename and edit embeds.
- Tiptap extensions for agent-driven editing:
- agent-focus: Yjs-awareness ranges so every peer sees where
another user's AI is working ("<Name> agent" label).
- agent-highlight: local PM decoration to mark the paragraph the
agent is currently rewriting.
- agent-rewrite: typewriter-animated range rewrite command used
by the rephrase flow.
- Rephrase flow now goes through agent-rewrite (typewriter) instead
of a plain replace; bold demonstration moved to the floating bubble
toolbar above a selection.
- SlashMenu reorganised with grouping and dev-only items dropped.
- New components: DataFileViewer, FilesSidebar, UploadZone.
- Refreshed TopBar, ChatPanel, SettingsDrawer, Editor.
- Hooks: useEmbedChat + useEmbedData (data file store) + refreshed
useAgentChat.
- data-files helper + ai-tool-parts update.
Made-with: Cursor
- backend/src/agent/embed-chat.ts +13 -0
- backend/src/agent/embed-system-prompt.ts +55 -6
- backend/src/agent/embed-tools.ts +36 -0
- backend/src/agent/system-prompt.ts +99 -7
- backend/src/agent/tiptap-catalog.ts +266 -0
- backend/src/agent/tools.ts +17 -2
- frontend/src/components/ChatPanel.tsx +1 -43
- frontend/src/components/DataFileViewer.tsx +157 -0
- frontend/src/components/EmbedStudio.tsx +87 -7
- frontend/src/components/FilesSidebar.tsx +132 -0
- frontend/src/components/TopBar.tsx +99 -1
- frontend/src/components/UploadZone.tsx +65 -0
- frontend/src/editor/Editor.tsx +19 -1
- frontend/src/editor/SlashMenu.tsx +150 -33
- frontend/src/editor/agent-executor.ts +218 -0
- frontend/src/editor/embeds/embed-data-store.ts +107 -0
- frontend/src/editor/extensions/agent-focus.ts +315 -0
- frontend/src/editor/extensions/agent-highlight.ts +165 -0
- frontend/src/editor/extensions/agent-rewrite.ts +267 -0
- frontend/src/editor/frontmatter/SettingsDrawer.tsx +0 -9
- frontend/src/hooks/useAgentChat.ts +33 -28
- frontend/src/hooks/useEmbedChat.ts +157 -9
- frontend/src/hooks/useEmbedData.ts +109 -0
- frontend/src/styles/components/_embed-studio.css +388 -0
- frontend/src/styles/editor/_block-tools.css +22 -3
- frontend/src/utils/ai-tool-parts.ts +5 -1
- frontend/src/utils/data-files.ts +145 -0
|
@@ -2,6 +2,7 @@ import {
|
|
| 2 |
EMBED_SYSTEM_PROMPT,
|
| 3 |
BANNER_SYSTEM_PROMPT,
|
| 4 |
buildEmbedContext,
|
|
|
|
| 5 |
} from "./embed-system-prompt.js";
|
| 6 |
import { embedTools } from "./embed-tools.js";
|
| 7 |
import { streamChatResponse } from "./stream-handler.js";
|
|
@@ -11,10 +12,22 @@ export async function handleEmbedChat(req: Request, res: Response) {
|
|
| 11 |
const { context } = req.body;
|
| 12 |
const contextBlock = buildEmbedContext(context?.embedHtml);
|
| 13 |
const isBanner = Boolean(context?.isBanner);
|
|
|
|
|
|
|
| 14 |
|
| 15 |
const parts = [EMBED_SYSTEM_PROMPT];
|
| 16 |
if (isBanner) parts.push(BANNER_SYSTEM_PROMPT);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
if (contextBlock) parts.push(`## Current chart\n\n${contextBlock}`);
|
|
|
|
| 18 |
const systemPrompt = parts.join("\n\n");
|
| 19 |
|
| 20 |
return streamChatResponse(req, res, {
|
|
|
|
| 2 |
EMBED_SYSTEM_PROMPT,
|
| 3 |
BANNER_SYSTEM_PROMPT,
|
| 4 |
buildEmbedContext,
|
| 5 |
+
buildDataFilesContext,
|
| 6 |
} from "./embed-system-prompt.js";
|
| 7 |
import { embedTools } from "./embed-tools.js";
|
| 8 |
import { streamChatResponse } from "./stream-handler.js";
|
|
|
|
| 12 |
const { context } = req.body;
|
| 13 |
const contextBlock = buildEmbedContext(context?.embedHtml);
|
| 14 |
const isBanner = Boolean(context?.isBanner);
|
| 15 |
+
const isScratch = Boolean(context?.isScratch);
|
| 16 |
+
const dataFilesBlock = buildDataFilesContext(context?.dataFiles);
|
| 17 |
|
| 18 |
const parts = [EMBED_SYSTEM_PROMPT];
|
| 19 |
if (isBanner) parts.push(BANNER_SYSTEM_PROMPT);
|
| 20 |
+
if (isScratch) {
|
| 21 |
+
parts.push(
|
| 22 |
+
"## New chart\n\nThis chart is BRAND NEW - it has an auto-generated " +
|
| 23 |
+
"placeholder filename. Your VERY FIRST `createEmbed` call MUST include " +
|
| 24 |
+
"a descriptive `filename` slug (kebab-case, no extension, 2-4 words) " +
|
| 25 |
+
"that reflects what the chart represents. The client will rename the " +
|
| 26 |
+
"underlying file and keep the document in sync.",
|
| 27 |
+
);
|
| 28 |
+
}
|
| 29 |
if (contextBlock) parts.push(`## Current chart\n\n${contextBlock}`);
|
| 30 |
+
if (dataFilesBlock) parts.push(`## Attached data\n\n${dataFilesBlock}`);
|
| 31 |
const systemPrompt = parts.join("\n\n");
|
| 32 |
|
| 33 |
return streamChatResponse(req, res, {
|
|
@@ -11,10 +11,24 @@ You help users create, edit, and improve interactive data visualizations.
|
|
| 11 |
## Tools
|
| 12 |
|
| 13 |
- **createEmbed**: Create or fully replace the chart HTML. Use for new charts or extensive rewrites.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
- **patchEmbed**: Surgically edit a specific part of the chart. Preferred for small changes.
|
| 15 |
The search string must be an exact verbatim copy from the current HTML.
|
| 16 |
Call readEmbed first if you're unsure of the exact content.
|
| 17 |
- **readEmbed**: Read the full current HTML. Call before patching.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
## Chart conventions
|
| 20 |
|
|
@@ -106,28 +120,63 @@ Every chart is a self-contained HTML fragment with:
|
|
| 106 |
- Select closest previous sibling with root class, fallback to last unmounted
|
| 107 |
|
| 108 |
### Data
|
| 109 |
-
-
|
| 110 |
-
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
## Guidelines
|
| 114 |
|
| 115 |
1. **Act immediately.** When the user asks for a chart, create it right away with createEmbed.
|
| 116 |
2. **Use patchEmbed for edits.** Read the current chart first, then apply surgical patches.
|
| 117 |
3. **Follow conventions strictly.** Every chart must have: root div, scoped style, IIFE script, ColorPalettes, mount guard, responsive layout.
|
| 118 |
-
4. **
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
## Context
|
| 122 |
|
| 123 |
The current chart HTML (if any) is provided between <embed> tags.
|
| 124 |
-
Use this to understand the existing chart structure before making edits.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
export function buildEmbedContext(embedHtml?: string): string {
|
| 127 |
if (!embedHtml) return "";
|
| 128 |
return `<embed>\n${embedHtml}\n</embed>`;
|
| 129 |
}
|
| 130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
/**
|
| 132 |
* Extra instructions layered on top of the base system prompt when the
|
| 133 |
* chart being edited is the article banner (top-of-article hero).
|
|
|
|
| 11 |
## Tools
|
| 12 |
|
| 13 |
- **createEmbed**: Create or fully replace the chart HTML. Use for new charts or extensive rewrites.
|
| 14 |
+
On the FIRST call for a new chart, you MUST pass a descriptive \`filename\`
|
| 15 |
+
slug (kebab-case, no extension, e.g. "attention-heatmap", "model-accuracy",
|
| 16 |
+
"sales-by-region") that reflects what the chart represents. The client
|
| 17 |
+
will rename the underlying file and keep the doc in sync. Omit
|
| 18 |
+
\`filename\` on subsequent calls that only refresh the same chart.
|
| 19 |
- **patchEmbed**: Surgically edit a specific part of the chart. Preferred for small changes.
|
| 20 |
The search string must be an exact verbatim copy from the current HTML.
|
| 21 |
Call readEmbed first if you're unsure of the exact content.
|
| 22 |
- **readEmbed**: Read the full current HTML. Call before patching.
|
| 23 |
+
- **listDataFiles**: List user-attached data files (CSV, TSV, JSON, NDJSON, TXT).
|
| 24 |
+
Call this as soon as the user references "the data", "my dataset", a filename,
|
| 25 |
+
or otherwise implies real content should drive the chart.
|
| 26 |
+
- **readDataFile**: Read the raw content of a single attached data file.
|
| 27 |
+
Use to inspect structure, sample values, and pull the data you need. Because
|
| 28 |
+
the chart iframe is sandboxed and cannot fetch external files, you must
|
| 29 |
+
**inline** the data you use inside the chart script (ideally as a parsed
|
| 30 |
+
JSON array). For very large files, aggregate / downsample the data before
|
| 31 |
+
inlining it rather than embedding the raw content verbatim.
|
| 32 |
|
| 33 |
## Chart conventions
|
| 34 |
|
|
|
|
| 120 |
- Select closest previous sibling with root class, fallback to last unmounted
|
| 121 |
|
| 122 |
### Data
|
| 123 |
+
- Prefer user-attached data files: call \`listDataFiles\` first, then
|
| 124 |
+
\`readDataFile\` on the one that matches the user's intent, and **inline**
|
| 125 |
+
the parsed data directly in the script (the iframe is sandboxed and cannot
|
| 126 |
+
fetch external files).
|
| 127 |
+
- For demo/synthetic data, embed it inline as a JS array/object literal.
|
| 128 |
+
- Handle parse/render errors gracefully with a red \`<pre>\` message.
|
| 129 |
|
| 130 |
## Guidelines
|
| 131 |
|
| 132 |
1. **Act immediately.** When the user asks for a chart, create it right away with createEmbed.
|
| 133 |
2. **Use patchEmbed for edits.** Read the current chart first, then apply surgical patches.
|
| 134 |
3. **Follow conventions strictly.** Every chart must have: root div, scoped style, IIFE script, ColorPalettes, mount guard, responsive layout.
|
| 135 |
+
4. **Use attached data when relevant.** If the user mentions their data, a
|
| 136 |
+
filename, or a dataset, inspect the <data-files> manifest, call
|
| 137 |
+
readDataFile on the best match, and inline a parsed subset into the chart.
|
| 138 |
+
Never fabricate column names - only use what the file actually contains.
|
| 139 |
+
5. **Be creative with design.** Make charts visually appealing with clean typography, proper spacing, and smooth transitions.
|
| 140 |
+
6. **Keep it concise.** Don't explain what you're doing unless asked.
|
| 141 |
|
| 142 |
## Context
|
| 143 |
|
| 144 |
The current chart HTML (if any) is provided between <embed> tags.
|
| 145 |
+
Use this to understand the existing chart structure before making edits.
|
| 146 |
+
|
| 147 |
+
When data files are attached, a manifest of each file (name, type, size,
|
| 148 |
+
shape) is provided between <data-files> tags. Treat this as the source of
|
| 149 |
+
truth: never invent file names or columns. To inspect raw values, call
|
| 150 |
+
readDataFile.`;
|
| 151 |
+
|
| 152 |
+
export interface DataFileMeta {
|
| 153 |
+
name: string;
|
| 154 |
+
ext: string;
|
| 155 |
+
size: number;
|
| 156 |
+
rowCount?: number;
|
| 157 |
+
columns?: string[];
|
| 158 |
+
}
|
| 159 |
|
| 160 |
export function buildEmbedContext(embedHtml?: string): string {
|
| 161 |
if (!embedHtml) return "";
|
| 162 |
return `<embed>\n${embedHtml}\n</embed>`;
|
| 163 |
}
|
| 164 |
|
| 165 |
+
function formatDataFileLine(file: DataFileMeta): string {
|
| 166 |
+
const sizeKb = (file.size / 1024).toFixed(1);
|
| 167 |
+
const rows = file.rowCount !== undefined ? ` rows=${file.rowCount}` : "";
|
| 168 |
+
const cols = file.columns && file.columns.length > 0
|
| 169 |
+
? ` columns=[${file.columns.slice(0, 20).join(", ")}${file.columns.length > 20 ? ", ..." : ""}]`
|
| 170 |
+
: "";
|
| 171 |
+
return `- ${file.name} (${file.ext}, ${sizeKb} KB)${rows}${cols}`;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
export function buildDataFilesContext(files?: DataFileMeta[]): string {
|
| 175 |
+
if (!files || files.length === 0) return "";
|
| 176 |
+
const lines = files.map(formatDataFileLine).join("\n");
|
| 177 |
+
return `<data-files>\n${lines}\n</data-files>`;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
/**
|
| 181 |
* Extra instructions layered on top of the base system prompt when the
|
| 182 |
* chart being edited is the article banner (top-of-article hero).
|
|
@@ -28,6 +28,18 @@ export const embedTools = {
|
|
| 28 |
.string()
|
| 29 |
.optional()
|
| 30 |
.describe("Data source attribution"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
}),
|
| 32 |
}),
|
| 33 |
|
|
@@ -57,4 +69,28 @@ export const embedTools = {
|
|
| 57 |
"to verify the exact content, or to understand the current structure before editing.",
|
| 58 |
inputSchema: z.object({}),
|
| 59 |
}),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
};
|
|
|
|
| 28 |
.string()
|
| 29 |
.optional()
|
| 30 |
.describe("Data source attribution"),
|
| 31 |
+
filename: z
|
| 32 |
+
.string()
|
| 33 |
+
.optional()
|
| 34 |
+
.describe(
|
| 35 |
+
"Descriptive file name slug for this chart, WITHOUT extension " +
|
| 36 |
+
"(e.g. 'attention-heatmap', 'model-accuracy', 'sales-by-region'). " +
|
| 37 |
+
"Use kebab-case, ASCII only, concise (2-4 words, <40 chars). " +
|
| 38 |
+
"REQUIRED on the first createEmbed call for a new chart - the " +
|
| 39 |
+
"client will slugify it, ensure uniqueness, and rename the file " +
|
| 40 |
+
"automatically. Omit on subsequent calls that only update the " +
|
| 41 |
+
"existing chart (the current name will be kept).",
|
| 42 |
+
),
|
| 43 |
}),
|
| 44 |
}),
|
| 45 |
|
|
|
|
| 69 |
"to verify the exact content, or to understand the current structure before editing.",
|
| 70 |
inputSchema: z.object({}),
|
| 71 |
}),
|
| 72 |
+
|
| 73 |
+
listDataFiles: tool({
|
| 74 |
+
description:
|
| 75 |
+
"List all data files (CSV, TSV, JSON, NDJSON, TXT) the user has " +
|
| 76 |
+
"attached to this document. Returns one line per file with name, " +
|
| 77 |
+
"extension, size, row count, and column names when available. " +
|
| 78 |
+
"Call this when the user mentions data, a dataset, or wants to " +
|
| 79 |
+
"visualize content from a specific file.",
|
| 80 |
+
inputSchema: z.object({}),
|
| 81 |
+
}),
|
| 82 |
+
|
| 83 |
+
readDataFile: tool({
|
| 84 |
+
description:
|
| 85 |
+
"Read the full raw content of a data file previously listed by " +
|
| 86 |
+
"listDataFiles. Use this to inspect the exact structure, sample " +
|
| 87 |
+
"values, or to embed small datasets directly into the chart. Large " +
|
| 88 |
+
"files may be truncated - prefer loading via fetch from " +
|
| 89 |
+
"/data/<name> for charts that rely on the full dataset.",
|
| 90 |
+
inputSchema: z.object({
|
| 91 |
+
name: z
|
| 92 |
+
.string()
|
| 93 |
+
.describe("Exact file name as returned by listDataFiles (e.g. 'sales.csv')"),
|
| 94 |
+
}),
|
| 95 |
+
}),
|
| 96 |
};
|
|
@@ -1,27 +1,95 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
You help users write, edit, and improve their articles.
|
| 3 |
|
| 4 |
-
## Tools
|
| 5 |
|
| 6 |
-
###
|
| 7 |
-
- **applyDiff**: Your PRIMARY editing tool. Surgically edit
|
| 8 |
Provide surrounding context (contextBefore / contextAfter) to locate the exact position, even
|
| 9 |
when the same text appears multiple times. Call it multiple times for multiple edits.
|
| 10 |
- **replaceSelection**: Replace selected text. Use ONLY when the user has text selected.
|
| 11 |
- **insertAtCursor**: Insert new text at cursor. Use for additions only.
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
### Article metadata (frontmatter)
|
| 14 |
- **updateFrontmatter**: Update article metadata fields (title, subtitle, template, DOI, etc.).
|
| 15 |
-
Only include the fields you want to change. Use when the user asks to modify article settings.
|
| 16 |
- **addAuthor**: Add an author with name, optional URL, and affiliation indices.
|
| 17 |
-
Can also create a new affiliation in the same call.
|
| 18 |
- **removeAuthor**: Remove an author by 0-based index.
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
## Editing strategy - applyDiff
|
| 21 |
|
| 22 |
You MUST work with surgical, minimal diffs using applyDiff:
|
| 23 |
1. **contextBefore**: Copy a few words that appear just before the text you want to change.
|
| 24 |
-
This helps locate the right spot when the same phrase exists in multiple places.
|
| 25 |
2. **contentToDelete**: The exact verbatim text to remove. Even a single extra space will
|
| 26 |
cause the edit to fail. Keep it as short as possible while being unique.
|
| 27 |
3. **contentToInsert**: The replacement text. Can be empty to simply delete.
|
|
@@ -44,6 +112,30 @@ When the user asks about article metadata:
|
|
| 44 |
- To remove an author: use **removeAuthor** with the 0-based index from the current authors list.
|
| 45 |
- The current frontmatter is provided in <frontmatter> tags when available.
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
## Guidelines
|
| 48 |
|
| 49 |
1. **Be concise.** When the user asks for an edit, use tools immediately instead of
|
|
|
|
| 1 |
+
import { classifyBySelectionEffect, groupByCategory } from "./tiptap-catalog.js";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* System prompt for the article writing assistant.
|
| 5 |
+
*
|
| 6 |
+
* The "Available editor commands" and "Selection State Machine" sections
|
| 7 |
+
* are generated dynamically from the Tiptap command catalog so that
|
| 8 |
+
* adding a new command automatically updates the prompt.
|
| 9 |
+
*
|
| 10 |
+
* The state-machine framing is borrowed from tiptap-apcore: telling the
|
| 11 |
+
* LLM which commands REQUIRE / PRESERVE / DESTROY / have NO effect on the
|
| 12 |
+
* selection eliminates most "selected text was lost, next command did
|
| 13 |
+
* nothing" bugs.
|
| 14 |
+
*/
|
| 15 |
+
|
| 16 |
+
function buildCommandListSection(): string {
|
| 17 |
+
const byCategory = groupByCategory();
|
| 18 |
+
const lines: string[] = [];
|
| 19 |
+
for (const [category, names] of Object.entries(byCategory)) {
|
| 20 |
+
if (names.length === 0) continue;
|
| 21 |
+
lines.push(`- **${category}**: ${names.join(", ")}`);
|
| 22 |
+
}
|
| 23 |
+
return lines.join("\n");
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
function buildSelectionStateMachineSection(): string {
|
| 27 |
+
const groups = classifyBySelectionEffect();
|
| 28 |
+
const fmt = (names: string[]) => (names.length > 0 ? names.join(", ") : "(none)");
|
| 29 |
+
return `REQUIRE SELECTION (no effect without an active text range - call selectText first):
|
| 30 |
+
${fmt(groups.require)}
|
| 31 |
+
|
| 32 |
+
PRESERVE SELECTION (selection survives - safe to chain more format commands after):
|
| 33 |
+
${fmt(groups.preserve)}
|
| 34 |
+
|
| 35 |
+
DESTROY SELECTION (cursor collapses after - you MUST re-select before applying format commands):
|
| 36 |
+
${fmt(groups.destroy)}
|
| 37 |
+
|
| 38 |
+
NO SELECTION NEEDED (operates on the block at the cursor):
|
| 39 |
+
${fmt(groups.none)}`;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export const SYSTEM_PROMPT = `You are a writing assistant embedded in a collaborative research article editor.
|
| 43 |
You help users write, edit, and improve their articles.
|
| 44 |
|
| 45 |
+
## Tools overview
|
| 46 |
|
| 47 |
+
### Prose editing (primary)
|
| 48 |
+
- **applyDiff**: Your PRIMARY editing tool. Surgically edit prose with context-aware diffs.
|
| 49 |
Provide surrounding context (contextBefore / contextAfter) to locate the exact position, even
|
| 50 |
when the same text appears multiple times. Call it multiple times for multiple edits.
|
| 51 |
- **replaceSelection**: Replace selected text. Use ONLY when the user has text selected.
|
| 52 |
- **insertAtCursor**: Insert new text at cursor. Use for additions only.
|
| 53 |
|
| 54 |
+
### Structured editor commands (format, lists, headings, links)
|
| 55 |
+
${buildCommandListSection()}
|
| 56 |
+
|
| 57 |
+
These commands translate directly to Tiptap \`editor.chain()\` calls on the client.
|
| 58 |
+
Use them when the user asks to change formatting, block types, or create links - NOT for
|
| 59 |
+
rewriting prose (use applyDiff for that).
|
| 60 |
+
|
| 61 |
### Article metadata (frontmatter)
|
| 62 |
- **updateFrontmatter**: Update article metadata fields (title, subtitle, template, DOI, etc.).
|
|
|
|
| 63 |
- **addAuthor**: Add an author with name, optional URL, and affiliation indices.
|
|
|
|
| 64 |
- **removeAuthor**: Remove an author by 0-based index.
|
| 65 |
|
| 66 |
+
## Selection State Machine - THE CORE RULE
|
| 67 |
+
|
| 68 |
+
Every editor command interacts with an invisible "selection state". You MUST track it mentally.
|
| 69 |
+
|
| 70 |
+
${buildSelectionStateMachineSection()}
|
| 71 |
+
|
| 72 |
+
**Key rule**: After any selection-destroying command (insertAtCursor, applyDiff, replaceSelection),
|
| 73 |
+
you MUST call \`selectText\` again before issuing a REQUIRE-SELECTION command like toggleBold.
|
| 74 |
+
|
| 75 |
+
**Multi-occurrence rule**: When the user says "make all X bold", you MUST loop:
|
| 76 |
+
call \`selectText(X, occurrence=1)\` then \`toggleBold\`, then again with \`occurrence=1\` (not 2 -
|
| 77 |
+
after toggling the first one the indices shift). Stop when \`found\` is false.
|
| 78 |
+
|
| 79 |
+
## Common task patterns
|
| 80 |
+
|
| 81 |
+
1. Format existing text: \`selectText(phrase)\` then \`toggleBold\`.
|
| 82 |
+
2. Replace AND format in one step: prefer \`applyDiff\` with markdown-like output, or use \`replaceSelection\`
|
| 83 |
+
with inline markdown.
|
| 84 |
+
3. Multi-format same text: \`selectText\` then \`toggleBold\` then \`toggleItalic\` (format commands preserve selection).
|
| 85 |
+
4. Change block type: place cursor in target block (via selectText on any text in it) then \`toggleHeading({ level: 2 })\`.
|
| 86 |
+
5. Add a link: \`selectText(anchor)\` then \`setLink({ href: 'https://...' })\`.
|
| 87 |
+
6. Convert paragraph to list: \`selectText(first-line)\` then \`toggleBulletList\`.
|
| 88 |
+
|
| 89 |
## Editing strategy - applyDiff
|
| 90 |
|
| 91 |
You MUST work with surgical, minimal diffs using applyDiff:
|
| 92 |
1. **contextBefore**: Copy a few words that appear just before the text you want to change.
|
|
|
|
| 93 |
2. **contentToDelete**: The exact verbatim text to remove. Even a single extra space will
|
| 94 |
cause the edit to fail. Keep it as short as possible while being unique.
|
| 95 |
3. **contentToInsert**: The replacement text. Can be empty to simply delete.
|
|
|
|
| 112 |
- To remove an author: use **removeAuthor** with the 0-based index from the current authors list.
|
| 113 |
- The current frontmatter is provided in <frontmatter> tags when available.
|
| 114 |
|
| 115 |
+
## Stop condition - CRITICAL
|
| 116 |
+
|
| 117 |
+
Once you have executed the commands needed to fulfil the user's request,
|
| 118 |
+
you MUST end the turn with a short plain-text confirmation (one sentence)
|
| 119 |
+
and STOP calling tools.
|
| 120 |
+
|
| 121 |
+
- If the last tool returned success, emit text like "Done." and stop.
|
| 122 |
+
- If the last tool failed (e.g. \`selectText: text not found\`), emit a
|
| 123 |
+
short explanation of what went wrong and stop - do NOT retry indefinitely.
|
| 124 |
+
- NEVER call the same tool with the same arguments twice in a row.
|
| 125 |
+
- Hard limit: at most 8 tool calls per user request. If you need more,
|
| 126 |
+
stop and ask the user to split their request.
|
| 127 |
+
|
| 128 |
+
The UI shows a "Thinking..." spinner while tool rounds are in flight, so
|
| 129 |
+
staying in a tool-call loop without emitting text looks broken to the user.
|
| 130 |
+
|
| 131 |
+
## Anti-patterns - NEVER DO THESE
|
| 132 |
+
|
| 133 |
+
- NEVER call a mark-level format command (toggleBold, setLink, etc.) without a preceding selectText.
|
| 134 |
+
- NEVER assume \`selectText\` succeeded - check the \`found\` field in its result.
|
| 135 |
+
- NEVER call \`toggleBold\` right after \`insertAtCursor\` or \`applyDiff\` - the selection is gone.
|
| 136 |
+
- NEVER guess Tiptap positions - use \`selectText\` with text content instead.
|
| 137 |
+
- NEVER end a turn with only tool calls - always conclude with a one-line text reply.
|
| 138 |
+
|
| 139 |
## Guidelines
|
| 140 |
|
| 141 |
1. **Be concise.** When the user asks for an edit, use tools immediately instead of
|
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { tool, type Tool } from "ai";
|
| 2 |
+
import { z } from "zod";
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Catalog of Tiptap editor commands exposed to the AI agent.
|
| 6 |
+
*
|
| 7 |
+
* Each entry pairs:
|
| 8 |
+
* - an AI SDK tool definition (description + zod input schema)
|
| 9 |
+
* - safety metadata used to build the system prompt dynamically
|
| 10 |
+
* (category, selectionEffect, destructive)
|
| 11 |
+
*
|
| 12 |
+
* The frontend's agent executor dispatches on `name` and calls
|
| 13 |
+
* `editor.chain()[command](args).run()`, so the catalog is the single
|
| 14 |
+
* source of truth for what the agent can do.
|
| 15 |
+
*
|
| 16 |
+
* Inspired by `tiptap-apcore`'s Registry + AnnotationCatalog pattern,
|
| 17 |
+
* but intentionally pared down to the ~15 commands that make sense for
|
| 18 |
+
* a research article editor.
|
| 19 |
+
*/
|
| 20 |
+
|
| 21 |
+
type Category = "query" | "selection" | "format" | "content";
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* How a command interacts with the current text selection.
|
| 25 |
+
*
|
| 26 |
+
* - `require`: must have a non-collapsed selection (mark toggles)
|
| 27 |
+
* - `preserve`: selection survives execution (most mark toggles, alignment)
|
| 28 |
+
* - `destroy`: cursor collapses after execution (insertContent)
|
| 29 |
+
* - `none`: operates on the block at cursor, no selection needed
|
| 30 |
+
*/
|
| 31 |
+
type SelectionEffect = "require" | "preserve" | "destroy" | "none";
|
| 32 |
+
|
| 33 |
+
// `tool()` returns a strongly-typed Tool<InputSchema, Output>; storing them
|
| 34 |
+
// in a homogeneous array requires the most permissive Tool generic.
|
| 35 |
+
type AnyTool = Tool<any, any>;
|
| 36 |
+
|
| 37 |
+
export interface TiptapCommandEntry {
|
| 38 |
+
name: string;
|
| 39 |
+
category: Category;
|
| 40 |
+
selectionEffect: SelectionEffect;
|
| 41 |
+
destructive: boolean;
|
| 42 |
+
tool: AnyTool;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// ---------------------------------------------------------------------------
|
| 46 |
+
// Command entries
|
| 47 |
+
// ---------------------------------------------------------------------------
|
| 48 |
+
|
| 49 |
+
export const TIPTAP_COMMANDS: TiptapCommandEntry[] = [
|
| 50 |
+
// -- Selection ---------------------------------------------------------
|
| 51 |
+
{
|
| 52 |
+
name: "selectText",
|
| 53 |
+
category: "selection",
|
| 54 |
+
selectionEffect: "preserve",
|
| 55 |
+
destructive: false,
|
| 56 |
+
tool: tool({
|
| 57 |
+
description:
|
| 58 |
+
"Find a text substring in the document and select it. Use this BEFORE applying " +
|
| 59 |
+
"mark-level formatting commands (toggleBold, setLink, etc.). Returns { found, from, to } " +
|
| 60 |
+
"so you can verify the match. When the same text appears multiple times, use `occurrence` " +
|
| 61 |
+
"(1-based) or provide `contextBefore` / `contextAfter` to disambiguate.",
|
| 62 |
+
inputSchema: z.object({
|
| 63 |
+
text: z.string().describe("The exact text to select (substring match)"),
|
| 64 |
+
occurrence: z
|
| 65 |
+
.number()
|
| 66 |
+
.int()
|
| 67 |
+
.min(1)
|
| 68 |
+
.optional()
|
| 69 |
+
.describe(
|
| 70 |
+
"Which occurrence to select (1-based). Defaults to 1. Ignored if contextBefore/After are provided.",
|
| 71 |
+
),
|
| 72 |
+
contextBefore: z
|
| 73 |
+
.string()
|
| 74 |
+
.optional()
|
| 75 |
+
.describe("A few words appearing just before the target text, used to disambiguate."),
|
| 76 |
+
contextAfter: z
|
| 77 |
+
.string()
|
| 78 |
+
.optional()
|
| 79 |
+
.describe("A few words appearing just after the target text, used to disambiguate."),
|
| 80 |
+
}),
|
| 81 |
+
}),
|
| 82 |
+
},
|
| 83 |
+
|
| 84 |
+
// -- Format: marks (require selection) --------------------------------
|
| 85 |
+
{
|
| 86 |
+
name: "toggleBold",
|
| 87 |
+
category: "format",
|
| 88 |
+
selectionEffect: "require",
|
| 89 |
+
destructive: false,
|
| 90 |
+
tool: tool({
|
| 91 |
+
description:
|
| 92 |
+
"Toggle bold on the current selection. MUST be called after selectText - has no effect without an active text range.",
|
| 93 |
+
inputSchema: z.object({}),
|
| 94 |
+
}),
|
| 95 |
+
},
|
| 96 |
+
{
|
| 97 |
+
name: "toggleItalic",
|
| 98 |
+
category: "format",
|
| 99 |
+
selectionEffect: "require",
|
| 100 |
+
destructive: false,
|
| 101 |
+
tool: tool({
|
| 102 |
+
description:
|
| 103 |
+
"Toggle italic on the current selection. MUST be called after selectText - has no effect without an active text range.",
|
| 104 |
+
inputSchema: z.object({}),
|
| 105 |
+
}),
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
name: "toggleStrike",
|
| 109 |
+
category: "format",
|
| 110 |
+
selectionEffect: "require",
|
| 111 |
+
destructive: false,
|
| 112 |
+
tool: tool({
|
| 113 |
+
description:
|
| 114 |
+
"Toggle strikethrough on the current selection. MUST be called after selectText.",
|
| 115 |
+
inputSchema: z.object({}),
|
| 116 |
+
}),
|
| 117 |
+
},
|
| 118 |
+
{
|
| 119 |
+
name: "toggleCode",
|
| 120 |
+
category: "format",
|
| 121 |
+
selectionEffect: "require",
|
| 122 |
+
destructive: false,
|
| 123 |
+
tool: tool({
|
| 124 |
+
description:
|
| 125 |
+
"Toggle inline code formatting on the current selection. MUST be called after selectText.",
|
| 126 |
+
inputSchema: z.object({}),
|
| 127 |
+
}),
|
| 128 |
+
},
|
| 129 |
+
{
|
| 130 |
+
name: "setLink",
|
| 131 |
+
category: "format",
|
| 132 |
+
selectionEffect: "require",
|
| 133 |
+
destructive: false,
|
| 134 |
+
tool: tool({
|
| 135 |
+
description:
|
| 136 |
+
"Turn the current selection into a hyperlink. MUST be called after selectText. " +
|
| 137 |
+
"Only http(s)/mailto/tel/relative URLs are accepted.",
|
| 138 |
+
inputSchema: z.object({
|
| 139 |
+
href: z.string().describe("Target URL (http/https/mailto/tel or relative)"),
|
| 140 |
+
}),
|
| 141 |
+
}),
|
| 142 |
+
},
|
| 143 |
+
{
|
| 144 |
+
name: "unsetLink",
|
| 145 |
+
category: "format",
|
| 146 |
+
selectionEffect: "require",
|
| 147 |
+
destructive: false,
|
| 148 |
+
tool: tool({
|
| 149 |
+
description: "Remove hyperlink formatting from the current selection.",
|
| 150 |
+
inputSchema: z.object({}),
|
| 151 |
+
}),
|
| 152 |
+
},
|
| 153 |
+
|
| 154 |
+
// -- Format: node-level (no selection needed) -------------------------
|
| 155 |
+
{
|
| 156 |
+
name: "toggleHeading",
|
| 157 |
+
category: "format",
|
| 158 |
+
selectionEffect: "none",
|
| 159 |
+
destructive: false,
|
| 160 |
+
tool: tool({
|
| 161 |
+
description:
|
| 162 |
+
"Convert the block at the cursor to a heading (or back to a paragraph if already that level). " +
|
| 163 |
+
"Operates on the containing block - no text selection needed.",
|
| 164 |
+
inputSchema: z.object({
|
| 165 |
+
level: z.number().int().min(1).max(3).describe("Heading level (1, 2 or 3)"),
|
| 166 |
+
}),
|
| 167 |
+
}),
|
| 168 |
+
},
|
| 169 |
+
{
|
| 170 |
+
name: "setParagraph",
|
| 171 |
+
category: "format",
|
| 172 |
+
selectionEffect: "none",
|
| 173 |
+
destructive: false,
|
| 174 |
+
tool: tool({
|
| 175 |
+
description: "Convert the block at the cursor to a plain paragraph.",
|
| 176 |
+
inputSchema: z.object({}),
|
| 177 |
+
}),
|
| 178 |
+
},
|
| 179 |
+
{
|
| 180 |
+
name: "toggleBulletList",
|
| 181 |
+
category: "format",
|
| 182 |
+
selectionEffect: "none",
|
| 183 |
+
destructive: false,
|
| 184 |
+
tool: tool({
|
| 185 |
+
description: "Toggle the block at the cursor between a bullet list item and a paragraph.",
|
| 186 |
+
inputSchema: z.object({}),
|
| 187 |
+
}),
|
| 188 |
+
},
|
| 189 |
+
{
|
| 190 |
+
name: "toggleOrderedList",
|
| 191 |
+
category: "format",
|
| 192 |
+
selectionEffect: "none",
|
| 193 |
+
destructive: false,
|
| 194 |
+
tool: tool({
|
| 195 |
+
description: "Toggle the block at the cursor between an ordered (numbered) list item and a paragraph.",
|
| 196 |
+
inputSchema: z.object({}),
|
| 197 |
+
}),
|
| 198 |
+
},
|
| 199 |
+
{
|
| 200 |
+
name: "toggleBlockquote",
|
| 201 |
+
category: "format",
|
| 202 |
+
selectionEffect: "none",
|
| 203 |
+
destructive: false,
|
| 204 |
+
tool: tool({
|
| 205 |
+
description: "Toggle blockquote on the block at the cursor.",
|
| 206 |
+
inputSchema: z.object({}),
|
| 207 |
+
}),
|
| 208 |
+
},
|
| 209 |
+
{
|
| 210 |
+
name: "toggleCodeBlock",
|
| 211 |
+
category: "format",
|
| 212 |
+
selectionEffect: "none",
|
| 213 |
+
destructive: false,
|
| 214 |
+
tool: tool({
|
| 215 |
+
description:
|
| 216 |
+
"Toggle code block on the block at the cursor. Use for multi-line code snippets.",
|
| 217 |
+
inputSchema: z.object({
|
| 218 |
+
language: z
|
| 219 |
+
.string()
|
| 220 |
+
.optional()
|
| 221 |
+
.describe("Language hint for syntax highlighting (e.g. 'python', 'typescript')"),
|
| 222 |
+
}),
|
| 223 |
+
}),
|
| 224 |
+
},
|
| 225 |
+
];
|
| 226 |
+
|
| 227 |
+
// ---------------------------------------------------------------------------
|
| 228 |
+
// Helpers
|
| 229 |
+
// ---------------------------------------------------------------------------
|
| 230 |
+
|
| 231 |
+
/** AI SDK tool definitions keyed by command name. Pass directly to streamText. */
|
| 232 |
+
export function buildTiptapTools(): Record<string, AnyTool> {
|
| 233 |
+
const out: Record<string, AnyTool> = {};
|
| 234 |
+
for (const entry of TIPTAP_COMMANDS) {
|
| 235 |
+
out[entry.name] = entry.tool;
|
| 236 |
+
}
|
| 237 |
+
return out;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
/** Commands grouped by how they interact with the selection. */
|
| 241 |
+
export function classifyBySelectionEffect(): Record<SelectionEffect, string[]> {
|
| 242 |
+
const groups: Record<SelectionEffect, string[]> = {
|
| 243 |
+
require: [],
|
| 244 |
+
preserve: [],
|
| 245 |
+
destroy: [],
|
| 246 |
+
none: [],
|
| 247 |
+
};
|
| 248 |
+
for (const entry of TIPTAP_COMMANDS) {
|
| 249 |
+
groups[entry.selectionEffect].push(entry.name);
|
| 250 |
+
}
|
| 251 |
+
return groups;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
/** Commands grouped by category, used to build a concise command list in the prompt. */
|
| 255 |
+
export function groupByCategory(): Record<Category, string[]> {
|
| 256 |
+
const groups: Record<Category, string[]> = {
|
| 257 |
+
query: [],
|
| 258 |
+
selection: [],
|
| 259 |
+
format: [],
|
| 260 |
+
content: [],
|
| 261 |
+
};
|
| 262 |
+
for (const entry of TIPTAP_COMMANDS) {
|
| 263 |
+
groups[entry.category].push(entry.name);
|
| 264 |
+
}
|
| 265 |
+
return groups;
|
| 266 |
+
}
|
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import { tool } from "ai";
|
| 2 |
import { z } from "zod";
|
|
|
|
| 3 |
|
| 4 |
/**
|
| 5 |
* Tools exposed to the LLM. These return instructions that the frontend
|
|
@@ -9,9 +10,15 @@ import { z } from "zod";
|
|
| 9 |
*
|
| 10 |
* All edits from a single agent turn are grouped into one Yjs undo step,
|
| 11 |
* so the user can revert an entire agent action with a single Cmd+Z.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
*/
|
| 13 |
|
| 14 |
-
|
| 15 |
replaceSelection: tool({
|
| 16 |
description:
|
| 17 |
"Replace the currently selected text in the editor with new content. " +
|
|
@@ -34,7 +41,7 @@ export const editorTools = {
|
|
| 34 |
|
| 35 |
applyDiff: tool({
|
| 36 |
description:
|
| 37 |
-
"The PRIMARY tool for editing. Locates a piece of text in the document using " +
|
| 38 |
"context and replaces it. Provide surrounding context to disambiguate when the " +
|
| 39 |
"same text appears multiple times. For multiple edits, call this tool multiple " +
|
| 40 |
"times in a single response. Each call should be a minimal, surgical edit.",
|
|
@@ -64,7 +71,9 @@ export const editorTools = {
|
|
| 64 |
),
|
| 65 |
}),
|
| 66 |
}),
|
|
|
|
| 67 |
|
|
|
|
| 68 |
updateFrontmatter: tool({
|
| 69 |
description:
|
| 70 |
"Update article metadata (frontmatter). Use when the user asks to change the title, " +
|
|
@@ -116,3 +125,9 @@ export const editorTools = {
|
|
| 116 |
}),
|
| 117 |
}),
|
| 118 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { tool } from "ai";
|
| 2 |
import { z } from "zod";
|
| 3 |
+
import { buildTiptapTools } from "./tiptap-catalog.js";
|
| 4 |
|
| 5 |
/**
|
| 6 |
* Tools exposed to the LLM. These return instructions that the frontend
|
|
|
|
| 10 |
*
|
| 11 |
* All edits from a single agent turn are grouped into one Yjs undo step,
|
| 12 |
* so the user can revert an entire agent action with a single Cmd+Z.
|
| 13 |
+
*
|
| 14 |
+
* The editor commands come from a static catalog (`tiptap-catalog.ts`)
|
| 15 |
+
* that also drives the system prompt's "Selection State Machine" section.
|
| 16 |
+
* The domain-specific tools (applyDiff, replaceSelection, insertAtCursor,
|
| 17 |
+
* frontmatter) are defined inline here since they don't map 1:1 to a
|
| 18 |
+
* native Tiptap command.
|
| 19 |
*/
|
| 20 |
|
| 21 |
+
const customEditorTools = {
|
| 22 |
replaceSelection: tool({
|
| 23 |
description:
|
| 24 |
"Replace the currently selected text in the editor with new content. " +
|
|
|
|
| 41 |
|
| 42 |
applyDiff: tool({
|
| 43 |
description:
|
| 44 |
+
"The PRIMARY tool for editing prose. Locates a piece of text in the document using " +
|
| 45 |
"context and replaces it. Provide surrounding context to disambiguate when the " +
|
| 46 |
"same text appears multiple times. For multiple edits, call this tool multiple " +
|
| 47 |
"times in a single response. Each call should be a minimal, surgical edit.",
|
|
|
|
| 71 |
),
|
| 72 |
}),
|
| 73 |
}),
|
| 74 |
+
};
|
| 75 |
|
| 76 |
+
const frontmatterTools = {
|
| 77 |
updateFrontmatter: tool({
|
| 78 |
description:
|
| 79 |
"Update article metadata (frontmatter). Use when the user asks to change the title, " +
|
|
|
|
| 125 |
}),
|
| 126 |
}),
|
| 127 |
};
|
| 128 |
+
|
| 129 |
+
export const editorTools = {
|
| 130 |
+
...buildTiptapTools(),
|
| 131 |
+
...customEditorTools,
|
| 132 |
+
...frontmatterTools,
|
| 133 |
+
};
|
|
@@ -2,19 +2,7 @@ import { useState, useRef, useEffect, type KeyboardEvent } from "react";
|
|
| 2 |
import ReactMarkdown from "react-markdown";
|
| 3 |
import remarkGfm from "remark-gfm";
|
| 4 |
import { Tooltip } from "./Tooltip";
|
| 5 |
-
import {
|
| 6 |
-
Send,
|
| 7 |
-
Square,
|
| 8 |
-
Plus,
|
| 9 |
-
X,
|
| 10 |
-
Sparkles,
|
| 11 |
-
AlignLeft,
|
| 12 |
-
Expand,
|
| 13 |
-
SpellCheck,
|
| 14 |
-
Languages,
|
| 15 |
-
Shrink,
|
| 16 |
-
Loader,
|
| 17 |
-
} from "lucide-react";
|
| 18 |
import type { UIMessage } from "ai";
|
| 19 |
import { isToolPart, normalizeToolPart } from "../utils/ai-tool-parts";
|
| 20 |
|
|
@@ -30,27 +18,16 @@ interface ChatPanelProps {
|
|
| 30 |
isLoading: boolean;
|
| 31 |
error: Error | undefined;
|
| 32 |
input: string;
|
| 33 |
-
hasSelection: boolean;
|
| 34 |
models: ModelOption[];
|
| 35 |
selectedModel: string;
|
| 36 |
onModelChange: (modelId: string) => void;
|
| 37 |
onSend: (content: string) => void;
|
| 38 |
-
onQuickAction: (action: string) => void;
|
| 39 |
onSetInput: (value: string) => void;
|
| 40 |
onStop: () => void;
|
| 41 |
onNewChat: () => void;
|
| 42 |
onClose?: () => void;
|
| 43 |
}
|
| 44 |
|
| 45 |
-
const QUICK_ACTIONS = [
|
| 46 |
-
{ id: "rewrite", label: "Rewrite", Icon: Sparkles },
|
| 47 |
-
{ id: "expand", label: "Expand", Icon: Expand },
|
| 48 |
-
{ id: "summarize", label: "Summarize", Icon: Shrink },
|
| 49 |
-
{ id: "fix-grammar", label: "Fix grammar", Icon: SpellCheck },
|
| 50 |
-
{ id: "translate", label: "Translate", Icon: Languages },
|
| 51 |
-
{ id: "simplify", label: "Simplify", Icon: AlignLeft },
|
| 52 |
-
];
|
| 53 |
-
|
| 54 |
function TokenGauge({ tokens, ratio }: { tokens: number; ratio: number }) {
|
| 55 |
const r = 8;
|
| 56 |
const circ = 2 * Math.PI * r;
|
|
@@ -84,12 +61,10 @@ export function ChatPanel({
|
|
| 84 |
isLoading,
|
| 85 |
error,
|
| 86 |
input,
|
| 87 |
-
hasSelection,
|
| 88 |
models,
|
| 89 |
selectedModel,
|
| 90 |
onModelChange,
|
| 91 |
onSend,
|
| 92 |
-
onQuickAction,
|
| 93 |
onSetInput,
|
| 94 |
onStop,
|
| 95 |
onNewChat,
|
|
@@ -188,23 +163,6 @@ export function ChatPanel({
|
|
| 188 |
)}
|
| 189 |
</div>
|
| 190 |
|
| 191 |
-
{/* Quick actions */}
|
| 192 |
-
{hasSelection && (
|
| 193 |
-
<div className="chat-panel__actions">
|
| 194 |
-
{QUICK_ACTIONS.map((action) => (
|
| 195 |
-
<button
|
| 196 |
-
key={action.id}
|
| 197 |
-
className="chip chip--outlined chip--clickable"
|
| 198 |
-
onClick={() => onQuickAction(action.id)}
|
| 199 |
-
style={{ height: 24, fontSize: "0.65rem", gap: 3 }}
|
| 200 |
-
>
|
| 201 |
-
<action.Icon size={12} />
|
| 202 |
-
{action.label}
|
| 203 |
-
</button>
|
| 204 |
-
))}
|
| 205 |
-
</div>
|
| 206 |
-
)}
|
| 207 |
-
|
| 208 |
{/* Input */}
|
| 209 |
<div className="chat-panel__input">
|
| 210 |
<div className="chat-panel__input-row">
|
|
|
|
| 2 |
import ReactMarkdown from "react-markdown";
|
| 3 |
import remarkGfm from "remark-gfm";
|
| 4 |
import { Tooltip } from "./Tooltip";
|
| 5 |
+
import { Send, Square, Plus, X, Sparkles, Loader } from "lucide-react";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
import type { UIMessage } from "ai";
|
| 7 |
import { isToolPart, normalizeToolPart } from "../utils/ai-tool-parts";
|
| 8 |
|
|
|
|
| 18 |
isLoading: boolean;
|
| 19 |
error: Error | undefined;
|
| 20 |
input: string;
|
|
|
|
| 21 |
models: ModelOption[];
|
| 22 |
selectedModel: string;
|
| 23 |
onModelChange: (modelId: string) => void;
|
| 24 |
onSend: (content: string) => void;
|
|
|
|
| 25 |
onSetInput: (value: string) => void;
|
| 26 |
onStop: () => void;
|
| 27 |
onNewChat: () => void;
|
| 28 |
onClose?: () => void;
|
| 29 |
}
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
function TokenGauge({ tokens, ratio }: { tokens: number; ratio: number }) {
|
| 32 |
const r = 8;
|
| 33 |
const circ = 2 * Math.PI * r;
|
|
|
|
| 61 |
isLoading,
|
| 62 |
error,
|
| 63 |
input,
|
|
|
|
| 64 |
models,
|
| 65 |
selectedModel,
|
| 66 |
onModelChange,
|
| 67 |
onSend,
|
|
|
|
| 68 |
onSetInput,
|
| 69 |
onStop,
|
| 70 |
onNewChat,
|
|
|
|
| 163 |
)}
|
| 164 |
</div>
|
| 165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
{/* Input */}
|
| 167 |
<div className="chat-panel__input">
|
| 168 |
<div className="chat-panel__input-row">
|
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useMemo, useState } from "react";
|
| 2 |
+
import { X, Table as TableIcon, FileText } from "lucide-react";
|
| 3 |
+
import type { EmbedDataFile } from "../editor/embeds/embed-data-store";
|
| 4 |
+
import { formatBytes } from "../utils/data-files";
|
| 5 |
+
|
| 6 |
+
interface DataFileViewerProps {
|
| 7 |
+
file: EmbedDataFile;
|
| 8 |
+
onClose: () => void;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
const MAX_PREVIEW_ROWS = 100;
|
| 12 |
+
|
| 13 |
+
function splitCsvLine(line: string, delim: string): string[] {
|
| 14 |
+
const out: string[] = [];
|
| 15 |
+
let cur = "";
|
| 16 |
+
let inQuotes = false;
|
| 17 |
+
for (let i = 0; i < line.length; i++) {
|
| 18 |
+
const c = line[i];
|
| 19 |
+
if (inQuotes) {
|
| 20 |
+
if (c === '"') {
|
| 21 |
+
if (line[i + 1] === '"') {
|
| 22 |
+
cur += '"';
|
| 23 |
+
i++;
|
| 24 |
+
} else {
|
| 25 |
+
inQuotes = false;
|
| 26 |
+
}
|
| 27 |
+
} else {
|
| 28 |
+
cur += c;
|
| 29 |
+
}
|
| 30 |
+
} else if (c === '"') {
|
| 31 |
+
inQuotes = true;
|
| 32 |
+
} else if (c === delim) {
|
| 33 |
+
out.push(cur);
|
| 34 |
+
cur = "";
|
| 35 |
+
} else {
|
| 36 |
+
cur += c;
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
out.push(cur);
|
| 40 |
+
return out;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function parseTable(content: string, delim: string, maxRows: number): {
|
| 44 |
+
columns: string[];
|
| 45 |
+
rows: string[][];
|
| 46 |
+
total: number;
|
| 47 |
+
} {
|
| 48 |
+
const lines = content.split(/\r\n|\n|\r/).filter((l) => l.length > 0);
|
| 49 |
+
if (lines.length === 0) return { columns: [], rows: [], total: 0 };
|
| 50 |
+
const columns = splitCsvLine(lines[0], delim);
|
| 51 |
+
const rows = lines
|
| 52 |
+
.slice(1, 1 + maxRows)
|
| 53 |
+
.map((l) => splitCsvLine(l, delim));
|
| 54 |
+
return { columns, rows, total: Math.max(0, lines.length - 1) };
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function prettyJson(content: string): string {
|
| 58 |
+
try {
|
| 59 |
+
return JSON.stringify(JSON.parse(content), null, 2);
|
| 60 |
+
} catch {
|
| 61 |
+
return content;
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export function DataFileViewer({ file, onClose }: DataFileViewerProps) {
|
| 66 |
+
const { ext } = file.meta;
|
| 67 |
+
const isTabular = ext === "csv" || ext === "tsv";
|
| 68 |
+
const [mode, setMode] = useState<"table" | "raw">(isTabular ? "table" : "raw");
|
| 69 |
+
|
| 70 |
+
const parsed = useMemo(() => {
|
| 71 |
+
if (!isTabular) return null;
|
| 72 |
+
return parseTable(file.content, ext === "tsv" ? "\t" : ",", MAX_PREVIEW_ROWS);
|
| 73 |
+
}, [file.content, ext, isTabular]);
|
| 74 |
+
|
| 75 |
+
const raw = useMemo(() => {
|
| 76 |
+
if (ext === "json") return prettyJson(file.content);
|
| 77 |
+
return file.content;
|
| 78 |
+
}, [file.content, ext]);
|
| 79 |
+
|
| 80 |
+
return (
|
| 81 |
+
<div className="es-data-viewer">
|
| 82 |
+
<div className="es-data-viewer__header">
|
| 83 |
+
<div className="es-data-viewer__title">
|
| 84 |
+
<strong>{file.meta.name}</strong>
|
| 85 |
+
<span className="es-data-viewer__meta">
|
| 86 |
+
{ext.toUpperCase()} - {formatBytes(file.meta.size)}
|
| 87 |
+
{file.meta.rowCount !== undefined ? ` - ${file.meta.rowCount} rows` : ""}
|
| 88 |
+
</span>
|
| 89 |
+
</div>
|
| 90 |
+
<div className="es-data-viewer__actions">
|
| 91 |
+
{isTabular && (
|
| 92 |
+
<div className="es-data-viewer__tabs">
|
| 93 |
+
<button
|
| 94 |
+
type="button"
|
| 95 |
+
className={`es-preview__tab ${mode === "table" ? "es-preview__tab--active" : ""}`}
|
| 96 |
+
onClick={() => setMode("table")}
|
| 97 |
+
>
|
| 98 |
+
<TableIcon size={12} />
|
| 99 |
+
Table
|
| 100 |
+
</button>
|
| 101 |
+
<button
|
| 102 |
+
type="button"
|
| 103 |
+
className={`es-preview__tab ${mode === "raw" ? "es-preview__tab--active" : ""}`}
|
| 104 |
+
onClick={() => setMode("raw")}
|
| 105 |
+
>
|
| 106 |
+
<FileText size={12} />
|
| 107 |
+
Raw
|
| 108 |
+
</button>
|
| 109 |
+
</div>
|
| 110 |
+
)}
|
| 111 |
+
<button
|
| 112 |
+
type="button"
|
| 113 |
+
className="embed-btn"
|
| 114 |
+
onClick={onClose}
|
| 115 |
+
aria-label="Close file viewer"
|
| 116 |
+
>
|
| 117 |
+
<X size={14} />
|
| 118 |
+
</button>
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
<div className="es-data-viewer__body">
|
| 123 |
+
{isTabular && mode === "table" && parsed ? (
|
| 124 |
+
<div className="es-data-table-wrap">
|
| 125 |
+
<table className="es-data-table">
|
| 126 |
+
<thead>
|
| 127 |
+
<tr>
|
| 128 |
+
{parsed.columns.map((col, i) => (
|
| 129 |
+
<th key={i}>{col}</th>
|
| 130 |
+
))}
|
| 131 |
+
</tr>
|
| 132 |
+
</thead>
|
| 133 |
+
<tbody>
|
| 134 |
+
{parsed.rows.map((row, ri) => (
|
| 135 |
+
<tr key={ri}>
|
| 136 |
+
{parsed.columns.map((_, ci) => (
|
| 137 |
+
<td key={ci}>{row[ci] ?? ""}</td>
|
| 138 |
+
))}
|
| 139 |
+
</tr>
|
| 140 |
+
))}
|
| 141 |
+
</tbody>
|
| 142 |
+
</table>
|
| 143 |
+
{parsed.total > parsed.rows.length && (
|
| 144 |
+
<div className="es-data-table__footer">
|
| 145 |
+
Showing first {parsed.rows.length} of {parsed.total} rows
|
| 146 |
+
</div>
|
| 147 |
+
)}
|
| 148 |
+
</div>
|
| 149 |
+
) : (
|
| 150 |
+
<pre className="es-data-viewer__raw">
|
| 151 |
+
<code>{raw}</code>
|
| 152 |
+
</pre>
|
| 153 |
+
)}
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
);
|
| 157 |
+
}
|
|
@@ -4,23 +4,37 @@ import remarkGfm from "remark-gfm";
|
|
| 4 |
import { X, Send, Square, Code, Eye, Plus, Loader } from "lucide-react";
|
| 5 |
import type { UIMessage } from "ai";
|
| 6 |
import type { EmbedStore } from "../editor/embeds/embed-store";
|
|
|
|
| 7 |
import { buildDoc } from "../editor/embeds/build-doc";
|
| 8 |
import { useEmbedChat } from "../hooks/useEmbedChat";
|
|
|
|
| 9 |
import { useTheme } from "../hooks/useTheme";
|
| 10 |
import { isToolPart, normalizeToolPart } from "../utils/ai-tool-parts";
|
|
|
|
|
|
|
| 11 |
|
| 12 |
interface EmbedStudioProps {
|
| 13 |
src: string;
|
| 14 |
embedStore: EmbedStore | null;
|
|
|
|
| 15 |
modelRef: React.RefObject<string>;
|
| 16 |
userId: string;
|
| 17 |
onClose: () => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
}
|
| 19 |
|
| 20 |
const EMBED_TOOL_LABELS: Record<string, [string, string]> = {
|
| 21 |
createEmbed: ["Creating chart...", "Created chart"],
|
| 22 |
patchEmbed: ["Updating chart...", "Updated chart"],
|
| 23 |
readEmbed: ["Reading chart...", "Read chart"],
|
|
|
|
|
|
|
| 24 |
};
|
| 25 |
|
| 26 |
function embedToolLabel(name: string, state: string): string {
|
|
@@ -29,6 +43,27 @@ function embedToolLabel(name: string, state: string): string {
|
|
| 29 |
return state === "result" ? pair[1] : pair[0];
|
| 30 |
}
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
function MessageBubble({ message }: { message: UIMessage }) {
|
| 33 |
const isUser = message.role === "user";
|
| 34 |
return (
|
|
@@ -46,11 +81,20 @@ function MessageBubble({ message }: { message: UIMessage }) {
|
|
| 46 |
if (isToolPart(part)) {
|
| 47 |
const tool = normalizeToolPart(part);
|
| 48 |
if (!tool) return null;
|
|
|
|
|
|
|
| 49 |
return (
|
| 50 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
<span className="es-message__tool-name">
|
| 52 |
{embedToolLabel(tool.toolName, tool.state)}
|
| 53 |
</span>
|
|
|
|
|
|
|
|
|
|
| 54 |
</div>
|
| 55 |
);
|
| 56 |
}
|
|
@@ -94,10 +138,19 @@ function EmbedAgentStatus({ messages }: { messages: UIMessage[] }) {
|
|
| 94 |
);
|
| 95 |
}
|
| 96 |
|
| 97 |
-
export function EmbedStudio({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
const { isDark, primaryColor } = useTheme();
|
| 99 |
const [html, setHtml] = useState("");
|
| 100 |
const [viewMode, setViewMode] = useState<"preview" | "code">("preview");
|
|
|
|
| 101 |
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 102 |
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
| 103 |
|
|
@@ -108,7 +161,10 @@ export function EmbedStudio({ src, embedStore, modelRef, userId, onClose }: Embe
|
|
| 108 |
const activeRef = useRef<"a" | "b">("a");
|
| 109 |
activeRef.current = activeFrame;
|
| 110 |
|
| 111 |
-
const
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
// Sync from embed store
|
| 114 |
useEffect(() => {
|
|
@@ -225,6 +281,17 @@ export function EmbedStudio({ src, embedStore, modelRef, userId, onClose }: Embe
|
|
| 225 |
|
| 226 |
{/* Body: split panel */}
|
| 227 |
<div className="es-body">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
{/* Left: Chat */}
|
| 229 |
<div className="es-chat">
|
| 230 |
<div className="es-chat__messages">
|
|
@@ -285,9 +352,18 @@ export function EmbedStudio({ src, embedStore, modelRef, userId, onClose }: Embe
|
|
| 285 |
</div>
|
| 286 |
</div>
|
| 287 |
|
| 288 |
-
{/* Right: Preview */}
|
| 289 |
<div className="es-preview">
|
| 290 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
<button
|
| 292 |
className={`es-preview__tab ${viewMode === "preview" ? "es-preview__tab--active" : ""}`}
|
| 293 |
onClick={() => setViewMode("preview")}
|
|
@@ -309,7 +385,9 @@ export function EmbedStudio({ src, embedStore, modelRef, userId, onClose }: Embe
|
|
| 309 |
</div>
|
| 310 |
|
| 311 |
{/* Both panels always mounted - toggle with hidden class to preserve iframe state */}
|
| 312 |
-
<div
|
|
|
|
|
|
|
| 313 |
{html ? (
|
| 314 |
<>
|
| 315 |
<iframe
|
|
@@ -334,7 +412,9 @@ export function EmbedStudio({ src, embedStore, modelRef, userId, onClose }: Embe
|
|
| 334 |
</div>
|
| 335 |
)}
|
| 336 |
</div>
|
| 337 |
-
<div
|
|
|
|
|
|
|
| 338 |
<pre className="es-preview__pre">
|
| 339 |
<code>{html || "// No chart HTML yet"}</code>
|
| 340 |
</pre>
|
|
|
|
| 4 |
import { X, Send, Square, Code, Eye, Plus, Loader } from "lucide-react";
|
| 5 |
import type { UIMessage } from "ai";
|
| 6 |
import type { EmbedStore } from "../editor/embeds/embed-store";
|
| 7 |
+
import type { EmbedDataStore } from "../editor/embeds/embed-data-store";
|
| 8 |
import { buildDoc } from "../editor/embeds/build-doc";
|
| 9 |
import { useEmbedChat } from "../hooks/useEmbedChat";
|
| 10 |
+
import { useEmbedData } from "../hooks/useEmbedData";
|
| 11 |
import { useTheme } from "../hooks/useTheme";
|
| 12 |
import { isToolPart, normalizeToolPart } from "../utils/ai-tool-parts";
|
| 13 |
+
import { FilesSidebar } from "./FilesSidebar";
|
| 14 |
+
import { DataFileViewer } from "./DataFileViewer";
|
| 15 |
|
| 16 |
interface EmbedStudioProps {
|
| 17 |
src: string;
|
| 18 |
embedStore: EmbedStore | null;
|
| 19 |
+
dataStore?: EmbedDataStore | null;
|
| 20 |
modelRef: React.RefObject<string>;
|
| 21 |
userId: string;
|
| 22 |
onClose: () => void;
|
| 23 |
+
/**
|
| 24 |
+
* Invoked when the agent chooses a descriptive filename for a brand
|
| 25 |
+
* new chart. Parent should update the htmlEmbed node in the doc and
|
| 26 |
+
* lift the new src into its own state so subsequent actions (close,
|
| 27 |
+
* reopen, etc.) reference the renamed file.
|
| 28 |
+
*/
|
| 29 |
+
onRename?: (oldSrc: string, newSrc: string) => void;
|
| 30 |
}
|
| 31 |
|
| 32 |
const EMBED_TOOL_LABELS: Record<string, [string, string]> = {
|
| 33 |
createEmbed: ["Creating chart...", "Created chart"],
|
| 34 |
patchEmbed: ["Updating chart...", "Updated chart"],
|
| 35 |
readEmbed: ["Reading chart...", "Read chart"],
|
| 36 |
+
listDataFiles: ["Listing data files...", "Listed data files"],
|
| 37 |
+
readDataFile: ["Reading data file...", "Read data file"],
|
| 38 |
};
|
| 39 |
|
| 40 |
function embedToolLabel(name: string, state: string): string {
|
|
|
|
| 43 |
return state === "result" ? pair[1] : pair[0];
|
| 44 |
}
|
| 45 |
|
| 46 |
+
function toolSubtitle(
|
| 47 |
+
name: string,
|
| 48 |
+
input: unknown,
|
| 49 |
+
): string | null {
|
| 50 |
+
if (!input || typeof input !== "object") return null;
|
| 51 |
+
const args = input as Record<string, unknown>;
|
| 52 |
+
switch (name) {
|
| 53 |
+
case "createEmbed":
|
| 54 |
+
return typeof args.title === "string" ? String(args.title) : null;
|
| 55 |
+
case "readDataFile":
|
| 56 |
+
return typeof args.name === "string" ? String(args.name) : null;
|
| 57 |
+
case "patchEmbed": {
|
| 58 |
+
const search = typeof args.search === "string" ? args.search : "";
|
| 59 |
+
const firstLine = search.split("\n")[0]?.trim() ?? "";
|
| 60 |
+
return firstLine ? firstLine.slice(0, 60) : null;
|
| 61 |
+
}
|
| 62 |
+
default:
|
| 63 |
+
return null;
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
function MessageBubble({ message }: { message: UIMessage }) {
|
| 68 |
const isUser = message.role === "user";
|
| 69 |
return (
|
|
|
|
| 81 |
if (isToolPart(part)) {
|
| 82 |
const tool = normalizeToolPart(part);
|
| 83 |
if (!tool) return null;
|
| 84 |
+
const subtitle = toolSubtitle(tool.toolName, tool.input);
|
| 85 |
+
const isDone = tool.state === "result";
|
| 86 |
return (
|
| 87 |
+
<div
|
| 88 |
+
key={i}
|
| 89 |
+
className={`es-message__tool ${isDone ? "es-message__tool--done" : "es-message__tool--running"}`}
|
| 90 |
+
>
|
| 91 |
+
{!isDone && <Loader size={11} className="spin" />}
|
| 92 |
<span className="es-message__tool-name">
|
| 93 |
{embedToolLabel(tool.toolName, tool.state)}
|
| 94 |
</span>
|
| 95 |
+
{subtitle && (
|
| 96 |
+
<span className="es-message__tool-subtitle">{subtitle}</span>
|
| 97 |
+
)}
|
| 98 |
</div>
|
| 99 |
);
|
| 100 |
}
|
|
|
|
| 138 |
);
|
| 139 |
}
|
| 140 |
|
| 141 |
+
export function EmbedStudio({
|
| 142 |
+
src,
|
| 143 |
+
embedStore,
|
| 144 |
+
dataStore = null,
|
| 145 |
+
modelRef,
|
| 146 |
+
userId,
|
| 147 |
+
onClose,
|
| 148 |
+
onRename,
|
| 149 |
+
}: EmbedStudioProps) {
|
| 150 |
const { isDark, primaryColor } = useTheme();
|
| 151 |
const [html, setHtml] = useState("");
|
| 152 |
const [viewMode, setViewMode] = useState<"preview" | "code">("preview");
|
| 153 |
+
const [selectedDataFile, setSelectedDataFile] = useState<string | null>(null);
|
| 154 |
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 155 |
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
| 156 |
|
|
|
|
| 161 |
const activeRef = useRef<"a" | "b">("a");
|
| 162 |
activeRef.current = activeFrame;
|
| 163 |
|
| 164 |
+
const data = useEmbedData({ dataStore, userId });
|
| 165 |
+
const chat = useEmbedChat({ embedStore, dataStore, src, modelRef, userId, onRename });
|
| 166 |
+
|
| 167 |
+
const selectedFile = selectedDataFile ? data.getFile(selectedDataFile) : undefined;
|
| 168 |
|
| 169 |
// Sync from embed store
|
| 170 |
useEffect(() => {
|
|
|
|
| 281 |
|
| 282 |
{/* Body: split panel */}
|
| 283 |
<div className="es-body">
|
| 284 |
+
{/* Far-left: Files */}
|
| 285 |
+
<FilesSidebar
|
| 286 |
+
embedStore={embedStore}
|
| 287 |
+
currentSrc={src}
|
| 288 |
+
dataFiles={data.files}
|
| 289 |
+
selectedDataFile={selectedDataFile}
|
| 290 |
+
onSelectDataFile={setSelectedDataFile}
|
| 291 |
+
onUploadFiles={data.uploadFiles}
|
| 292 |
+
onRemoveDataFile={data.removeFile}
|
| 293 |
+
/>
|
| 294 |
+
|
| 295 |
{/* Left: Chat */}
|
| 296 |
<div className="es-chat">
|
| 297 |
<div className="es-chat__messages">
|
|
|
|
| 352 |
</div>
|
| 353 |
</div>
|
| 354 |
|
| 355 |
+
{/* Right: Preview (chart) or data file viewer */}
|
| 356 |
<div className="es-preview">
|
| 357 |
+
{selectedFile ? (
|
| 358 |
+
<DataFileViewer
|
| 359 |
+
file={selectedFile}
|
| 360 |
+
onClose={() => setSelectedDataFile(null)}
|
| 361 |
+
/>
|
| 362 |
+
) : null}
|
| 363 |
+
|
| 364 |
+
<div
|
| 365 |
+
className={`es-preview__toolbar ${selectedFile ? "es-hidden" : ""}`}
|
| 366 |
+
>
|
| 367 |
<button
|
| 368 |
className={`es-preview__tab ${viewMode === "preview" ? "es-preview__tab--active" : ""}`}
|
| 369 |
onClick={() => setViewMode("preview")}
|
|
|
|
| 385 |
</div>
|
| 386 |
|
| 387 |
{/* Both panels always mounted - toggle with hidden class to preserve iframe state */}
|
| 388 |
+
<div
|
| 389 |
+
className={`es-preview__frame-container ${viewMode !== "preview" || selectedFile ? "es-hidden" : ""}`}
|
| 390 |
+
>
|
| 391 |
{html ? (
|
| 392 |
<>
|
| 393 |
<iframe
|
|
|
|
| 412 |
</div>
|
| 413 |
)}
|
| 414 |
</div>
|
| 415 |
+
<div
|
| 416 |
+
className={`es-preview__code ${viewMode !== "code" || selectedFile ? "es-hidden" : ""}`}
|
| 417 |
+
>
|
| 418 |
<pre className="es-preview__pre">
|
| 419 |
<code>{html || "// No chart HTML yet"}</code>
|
| 420 |
</pre>
|
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from "react";
|
| 2 |
+
import { FileText, Trash2, BarChart3, Database, AlertCircle } from "lucide-react";
|
| 3 |
+
import type { EmbedStore } from "../editor/embeds/embed-store";
|
| 4 |
+
import type { EmbedDataFileMeta } from "../editor/embeds/embed-data-store";
|
| 5 |
+
import { UploadZone } from "./UploadZone";
|
| 6 |
+
import { formatBytes } from "../utils/data-files";
|
| 7 |
+
import type { UploadResult } from "../hooks/useEmbedData";
|
| 8 |
+
|
| 9 |
+
interface FilesSidebarProps {
|
| 10 |
+
embedStore: EmbedStore | null;
|
| 11 |
+
currentSrc: string;
|
| 12 |
+
dataFiles: EmbedDataFileMeta[];
|
| 13 |
+
selectedDataFile: string | null;
|
| 14 |
+
onSelectDataFile: (name: string | null) => void;
|
| 15 |
+
onUploadFiles: (files: FileList | File[]) => Promise<UploadResult[]>;
|
| 16 |
+
onRemoveDataFile: (name: string) => void;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export function FilesSidebar({
|
| 20 |
+
embedStore,
|
| 21 |
+
currentSrc,
|
| 22 |
+
dataFiles,
|
| 23 |
+
selectedDataFile,
|
| 24 |
+
onSelectDataFile,
|
| 25 |
+
onUploadFiles,
|
| 26 |
+
onRemoveDataFile,
|
| 27 |
+
}: FilesSidebarProps) {
|
| 28 |
+
const [charts, setCharts] = useState<string[]>(() =>
|
| 29 |
+
embedStore ? embedStore.keys() : [],
|
| 30 |
+
);
|
| 31 |
+
const [uploadError, setUploadError] = useState<string | null>(null);
|
| 32 |
+
|
| 33 |
+
useEffect(() => {
|
| 34 |
+
if (!embedStore) return;
|
| 35 |
+
setCharts(embedStore.keys());
|
| 36 |
+
return embedStore.observe(() => setCharts(embedStore.keys()));
|
| 37 |
+
}, [embedStore]);
|
| 38 |
+
|
| 39 |
+
const handleUpload = async (files: FileList | File[]) => {
|
| 40 |
+
setUploadError(null);
|
| 41 |
+
const results = await onUploadFiles(files);
|
| 42 |
+
const firstError = results.find((r) => !r.ok);
|
| 43 |
+
if (firstError?.error) setUploadError(firstError.error);
|
| 44 |
+
const firstOk = results.find((r) => r.ok && r.name);
|
| 45 |
+
if (firstOk?.name) onSelectDataFile(firstOk.name);
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
return (
|
| 49 |
+
<div className="es-files">
|
| 50 |
+
<section className="es-files__section">
|
| 51 |
+
<header className="es-files__section-title">
|
| 52 |
+
<BarChart3 size={13} />
|
| 53 |
+
<span>Charts</span>
|
| 54 |
+
<span className="es-files__count">{charts.length}</span>
|
| 55 |
+
</header>
|
| 56 |
+
<ul className="es-files__list">
|
| 57 |
+
{charts.length === 0 && (
|
| 58 |
+
<li className="es-files__empty">No charts yet</li>
|
| 59 |
+
)}
|
| 60 |
+
{charts.map((name) => (
|
| 61 |
+
<li
|
| 62 |
+
key={name}
|
| 63 |
+
className={`es-files__item ${name === currentSrc ? "es-files__item--current" : ""}`}
|
| 64 |
+
title={name === currentSrc ? "Current chart" : name}
|
| 65 |
+
>
|
| 66 |
+
<FileText size={12} />
|
| 67 |
+
<span className="es-files__name">{name}</span>
|
| 68 |
+
{name === currentSrc && (
|
| 69 |
+
<span className="es-files__badge">editing</span>
|
| 70 |
+
)}
|
| 71 |
+
</li>
|
| 72 |
+
))}
|
| 73 |
+
</ul>
|
| 74 |
+
</section>
|
| 75 |
+
|
| 76 |
+
<section className="es-files__section">
|
| 77 |
+
<header className="es-files__section-title">
|
| 78 |
+
<Database size={13} />
|
| 79 |
+
<span>Data</span>
|
| 80 |
+
<span className="es-files__count">{dataFiles.length}</span>
|
| 81 |
+
</header>
|
| 82 |
+
|
| 83 |
+
<ul className="es-files__list">
|
| 84 |
+
{dataFiles.length === 0 && (
|
| 85 |
+
<li className="es-files__empty">No data uploaded yet</li>
|
| 86 |
+
)}
|
| 87 |
+
{dataFiles.map((file) => {
|
| 88 |
+
const isSelected = file.name === selectedDataFile;
|
| 89 |
+
return (
|
| 90 |
+
<li
|
| 91 |
+
key={file.name}
|
| 92 |
+
className={`es-files__item es-files__item--data ${isSelected ? "es-files__item--current" : ""}`}
|
| 93 |
+
>
|
| 94 |
+
<button
|
| 95 |
+
type="button"
|
| 96 |
+
className="es-files__item-main"
|
| 97 |
+
onClick={() => onSelectDataFile(isSelected ? null : file.name)}
|
| 98 |
+
title={file.name}
|
| 99 |
+
>
|
| 100 |
+
<FileText size={12} />
|
| 101 |
+
<span className="es-files__name">{file.name}</span>
|
| 102 |
+
<span className="es-files__size">{formatBytes(file.size)}</span>
|
| 103 |
+
</button>
|
| 104 |
+
<button
|
| 105 |
+
type="button"
|
| 106 |
+
className="es-files__item-remove"
|
| 107 |
+
onClick={(e) => {
|
| 108 |
+
e.stopPropagation();
|
| 109 |
+
if (isSelected) onSelectDataFile(null);
|
| 110 |
+
onRemoveDataFile(file.name);
|
| 111 |
+
}}
|
| 112 |
+
aria-label={`Remove ${file.name}`}
|
| 113 |
+
title="Remove file"
|
| 114 |
+
>
|
| 115 |
+
<Trash2 size={12} />
|
| 116 |
+
</button>
|
| 117 |
+
</li>
|
| 118 |
+
);
|
| 119 |
+
})}
|
| 120 |
+
</ul>
|
| 121 |
+
|
| 122 |
+
<UploadZone onFiles={handleUpload} compact={dataFiles.length > 0} />
|
| 123 |
+
{uploadError && (
|
| 124 |
+
<div className="es-files__error">
|
| 125 |
+
<AlertCircle size={12} />
|
| 126 |
+
<span>{uploadError}</span>
|
| 127 |
+
</div>
|
| 128 |
+
)}
|
| 129 |
+
</section>
|
| 130 |
+
</div>
|
| 131 |
+
);
|
| 132 |
+
}
|
|
@@ -1,6 +1,20 @@
|
|
|
|
|
| 1 |
import type { Editor as TiptapEditor } from "@tiptap/core";
|
| 2 |
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
| 3 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
import { Tooltip } from "./Tooltip";
|
| 5 |
import { SyncIndicator } from "./SyncIndicator";
|
| 6 |
import type { CollabUser } from "../utils/user";
|
|
@@ -48,6 +62,43 @@ export function TopBar({
|
|
| 48 |
? `${publishingUserName} is publishing...`
|
| 49 |
: "Publish in progress..."
|
| 50 |
: "Publish article";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
return (
|
| 52 |
<div className="top-bar">
|
| 53 |
<button
|
|
@@ -103,6 +154,53 @@ export function TopBar({
|
|
| 103 |
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
|
| 104 |
</button>
|
| 105 |
</Tooltip>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
<span className="divider-v" />
|
| 107 |
<Tooltip title={publishTooltip}>
|
| 108 |
<button
|
|
|
|
| 1 |
+
import { useEffect, useRef, useState } from "react";
|
| 2 |
import type { Editor as TiptapEditor } from "@tiptap/core";
|
| 3 |
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
| 4 |
+
import {
|
| 5 |
+
Undo2,
|
| 6 |
+
Redo2,
|
| 7 |
+
Settings,
|
| 8 |
+
Upload,
|
| 9 |
+
Eye,
|
| 10 |
+
Sun,
|
| 11 |
+
Moon,
|
| 12 |
+
Menu,
|
| 13 |
+
LogIn,
|
| 14 |
+
MoreHorizontal,
|
| 15 |
+
FileText,
|
| 16 |
+
Trash2,
|
| 17 |
+
} from "lucide-react";
|
| 18 |
import { Tooltip } from "./Tooltip";
|
| 19 |
import { SyncIndicator } from "./SyncIndicator";
|
| 20 |
import type { CollabUser } from "../utils/user";
|
|
|
|
| 62 |
? `${publishingUserName} is publishing...`
|
| 63 |
: "Publish in progress..."
|
| 64 |
: "Publish article";
|
| 65 |
+
|
| 66 |
+
// Dev / utility menu state (Load demo, Reset article). Kept out of
|
| 67 |
+
// the slash menu so it doesn't clutter the authoring surface.
|
| 68 |
+
const [devMenuOpen, setDevMenuOpen] = useState(false);
|
| 69 |
+
const devMenuRef = useRef<HTMLDivElement | null>(null);
|
| 70 |
+
useEffect(() => {
|
| 71 |
+
if (!devMenuOpen) return;
|
| 72 |
+
const onDocClick = (e: MouseEvent) => {
|
| 73 |
+
if (!devMenuRef.current) return;
|
| 74 |
+
if (!devMenuRef.current.contains(e.target as Node)) setDevMenuOpen(false);
|
| 75 |
+
};
|
| 76 |
+
const onKey = (e: KeyboardEvent) => {
|
| 77 |
+
if (e.key === "Escape") setDevMenuOpen(false);
|
| 78 |
+
};
|
| 79 |
+
document.addEventListener("mousedown", onDocClick);
|
| 80 |
+
document.addEventListener("keydown", onKey);
|
| 81 |
+
return () => {
|
| 82 |
+
document.removeEventListener("mousedown", onDocClick);
|
| 83 |
+
document.removeEventListener("keydown", onKey);
|
| 84 |
+
};
|
| 85 |
+
}, [devMenuOpen]);
|
| 86 |
+
|
| 87 |
+
const handleLoadDemo = () => {
|
| 88 |
+
setDevMenuOpen(false);
|
| 89 |
+
window.dispatchEvent(new CustomEvent("load-demo-content"));
|
| 90 |
+
};
|
| 91 |
+
const handleReset = () => {
|
| 92 |
+
setDevMenuOpen(false);
|
| 93 |
+
if (
|
| 94 |
+
window.confirm(
|
| 95 |
+
"Reset the article? This wipes the document, banner, frontmatter and citations.",
|
| 96 |
+
)
|
| 97 |
+
) {
|
| 98 |
+
window.dispatchEvent(new CustomEvent("reset-article"));
|
| 99 |
+
}
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
return (
|
| 103 |
<div className="top-bar">
|
| 104 |
<button
|
|
|
|
| 154 |
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
|
| 155 |
</button>
|
| 156 |
</Tooltip>
|
| 157 |
+
|
| 158 |
+
<div className="top-bar__menu" ref={devMenuRef}>
|
| 159 |
+
<Tooltip title="More actions">
|
| 160 |
+
<button
|
| 161 |
+
className="icon-btn"
|
| 162 |
+
onClick={() => setDevMenuOpen((v) => !v)}
|
| 163 |
+
aria-label="More actions"
|
| 164 |
+
aria-haspopup="menu"
|
| 165 |
+
aria-expanded={devMenuOpen || undefined}
|
| 166 |
+
>
|
| 167 |
+
<MoreHorizontal size={18} />
|
| 168 |
+
</button>
|
| 169 |
+
</Tooltip>
|
| 170 |
+
{devMenuOpen && (
|
| 171 |
+
<div className="top-bar__menu-pop" role="menu">
|
| 172 |
+
<button
|
| 173 |
+
className="top-bar__menu-item"
|
| 174 |
+
role="menuitem"
|
| 175 |
+
onClick={handleLoadDemo}
|
| 176 |
+
>
|
| 177 |
+
<FileText size={14} />
|
| 178 |
+
<span className="top-bar__menu-item-content">
|
| 179 |
+
<span className="top-bar__menu-item-title">
|
| 180 |
+
Load demo content
|
| 181 |
+
</span>
|
| 182 |
+
<span className="top-bar__menu-item-desc">
|
| 183 |
+
Replace document with a full demo article
|
| 184 |
+
</span>
|
| 185 |
+
</span>
|
| 186 |
+
</button>
|
| 187 |
+
<button
|
| 188 |
+
className="top-bar__menu-item top-bar__menu-item--danger"
|
| 189 |
+
role="menuitem"
|
| 190 |
+
onClick={handleReset}
|
| 191 |
+
>
|
| 192 |
+
<Trash2 size={14} />
|
| 193 |
+
<span className="top-bar__menu-item-content">
|
| 194 |
+
<span className="top-bar__menu-item-title">Reset article</span>
|
| 195 |
+
<span className="top-bar__menu-item-desc">
|
| 196 |
+
Wipe document, banner, frontmatter, citations
|
| 197 |
+
</span>
|
| 198 |
+
</span>
|
| 199 |
+
</button>
|
| 200 |
+
</div>
|
| 201 |
+
)}
|
| 202 |
+
</div>
|
| 203 |
+
|
| 204 |
<span className="divider-v" />
|
| 205 |
<Tooltip title={publishTooltip}>
|
| 206 |
<button
|
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useRef, useState, type DragEvent } from "react";
|
| 2 |
+
import { Upload } from "lucide-react";
|
| 3 |
+
import { ACCEPTED_DATA_EXTS, MAX_DATA_FILE_SIZE } from "../utils/data-files";
|
| 4 |
+
|
| 5 |
+
interface UploadZoneProps {
|
| 6 |
+
onFiles: (files: FileList | File[]) => void | Promise<void>;
|
| 7 |
+
disabled?: boolean;
|
| 8 |
+
compact?: boolean;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
const ACCEPT_ATTR = ACCEPTED_DATA_EXTS.map((e) => `.${e}`).join(",");
|
| 12 |
+
const MAX_MB = (MAX_DATA_FILE_SIZE / (1024 * 1024)).toFixed(0);
|
| 13 |
+
|
| 14 |
+
export function UploadZone({ onFiles, disabled, compact }: UploadZoneProps) {
|
| 15 |
+
const inputRef = useRef<HTMLInputElement>(null);
|
| 16 |
+
const [isDragging, setIsDragging] = useState(false);
|
| 17 |
+
|
| 18 |
+
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
| 19 |
+
e.preventDefault();
|
| 20 |
+
setIsDragging(false);
|
| 21 |
+
if (disabled) return;
|
| 22 |
+
if (e.dataTransfer.files?.length) void onFiles(e.dataTransfer.files);
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
| 26 |
+
e.preventDefault();
|
| 27 |
+
if (!disabled) setIsDragging(true);
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
const handleDragLeave = () => setIsDragging(false);
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<div
|
| 34 |
+
className={`es-upload-zone ${isDragging ? "es-upload-zone--active" : ""} ${compact ? "es-upload-zone--compact" : ""}`}
|
| 35 |
+
onDrop={handleDrop}
|
| 36 |
+
onDragOver={handleDragOver}
|
| 37 |
+
onDragLeave={handleDragLeave}
|
| 38 |
+
onClick={() => !disabled && inputRef.current?.click()}
|
| 39 |
+
role="button"
|
| 40 |
+
tabIndex={disabled ? -1 : 0}
|
| 41 |
+
aria-label="Upload data file"
|
| 42 |
+
>
|
| 43 |
+
<input
|
| 44 |
+
ref={inputRef}
|
| 45 |
+
type="file"
|
| 46 |
+
accept={ACCEPT_ATTR}
|
| 47 |
+
multiple
|
| 48 |
+
onChange={(e) => {
|
| 49 |
+
if (e.target.files?.length) void onFiles(e.target.files);
|
| 50 |
+
e.target.value = "";
|
| 51 |
+
}}
|
| 52 |
+
style={{ display: "none" }}
|
| 53 |
+
/>
|
| 54 |
+
<Upload size={compact ? 14 : 18} />
|
| 55 |
+
{compact ? (
|
| 56 |
+
<span>Add data file</span>
|
| 57 |
+
) : (
|
| 58 |
+
<div className="es-upload-zone__text">
|
| 59 |
+
<strong>Drop a data file</strong>
|
| 60 |
+
<span>CSV, TSV, JSON, NDJSON, TXT - up to {MAX_MB} MB</span>
|
| 61 |
+
</div>
|
| 62 |
+
)}
|
| 63 |
+
</div>
|
| 64 |
+
);
|
| 65 |
+
}
|
|
@@ -29,10 +29,14 @@ import { Glossary } from "./extensions/glossary";
|
|
| 29 |
import { Footnote } from "./extensions/footnote";
|
| 30 |
import { Stack, StackColumn } from "./extensions/stack";
|
| 31 |
import { ScrollGuard } from "./extensions/scroll-guard";
|
|
|
|
|
|
|
|
|
|
| 32 |
import { CitationPanel } from "./CitationPanel";
|
| 33 |
import { createCommentStore, CommentStore } from "./comments";
|
| 34 |
import { createFrontmatterStore, FrontmatterStore } from "./frontmatter/frontmatter-store";
|
| 35 |
import { createEmbedStore, EmbedStore } from "./embeds/embed-store";
|
|
|
|
| 36 |
import { COMPONENTS, createComponentExtension } from "./components";
|
| 37 |
import { uploadImage } from "./upload";
|
| 38 |
import { loadDemoContent } from "./load-demo";
|
|
@@ -44,6 +48,7 @@ interface EditorProps {
|
|
| 44 |
onCommentStoreReady: (store: CommentStore) => void;
|
| 45 |
onFrontmatterStoreReady: (store: FrontmatterStore) => void;
|
| 46 |
onEmbedStoreReady?: (store: EmbedStore) => void;
|
|
|
|
| 47 |
onSettingsMapReady?: (map: Y.Map<any>) => void;
|
| 48 |
onEditorReady: (editor: TiptapEditor | null) => void;
|
| 49 |
onUndoManagerReady?: (manager: UndoManager) => void;
|
|
@@ -58,6 +63,7 @@ export function Editor({
|
|
| 58 |
onCommentStoreReady,
|
| 59 |
onFrontmatterStoreReady,
|
| 60 |
onEmbedStoreReady,
|
|
|
|
| 61 |
onProviderReady,
|
| 62 |
onSettingsMapReady,
|
| 63 |
onEditorReady,
|
|
@@ -95,6 +101,7 @@ export function Editor({
|
|
| 95 |
const commentStore = useMemo(() => createCommentStore(ydoc), [ydoc]);
|
| 96 |
const frontmatterStore = useMemo(() => createFrontmatterStore(ydoc), [ydoc]);
|
| 97 |
const embedStore = useMemo(() => createEmbedStore(ydoc), [ydoc]);
|
|
|
|
| 98 |
const [showCitationPanel, setShowCitationPanel] = useState(false);
|
| 99 |
|
| 100 |
useEffect(() => {
|
|
@@ -109,6 +116,10 @@ export function Editor({
|
|
| 109 |
onEmbedStoreReady?.(embedStore);
|
| 110 |
}, [embedStore, onEmbedStoreReady]);
|
| 111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
useEffect(() => {
|
| 113 |
onSettingsMapReady?.(settingsMap);
|
| 114 |
}, [settingsMap, onSettingsMapReady]);
|
|
@@ -214,6 +225,12 @@ export function Editor({
|
|
| 214 |
}),
|
| 215 |
Comment,
|
| 216 |
ScrollGuard,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
],
|
| 218 |
},
|
| 219 |
[ydoc, provider],
|
|
@@ -268,7 +285,7 @@ export function Editor({
|
|
| 268 |
return () => window.removeEventListener("reset-article", handler);
|
| 269 |
}, [editor, frontmatterStore, citationsMap, settingsMap, embedStore, ydoc]);
|
| 270 |
|
| 271 |
-
// Dev-only hook used by the demo recording script (backend/demo/
|
| 272 |
// Lets the script swap the banner HTML deterministically, simulating a
|
| 273 |
// successful Embed Studio agent run without depending on the LLM backend.
|
| 274 |
useEffect(() => {
|
|
@@ -290,6 +307,7 @@ export function Editor({
|
|
| 290 |
useEffect(() => {
|
| 291 |
if (!editor) return;
|
| 292 |
(editor.commands as any).updateUser(user);
|
|
|
|
| 293 |
}, [editor, user]);
|
| 294 |
|
| 295 |
useEffect(() => {
|
|
|
|
| 29 |
import { Footnote } from "./extensions/footnote";
|
| 30 |
import { Stack, StackColumn } from "./extensions/stack";
|
| 31 |
import { ScrollGuard } from "./extensions/scroll-guard";
|
| 32 |
+
import { AgentHighlight } from "./extensions/agent-highlight";
|
| 33 |
+
import { AgentFocus } from "./extensions/agent-focus";
|
| 34 |
+
import { AgentRewrite } from "./extensions/agent-rewrite";
|
| 35 |
import { CitationPanel } from "./CitationPanel";
|
| 36 |
import { createCommentStore, CommentStore } from "./comments";
|
| 37 |
import { createFrontmatterStore, FrontmatterStore } from "./frontmatter/frontmatter-store";
|
| 38 |
import { createEmbedStore, EmbedStore } from "./embeds/embed-store";
|
| 39 |
+
import { createEmbedDataStore, EmbedDataStore } from "./embeds/embed-data-store";
|
| 40 |
import { COMPONENTS, createComponentExtension } from "./components";
|
| 41 |
import { uploadImage } from "./upload";
|
| 42 |
import { loadDemoContent } from "./load-demo";
|
|
|
|
| 48 |
onCommentStoreReady: (store: CommentStore) => void;
|
| 49 |
onFrontmatterStoreReady: (store: FrontmatterStore) => void;
|
| 50 |
onEmbedStoreReady?: (store: EmbedStore) => void;
|
| 51 |
+
onEmbedDataStoreReady?: (store: EmbedDataStore) => void;
|
| 52 |
onSettingsMapReady?: (map: Y.Map<any>) => void;
|
| 53 |
onEditorReady: (editor: TiptapEditor | null) => void;
|
| 54 |
onUndoManagerReady?: (manager: UndoManager) => void;
|
|
|
|
| 63 |
onCommentStoreReady,
|
| 64 |
onFrontmatterStoreReady,
|
| 65 |
onEmbedStoreReady,
|
| 66 |
+
onEmbedDataStoreReady,
|
| 67 |
onProviderReady,
|
| 68 |
onSettingsMapReady,
|
| 69 |
onEditorReady,
|
|
|
|
| 101 |
const commentStore = useMemo(() => createCommentStore(ydoc), [ydoc]);
|
| 102 |
const frontmatterStore = useMemo(() => createFrontmatterStore(ydoc), [ydoc]);
|
| 103 |
const embedStore = useMemo(() => createEmbedStore(ydoc), [ydoc]);
|
| 104 |
+
const embedDataStore = useMemo(() => createEmbedDataStore(ydoc), [ydoc]);
|
| 105 |
const [showCitationPanel, setShowCitationPanel] = useState(false);
|
| 106 |
|
| 107 |
useEffect(() => {
|
|
|
|
| 116 |
onEmbedStoreReady?.(embedStore);
|
| 117 |
}, [embedStore, onEmbedStoreReady]);
|
| 118 |
|
| 119 |
+
useEffect(() => {
|
| 120 |
+
onEmbedDataStoreReady?.(embedDataStore);
|
| 121 |
+
}, [embedDataStore, onEmbedDataStoreReady]);
|
| 122 |
+
|
| 123 |
useEffect(() => {
|
| 124 |
onSettingsMapReady?.(settingsMap);
|
| 125 |
}, [settingsMap, onSettingsMapReady]);
|
|
|
|
| 225 |
}),
|
| 226 |
Comment,
|
| 227 |
ScrollGuard,
|
| 228 |
+
AgentHighlight,
|
| 229 |
+
AgentFocus.configure({
|
| 230 |
+
provider,
|
| 231 |
+
user,
|
| 232 |
+
}),
|
| 233 |
+
AgentRewrite,
|
| 234 |
],
|
| 235 |
},
|
| 236 |
[ydoc, provider],
|
|
|
|
| 285 |
return () => window.removeEventListener("reset-article", handler);
|
| 286 |
}, [editor, frontmatterStore, citationsMap, settingsMap, embedStore, ydoc]);
|
| 287 |
|
| 288 |
+
// Dev-only hook used by the demo recording script (backend/demo/showcase.ts).
|
| 289 |
// Lets the script swap the banner HTML deterministically, simulating a
|
| 290 |
// successful Embed Studio agent run without depending on the LLM backend.
|
| 291 |
useEffect(() => {
|
|
|
|
| 307 |
useEffect(() => {
|
| 308 |
if (!editor) return;
|
| 309 |
(editor.commands as any).updateUser(user);
|
| 310 |
+
editor.commands.updateAgentFocusUser(user);
|
| 311 |
}, [editor, user]);
|
| 312 |
|
| 313 |
useEffect(() => {
|
|
@@ -5,16 +5,26 @@ import {
|
|
| 5 |
forwardRef,
|
| 6 |
useEffect,
|
| 7 |
useImperativeHandle,
|
|
|
|
| 8 |
useState,
|
| 9 |
useCallback,
|
| 10 |
} from "react";
|
| 11 |
import type { Editor } from "@tiptap/core";
|
| 12 |
import { getComponentSlashItems } from "./components";
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
interface SlashItem {
|
| 15 |
title: string;
|
| 16 |
description: string;
|
| 17 |
icon: string;
|
|
|
|
| 18 |
command: (editor: Editor) => void;
|
| 19 |
}
|
| 20 |
|
|
@@ -23,6 +33,7 @@ const BUILT_IN_ITEMS: SlashItem[] = [
|
|
| 23 |
title: "Heading 1",
|
| 24 |
description: "Large section heading",
|
| 25 |
icon: "H1",
|
|
|
|
| 26 |
command: (editor) =>
|
| 27 |
editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
| 28 |
},
|
|
@@ -30,6 +41,7 @@ const BUILT_IN_ITEMS: SlashItem[] = [
|
|
| 30 |
title: "Heading 2",
|
| 31 |
description: "Medium section heading",
|
| 32 |
icon: "H2",
|
|
|
|
| 33 |
command: (editor) =>
|
| 34 |
editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
| 35 |
},
|
|
@@ -37,6 +49,7 @@ const BUILT_IN_ITEMS: SlashItem[] = [
|
|
| 37 |
title: "Heading 3",
|
| 38 |
description: "Small section heading",
|
| 39 |
icon: "H3",
|
|
|
|
| 40 |
command: (editor) =>
|
| 41 |
editor.chain().focus().toggleHeading({ level: 3 }).run(),
|
| 42 |
},
|
|
@@ -44,36 +57,42 @@ const BUILT_IN_ITEMS: SlashItem[] = [
|
|
| 44 |
title: "Bullet list",
|
| 45 |
description: "Unordered list",
|
| 46 |
icon: "•",
|
|
|
|
| 47 |
command: (editor) => editor.chain().focus().toggleBulletList().run(),
|
| 48 |
},
|
| 49 |
{
|
| 50 |
title: "Numbered list",
|
| 51 |
description: "Ordered list",
|
| 52 |
icon: "1.",
|
|
|
|
| 53 |
command: (editor) => editor.chain().focus().toggleOrderedList().run(),
|
| 54 |
},
|
| 55 |
{
|
| 56 |
title: "Quote",
|
| 57 |
description: "Blockquote",
|
| 58 |
icon: "❝",
|
|
|
|
| 59 |
command: (editor) => editor.chain().focus().toggleBlockquote().run(),
|
| 60 |
},
|
| 61 |
{
|
| 62 |
title: "Code block",
|
| 63 |
description: "Code with syntax highlighting",
|
| 64 |
icon: "<>",
|
|
|
|
| 65 |
command: (editor) => editor.chain().focus().toggleCodeBlock().run(),
|
| 66 |
},
|
| 67 |
{
|
| 68 |
title: "Divider",
|
| 69 |
description: "Horizontal rule",
|
| 70 |
icon: "—",
|
|
|
|
| 71 |
command: (editor) => editor.chain().focus().setHorizontalRule().run(),
|
| 72 |
},
|
| 73 |
{
|
| 74 |
title: "Image",
|
| 75 |
description: "Upload or embed an image",
|
| 76 |
icon: "🖼",
|
|
|
|
| 77 |
command: (editor) => {
|
| 78 |
editor.chain().focus().insertImageUpload().run();
|
| 79 |
},
|
|
@@ -82,6 +101,7 @@ const BUILT_IN_ITEMS: SlashItem[] = [
|
|
| 82 |
title: "Citation",
|
| 83 |
description: "Cite a paper (DOI or BibTeX)",
|
| 84 |
icon: "📎",
|
|
|
|
| 85 |
command: () => {
|
| 86 |
window.dispatchEvent(new CustomEvent("open-citation-panel"));
|
| 87 |
},
|
|
@@ -90,6 +110,7 @@ const BUILT_IN_ITEMS: SlashItem[] = [
|
|
| 90 |
title: "Glossary",
|
| 91 |
description: "Highlighted term with tooltip definition",
|
| 92 |
icon: "📖",
|
|
|
|
| 93 |
command: (editor) => {
|
| 94 |
editor.chain().focus().insertGlossary("term", "Definition here…").run();
|
| 95 |
},
|
|
@@ -98,6 +119,7 @@ const BUILT_IN_ITEMS: SlashItem[] = [
|
|
| 98 |
title: "Footnote",
|
| 99 |
description: "Numbered footnote reference",
|
| 100 |
icon: "¹",
|
|
|
|
| 101 |
command: (editor) => {
|
| 102 |
editor.chain().focus().insertFootnote("Footnote content…").run();
|
| 103 |
},
|
|
@@ -106,6 +128,7 @@ const BUILT_IN_ITEMS: SlashItem[] = [
|
|
| 106 |
title: "Stack",
|
| 107 |
description: "Multi-column layout (2-4 columns)",
|
| 108 |
icon: "▥",
|
|
|
|
| 109 |
command: (editor) => {
|
| 110 |
editor.chain().focus().insertStack(2).run();
|
| 111 |
},
|
|
@@ -114,11 +137,11 @@ const BUILT_IN_ITEMS: SlashItem[] = [
|
|
| 114 |
title: "New Chart",
|
| 115 |
description: "Create a D3 chart with AI (Embed Studio)",
|
| 116 |
icon: "📊",
|
|
|
|
| 117 |
command: (editor) => {
|
| 118 |
const id = `d3-chart-${Date.now().toString(36)}`;
|
| 119 |
const src = `${id}.html`;
|
| 120 |
-
(editor.chain().focus() as any).
|
| 121 |
-
// Find the just-inserted htmlEmbed node and set its src
|
| 122 |
setTimeout(() => {
|
| 123 |
const { doc } = editor.state;
|
| 124 |
let targetPos = -1;
|
|
@@ -143,25 +166,94 @@ const BUILT_IN_ITEMS: SlashItem[] = [
|
|
| 143 |
}, 50);
|
| 144 |
},
|
| 145 |
},
|
| 146 |
-
{
|
| 147 |
-
title: "Load demo content",
|
| 148 |
-
description: "Replace document with a full demo article",
|
| 149 |
-
icon: "📄",
|
| 150 |
-
command: () => {
|
| 151 |
-
window.dispatchEvent(new CustomEvent("load-demo-content"));
|
| 152 |
-
},
|
| 153 |
-
},
|
| 154 |
-
{
|
| 155 |
-
title: "Reset article",
|
| 156 |
-
description: "Wipe document, banner, frontmatter and citations",
|
| 157 |
-
icon: "🗑",
|
| 158 |
-
command: () => {
|
| 159 |
-
window.dispatchEvent(new CustomEvent("reset-article"));
|
| 160 |
-
},
|
| 161 |
-
},
|
| 162 |
];
|
| 163 |
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
interface SlashMenuListProps {
|
| 167 |
items: SlashItem[];
|
|
@@ -176,6 +268,12 @@ const SlashMenuList = forwardRef<SlashMenuListRef, SlashMenuListProps>(
|
|
| 176 |
({ items, command }, ref) => {
|
| 177 |
const [selectedIndex, setSelectedIndex] = useState(0);
|
| 178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
useEffect(() => {
|
| 180 |
setSelectedIndex(0);
|
| 181 |
}, [items]);
|
|
@@ -210,18 +308,31 @@ const SlashMenuList = forwardRef<SlashMenuListRef, SlashMenuListProps>(
|
|
| 210 |
|
| 211 |
return (
|
| 212 |
<div className="slash-menu">
|
| 213 |
-
{
|
| 214 |
-
<
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
))}
|
| 226 |
</div>
|
| 227 |
);
|
|
@@ -233,8 +344,14 @@ SlashMenuList.displayName = "SlashMenuList";
|
|
| 233 |
export function slashMenuSuggestion() {
|
| 234 |
return {
|
| 235 |
items: ({ query }: { query: string }) => {
|
| 236 |
-
|
| 237 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
);
|
| 239 |
},
|
| 240 |
|
|
|
|
| 5 |
forwardRef,
|
| 6 |
useEffect,
|
| 7 |
useImperativeHandle,
|
| 8 |
+
useMemo,
|
| 9 |
useState,
|
| 10 |
useCallback,
|
| 11 |
} from "react";
|
| 12 |
import type { Editor } from "@tiptap/core";
|
| 13 |
import { getComponentSlashItems } from "./components";
|
| 14 |
|
| 15 |
+
type SlashSection =
|
| 16 |
+
| "Insert"
|
| 17 |
+
| "Text"
|
| 18 |
+
| "Academic"
|
| 19 |
+
| "Media"
|
| 20 |
+
| "Layout"
|
| 21 |
+
| "Advanced";
|
| 22 |
+
|
| 23 |
interface SlashItem {
|
| 24 |
title: string;
|
| 25 |
description: string;
|
| 26 |
icon: string;
|
| 27 |
+
section: SlashSection;
|
| 28 |
command: (editor: Editor) => void;
|
| 29 |
}
|
| 30 |
|
|
|
|
| 33 |
title: "Heading 1",
|
| 34 |
description: "Large section heading",
|
| 35 |
icon: "H1",
|
| 36 |
+
section: "Text",
|
| 37 |
command: (editor) =>
|
| 38 |
editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
| 39 |
},
|
|
|
|
| 41 |
title: "Heading 2",
|
| 42 |
description: "Medium section heading",
|
| 43 |
icon: "H2",
|
| 44 |
+
section: "Text",
|
| 45 |
command: (editor) =>
|
| 46 |
editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
| 47 |
},
|
|
|
|
| 49 |
title: "Heading 3",
|
| 50 |
description: "Small section heading",
|
| 51 |
icon: "H3",
|
| 52 |
+
section: "Text",
|
| 53 |
command: (editor) =>
|
| 54 |
editor.chain().focus().toggleHeading({ level: 3 }).run(),
|
| 55 |
},
|
|
|
|
| 57 |
title: "Bullet list",
|
| 58 |
description: "Unordered list",
|
| 59 |
icon: "•",
|
| 60 |
+
section: "Text",
|
| 61 |
command: (editor) => editor.chain().focus().toggleBulletList().run(),
|
| 62 |
},
|
| 63 |
{
|
| 64 |
title: "Numbered list",
|
| 65 |
description: "Ordered list",
|
| 66 |
icon: "1.",
|
| 67 |
+
section: "Text",
|
| 68 |
command: (editor) => editor.chain().focus().toggleOrderedList().run(),
|
| 69 |
},
|
| 70 |
{
|
| 71 |
title: "Quote",
|
| 72 |
description: "Blockquote",
|
| 73 |
icon: "❝",
|
| 74 |
+
section: "Text",
|
| 75 |
command: (editor) => editor.chain().focus().toggleBlockquote().run(),
|
| 76 |
},
|
| 77 |
{
|
| 78 |
title: "Code block",
|
| 79 |
description: "Code with syntax highlighting",
|
| 80 |
icon: "<>",
|
| 81 |
+
section: "Text",
|
| 82 |
command: (editor) => editor.chain().focus().toggleCodeBlock().run(),
|
| 83 |
},
|
| 84 |
{
|
| 85 |
title: "Divider",
|
| 86 |
description: "Horizontal rule",
|
| 87 |
icon: "—",
|
| 88 |
+
section: "Text",
|
| 89 |
command: (editor) => editor.chain().focus().setHorizontalRule().run(),
|
| 90 |
},
|
| 91 |
{
|
| 92 |
title: "Image",
|
| 93 |
description: "Upload or embed an image",
|
| 94 |
icon: "🖼",
|
| 95 |
+
section: "Insert",
|
| 96 |
command: (editor) => {
|
| 97 |
editor.chain().focus().insertImageUpload().run();
|
| 98 |
},
|
|
|
|
| 101 |
title: "Citation",
|
| 102 |
description: "Cite a paper (DOI or BibTeX)",
|
| 103 |
icon: "📎",
|
| 104 |
+
section: "Academic",
|
| 105 |
command: () => {
|
| 106 |
window.dispatchEvent(new CustomEvent("open-citation-panel"));
|
| 107 |
},
|
|
|
|
| 110 |
title: "Glossary",
|
| 111 |
description: "Highlighted term with tooltip definition",
|
| 112 |
icon: "📖",
|
| 113 |
+
section: "Academic",
|
| 114 |
command: (editor) => {
|
| 115 |
editor.chain().focus().insertGlossary("term", "Definition here…").run();
|
| 116 |
},
|
|
|
|
| 119 |
title: "Footnote",
|
| 120 |
description: "Numbered footnote reference",
|
| 121 |
icon: "¹",
|
| 122 |
+
section: "Academic",
|
| 123 |
command: (editor) => {
|
| 124 |
editor.chain().focus().insertFootnote("Footnote content…").run();
|
| 125 |
},
|
|
|
|
| 128 |
title: "Stack",
|
| 129 |
description: "Multi-column layout (2-4 columns)",
|
| 130 |
icon: "▥",
|
| 131 |
+
section: "Layout",
|
| 132 |
command: (editor) => {
|
| 133 |
editor.chain().focus().insertStack(2).run();
|
| 134 |
},
|
|
|
|
| 137 |
title: "New Chart",
|
| 138 |
description: "Create a D3 chart with AI (Embed Studio)",
|
| 139 |
icon: "📊",
|
| 140 |
+
section: "Insert",
|
| 141 |
command: (editor) => {
|
| 142 |
const id = `d3-chart-${Date.now().toString(36)}`;
|
| 143 |
const src = `${id}.html`;
|
| 144 |
+
(editor.chain().focus() as any).insertHtmlEmbed().run();
|
|
|
|
| 145 |
setTimeout(() => {
|
| 146 |
const { doc } = editor.state;
|
| 147 |
let targetPos = -1;
|
|
|
|
| 166 |
}, 50);
|
| 167 |
},
|
| 168 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
];
|
| 170 |
|
| 171 |
+
/**
|
| 172 |
+
* Component-driven slash items (Accordion, Note, HtmlEmbed, ...) come
|
| 173 |
+
* from the shared component registry. We map each tag to its slash
|
| 174 |
+
* section here so the menu stays categorized when new components are
|
| 175 |
+
* added.
|
| 176 |
+
*/
|
| 177 |
+
const COMPONENT_SECTION_MAP: Record<string, SlashSection> = {
|
| 178 |
+
Accordion: "Layout",
|
| 179 |
+
Note: "Academic",
|
| 180 |
+
Quote: "Text",
|
| 181 |
+
Wide: "Layout",
|
| 182 |
+
FullWidth: "Layout",
|
| 183 |
+
Sidenote: "Academic",
|
| 184 |
+
Reference: "Media",
|
| 185 |
+
HtmlEmbed: "Media",
|
| 186 |
+
HfUser: "Media",
|
| 187 |
+
RawHtml: "Advanced",
|
| 188 |
+
Mermaid: "Media",
|
| 189 |
+
};
|
| 190 |
+
|
| 191 |
+
function inferSection(title: string): SlashSection {
|
| 192 |
+
// Component slash items don't carry the original tag; match by label.
|
| 193 |
+
const byLabel: Record<string, SlashSection> = {
|
| 194 |
+
Accordion: "Layout",
|
| 195 |
+
"Note / Callout": "Academic",
|
| 196 |
+
"Quote block": "Text",
|
| 197 |
+
Wide: "Layout",
|
| 198 |
+
"Full width": "Layout",
|
| 199 |
+
Sidenote: "Academic",
|
| 200 |
+
"Reference / Figure": "Media",
|
| 201 |
+
"HTML Embed": "Media",
|
| 202 |
+
"HF User card": "Media",
|
| 203 |
+
"Raw HTML": "Advanced",
|
| 204 |
+
"Mermaid diagram": "Media",
|
| 205 |
+
};
|
| 206 |
+
return byLabel[title] ?? "Layout";
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
function withInferredSection(
|
| 210 |
+
items: Array<Omit<SlashItem, "section"> & { section?: SlashSection }>,
|
| 211 |
+
): SlashItem[] {
|
| 212 |
+
return items.map((item) => ({
|
| 213 |
+
...item,
|
| 214 |
+
section: item.section ?? inferSection(item.title),
|
| 215 |
+
}));
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
const ITEMS: SlashItem[] = [
|
| 219 |
+
...BUILT_IN_ITEMS,
|
| 220 |
+
...withInferredSection(getComponentSlashItems()),
|
| 221 |
+
];
|
| 222 |
+
|
| 223 |
+
// Display order of section groups in the menu. "Insert" is a tiny
|
| 224 |
+
// featured section at the very top holding the two flagship heavy
|
| 225 |
+
// inserts (New Chart, Image) - this editor's signature features.
|
| 226 |
+
// Text blocks follow because they're 80% of real usage.
|
| 227 |
+
const SECTION_ORDER: SlashSection[] = [
|
| 228 |
+
"Insert",
|
| 229 |
+
"Text",
|
| 230 |
+
"Media",
|
| 231 |
+
"Academic",
|
| 232 |
+
"Layout",
|
| 233 |
+
"Advanced",
|
| 234 |
+
];
|
| 235 |
+
|
| 236 |
+
// Used to re-export so we can also feed the section map to anyone who
|
| 237 |
+
// wants it (e.g. the TopBar "more" menu could list categories).
|
| 238 |
+
export { SECTION_ORDER, COMPONENT_SECTION_MAP };
|
| 239 |
+
|
| 240 |
+
interface SlashGroup {
|
| 241 |
+
section: SlashSection;
|
| 242 |
+
items: SlashItem[];
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
function groupItems(items: SlashItem[]): SlashGroup[] {
|
| 246 |
+
const map = new Map<SlashSection, SlashItem[]>();
|
| 247 |
+
for (const item of items) {
|
| 248 |
+
const list = map.get(item.section) ?? [];
|
| 249 |
+
list.push(item);
|
| 250 |
+
map.set(item.section, list);
|
| 251 |
+
}
|
| 252 |
+
return SECTION_ORDER.filter((s) => map.has(s)).map((section) => ({
|
| 253 |
+
section,
|
| 254 |
+
items: map.get(section)!,
|
| 255 |
+
}));
|
| 256 |
+
}
|
| 257 |
|
| 258 |
interface SlashMenuListProps {
|
| 259 |
items: SlashItem[];
|
|
|
|
| 268 |
({ items, command }, ref) => {
|
| 269 |
const [selectedIndex, setSelectedIndex] = useState(0);
|
| 270 |
|
| 271 |
+
// We render items in section groups. Keyboard navigation still
|
| 272 |
+
// operates on the flat `items` array (not on section headers),
|
| 273 |
+
// so ArrowDown moves between actual items regardless of their
|
| 274 |
+
// section.
|
| 275 |
+
const groups = useMemo(() => groupItems(items), [items]);
|
| 276 |
+
|
| 277 |
useEffect(() => {
|
| 278 |
setSelectedIndex(0);
|
| 279 |
}, [items]);
|
|
|
|
| 308 |
|
| 309 |
return (
|
| 310 |
<div className="slash-menu">
|
| 311 |
+
{groups.map((group) => (
|
| 312 |
+
<div key={group.section} className="slash-menu-group">
|
| 313 |
+
<div className="slash-menu-section" aria-hidden="true">
|
| 314 |
+
{group.section}
|
| 315 |
+
</div>
|
| 316 |
+
{group.items.map((item) => {
|
| 317 |
+
// Map back to the flat index so keyboard state and
|
| 318 |
+
// click highlight stay in sync.
|
| 319 |
+
const flatIndex = items.indexOf(item);
|
| 320 |
+
return (
|
| 321 |
+
<button
|
| 322 |
+
key={`${group.section}:${item.title}`}
|
| 323 |
+
className={`slash-menu-item ${flatIndex === selectedIndex ? "is-selected" : ""}`}
|
| 324 |
+
onClick={() => selectItem(flatIndex)}
|
| 325 |
+
onMouseEnter={() => setSelectedIndex(flatIndex)}
|
| 326 |
+
>
|
| 327 |
+
<span className="slash-menu-item-icon">{item.icon}</span>
|
| 328 |
+
<span className="slash-menu-item-content">
|
| 329 |
+
<span className="slash-menu-item-title">{item.title}</span>
|
| 330 |
+
<span className="slash-menu-item-desc">{item.description}</span>
|
| 331 |
+
</span>
|
| 332 |
+
</button>
|
| 333 |
+
);
|
| 334 |
+
})}
|
| 335 |
+
</div>
|
| 336 |
))}
|
| 337 |
</div>
|
| 338 |
);
|
|
|
|
| 344 |
export function slashMenuSuggestion() {
|
| 345 |
return {
|
| 346 |
items: ({ query }: { query: string }) => {
|
| 347 |
+
const q = query.toLowerCase();
|
| 348 |
+
if (!q) return ITEMS;
|
| 349 |
+
// Match on title OR description so typing "image" surfaces
|
| 350 |
+
// both "Image" and "HTML Embed", etc.
|
| 351 |
+
return ITEMS.filter(
|
| 352 |
+
(item) =>
|
| 353 |
+
item.title.toLowerCase().includes(q) ||
|
| 354 |
+
item.description.toLowerCase().includes(q),
|
| 355 |
);
|
| 356 |
},
|
| 357 |
|
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Editor } from "@tiptap/core";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Frontend executor for Tiptap commands issued by the AI agent.
|
| 5 |
+
*
|
| 6 |
+
* The backend exposes a catalog of commands (see `backend/src/agent/tiptap-catalog.ts`).
|
| 7 |
+
* When the LLM calls one, `useAgentChat` receives the tool call and
|
| 8 |
+
* dispatches it here. Most commands map directly to
|
| 9 |
+
* `editor.chain().focus()[commandName](args).run()`; a few need
|
| 10 |
+
* specific handling (selectText, setLink).
|
| 11 |
+
*
|
| 12 |
+
* Returns a short human-readable status string that is sent back to the
|
| 13 |
+
* LLM as the tool result so it can plan its next step.
|
| 14 |
+
*
|
| 15 |
+
* Inspired by `tiptap-apcore`'s TiptapExecutor but intentionally tiny -
|
| 16 |
+
* we only cover the commands declared in the catalog.
|
| 17 |
+
*/
|
| 18 |
+
|
| 19 |
+
const SAFE_URL_PROTOCOLS = /^(https?|mailto|tel):/i;
|
| 20 |
+
const SAFE_URL_RELATIVE = /^[/#?]/;
|
| 21 |
+
|
| 22 |
+
function isSafeUrl(url: string): boolean {
|
| 23 |
+
const trimmed = url.trim();
|
| 24 |
+
return SAFE_URL_PROTOCOLS.test(trimmed) || SAFE_URL_RELATIVE.test(trimmed);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// ---------------------------------------------------------------------------
|
| 28 |
+
// selectText: semantic selection by text content
|
| 29 |
+
// ---------------------------------------------------------------------------
|
| 30 |
+
|
| 31 |
+
/**
|
| 32 |
+
* Extract all text from the ProseMirror document as a flat string,
|
| 33 |
+
* keeping a mapping from string offsets to document positions.
|
| 34 |
+
*
|
| 35 |
+
* Duplicated from the logic inside `useAgentChat.findTextPosition` so
|
| 36 |
+
* this module has no React dependency.
|
| 37 |
+
*/
|
| 38 |
+
function getDocTextWithPositions(editor: Editor): { text: string; map: number[] } {
|
| 39 |
+
const chunks: string[] = [];
|
| 40 |
+
const map: number[] = [];
|
| 41 |
+
|
| 42 |
+
editor.state.doc.descendants((node, pos) => {
|
| 43 |
+
if (node.isText && node.text) {
|
| 44 |
+
for (let i = 0; i < node.text.length; i++) {
|
| 45 |
+
map.push(pos + i);
|
| 46 |
+
chunks.push(node.text[i]);
|
| 47 |
+
}
|
| 48 |
+
} else if (node.isBlock && chunks.length > 0) {
|
| 49 |
+
map.push(pos);
|
| 50 |
+
chunks.push("\n");
|
| 51 |
+
}
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
return { text: chunks.join(""), map };
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
interface SelectTextResult {
|
| 58 |
+
found: boolean;
|
| 59 |
+
from?: number;
|
| 60 |
+
to?: number;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function handleSelectText(
|
| 64 |
+
editor: Editor,
|
| 65 |
+
args: {
|
| 66 |
+
text: string;
|
| 67 |
+
occurrence?: number;
|
| 68 |
+
contextBefore?: string;
|
| 69 |
+
contextAfter?: string;
|
| 70 |
+
},
|
| 71 |
+
): SelectTextResult {
|
| 72 |
+
const { text, occurrence = 1, contextBefore, contextAfter } = args;
|
| 73 |
+
if (!text) return { found: false };
|
| 74 |
+
|
| 75 |
+
const { text: docText, map } = getDocTextWithPositions(editor);
|
| 76 |
+
|
| 77 |
+
// Collect all candidate indices
|
| 78 |
+
const candidates: number[] = [];
|
| 79 |
+
let startIdx = 0;
|
| 80 |
+
while (true) {
|
| 81 |
+
const idx = docText.indexOf(text, startIdx);
|
| 82 |
+
if (idx === -1) break;
|
| 83 |
+
candidates.push(idx);
|
| 84 |
+
startIdx = idx + 1;
|
| 85 |
+
}
|
| 86 |
+
if (candidates.length === 0) return { found: false };
|
| 87 |
+
|
| 88 |
+
let chosenIdx: number;
|
| 89 |
+
if (contextBefore || contextAfter) {
|
| 90 |
+
let bestIdx = candidates[0];
|
| 91 |
+
let bestScore = -1;
|
| 92 |
+
for (const idx of candidates) {
|
| 93 |
+
let score = 0;
|
| 94 |
+
if (contextBefore) {
|
| 95 |
+
const before = docText.slice(Math.max(0, idx - contextBefore.length - 10), idx);
|
| 96 |
+
if (before.includes(contextBefore)) score += 2;
|
| 97 |
+
else if (before.toLowerCase().includes(contextBefore.toLowerCase())) score += 1;
|
| 98 |
+
}
|
| 99 |
+
if (contextAfter) {
|
| 100 |
+
const after = docText.slice(idx + text.length, idx + text.length + contextAfter.length + 10);
|
| 101 |
+
if (after.includes(contextAfter)) score += 2;
|
| 102 |
+
else if (after.toLowerCase().includes(contextAfter.toLowerCase())) score += 1;
|
| 103 |
+
}
|
| 104 |
+
if (score > bestScore) {
|
| 105 |
+
bestScore = score;
|
| 106 |
+
bestIdx = idx;
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
chosenIdx = bestIdx;
|
| 110 |
+
} else {
|
| 111 |
+
const occIdx = Math.max(0, Math.min(occurrence - 1, candidates.length - 1));
|
| 112 |
+
chosenIdx = candidates[occIdx];
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
const from = map[chosenIdx];
|
| 116 |
+
const to = map[chosenIdx + text.length - 1] + 1;
|
| 117 |
+
editor.chain().focus().setTextSelection({ from, to }).run();
|
| 118 |
+
return { found: true, from, to };
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
// ---------------------------------------------------------------------------
|
| 122 |
+
// Catalog command dispatch
|
| 123 |
+
// ---------------------------------------------------------------------------
|
| 124 |
+
|
| 125 |
+
/**
|
| 126 |
+
* Tiptap commands that take no arguments. Called as
|
| 127 |
+
* `editor.chain().focus()[name]().run()`.
|
| 128 |
+
*/
|
| 129 |
+
const EMPTY_ARG_COMMANDS = new Set([
|
| 130 |
+
"toggleBold",
|
| 131 |
+
"toggleItalic",
|
| 132 |
+
"toggleStrike",
|
| 133 |
+
"toggleCode",
|
| 134 |
+
"unsetLink",
|
| 135 |
+
"setParagraph",
|
| 136 |
+
"toggleBulletList",
|
| 137 |
+
"toggleOrderedList",
|
| 138 |
+
"toggleBlockquote",
|
| 139 |
+
]);
|
| 140 |
+
|
| 141 |
+
/**
|
| 142 |
+
* Map a tool call to an `editor.chain()` invocation.
|
| 143 |
+
*
|
| 144 |
+
* Returns null when the tool name is not in the Tiptap catalog (so the
|
| 145 |
+
* caller can fall back to its own handling for applyDiff, frontmatter, etc.).
|
| 146 |
+
*/
|
| 147 |
+
export function executeTiptapCommand(
|
| 148 |
+
editor: Editor,
|
| 149 |
+
toolName: string,
|
| 150 |
+
args: Record<string, unknown>,
|
| 151 |
+
): string | null {
|
| 152 |
+
// selectText is not a native Tiptap command.
|
| 153 |
+
if (toolName === "selectText") {
|
| 154 |
+
const result = handleSelectText(editor, args as Parameters<typeof handleSelectText>[1]);
|
| 155 |
+
if (!result.found) {
|
| 156 |
+
return `selectText: text not found in the document`;
|
| 157 |
+
}
|
| 158 |
+
return `selectText: selected range ${result.from}-${result.to}`;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
// Empty-arg commands - just call them directly.
|
| 162 |
+
if (EMPTY_ARG_COMMANDS.has(toolName)) {
|
| 163 |
+
const chain = editor.chain().focus();
|
| 164 |
+
const fn = (chain as unknown as Record<string, () => typeof chain>)[toolName];
|
| 165 |
+
if (typeof fn !== "function") {
|
| 166 |
+
return `Unknown editor command: ${toolName}`;
|
| 167 |
+
}
|
| 168 |
+
const success = fn.call(chain).run();
|
| 169 |
+
return success ? `${toolName} applied` : `${toolName} failed (selection state?)`;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
// Commands with specific argument shapes.
|
| 173 |
+
switch (toolName) {
|
| 174 |
+
case "toggleHeading": {
|
| 175 |
+
const level = (args as { level?: number }).level;
|
| 176 |
+
if (!level || level < 1 || level > 3) return "toggleHeading: invalid level";
|
| 177 |
+
const ok = editor
|
| 178 |
+
.chain()
|
| 179 |
+
.focus()
|
| 180 |
+
.toggleHeading({ level: level as 1 | 2 | 3 })
|
| 181 |
+
.run();
|
| 182 |
+
return ok ? `toggleHeading (level ${level}) applied` : "toggleHeading failed";
|
| 183 |
+
}
|
| 184 |
+
case "toggleCodeBlock": {
|
| 185 |
+
const language = (args as { language?: string }).language;
|
| 186 |
+
const chain = editor.chain().focus();
|
| 187 |
+
const ok = language
|
| 188 |
+
? chain.toggleCodeBlock({ language }).run()
|
| 189 |
+
: chain.toggleCodeBlock().run();
|
| 190 |
+
return ok ? "toggleCodeBlock applied" : "toggleCodeBlock failed";
|
| 191 |
+
}
|
| 192 |
+
case "setLink": {
|
| 193 |
+
const href = (args as { href?: string }).href;
|
| 194 |
+
if (!href || !isSafeUrl(href)) return `setLink: unsafe or missing URL`;
|
| 195 |
+
const ok = editor.chain().focus().setLink({ href }).run();
|
| 196 |
+
return ok ? `setLink applied (${href})` : "setLink failed (is text selected?)";
|
| 197 |
+
}
|
| 198 |
+
default:
|
| 199 |
+
return null;
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
/** Tool names handled by `executeTiptapCommand`, useful for dispatch. */
|
| 204 |
+
export const TIPTAP_TOOL_NAMES = new Set<string>([
|
| 205 |
+
"selectText",
|
| 206 |
+
"toggleBold",
|
| 207 |
+
"toggleItalic",
|
| 208 |
+
"toggleStrike",
|
| 209 |
+
"toggleCode",
|
| 210 |
+
"setLink",
|
| 211 |
+
"unsetLink",
|
| 212 |
+
"toggleHeading",
|
| 213 |
+
"setParagraph",
|
| 214 |
+
"toggleBulletList",
|
| 215 |
+
"toggleOrderedList",
|
| 216 |
+
"toggleBlockquote",
|
| 217 |
+
"toggleCodeBlock",
|
| 218 |
+
]);
|
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as Y from "yjs";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Metadata describing a data file uploaded into the embed data store.
|
| 5 |
+
*
|
| 6 |
+
* Kept intentionally lean (strings + small numbers) so it can live
|
| 7 |
+
* alongside the raw content inside a single Y.Map entry without
|
| 8 |
+
* bloating the document size.
|
| 9 |
+
*/
|
| 10 |
+
export interface EmbedDataFileMeta {
|
| 11 |
+
name: string;
|
| 12 |
+
/** Extension without leading dot, lowercased: "csv" | "tsv" | "json" | "txt" */
|
| 13 |
+
ext: string;
|
| 14 |
+
/** Size of the stored text content in bytes (UTF-8). */
|
| 15 |
+
size: number;
|
| 16 |
+
/** Uploader user id (if available). */
|
| 17 |
+
uploader?: string;
|
| 18 |
+
/** Epoch ms when the file was added. */
|
| 19 |
+
addedAt: number;
|
| 20 |
+
/** Light metadata computed client-side after parsing. */
|
| 21 |
+
rowCount?: number;
|
| 22 |
+
columns?: string[];
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export interface EmbedDataFile {
|
| 26 |
+
meta: EmbedDataFileMeta;
|
| 27 |
+
/** Raw text content (UTF-8). */
|
| 28 |
+
content: string;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
/**
|
| 32 |
+
* Collaborative store for tabular/textual data files attached to a
|
| 33 |
+
* document. Each key is a filename (e.g. "sales.csv") and the value is
|
| 34 |
+
* an `EmbedDataFile` object (meta + raw text content).
|
| 35 |
+
*
|
| 36 |
+
* These files are accessible to the Embed Studio AI via tool calls so
|
| 37 |
+
* charts can reference user-provided datasets. The store is backed by
|
| 38 |
+
* `Y.Map("embed-data")` for realtime collaboration.
|
| 39 |
+
*/
|
| 40 |
+
export function createEmbedDataStore(ydoc: Y.Doc) {
|
| 41 |
+
const ymap = ydoc.getMap<EmbedDataFile>("embed-data");
|
| 42 |
+
|
| 43 |
+
function get(name: string): EmbedDataFile | undefined {
|
| 44 |
+
return ymap.get(name);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function getContent(name: string): string {
|
| 48 |
+
return ymap.get(name)?.content ?? "";
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function getMeta(name: string): EmbedDataFileMeta | undefined {
|
| 52 |
+
return ymap.get(name)?.meta;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function has(name: string): boolean {
|
| 56 |
+
return ymap.has(name);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function set(file: EmbedDataFile) {
|
| 60 |
+
ymap.set(file.meta.name, file);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function remove(name: string) {
|
| 64 |
+
ymap.delete(name);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
function rename(oldName: string, newName: string) {
|
| 68 |
+
const file = ymap.get(oldName);
|
| 69 |
+
if (!file) return;
|
| 70 |
+
ydoc.transact(() => {
|
| 71 |
+
ymap.set(newName, { ...file, meta: { ...file.meta, name: newName } });
|
| 72 |
+
ymap.delete(oldName);
|
| 73 |
+
});
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function keys(): string[] {
|
| 77 |
+
return Array.from(ymap.keys());
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function list(): EmbedDataFileMeta[] {
|
| 81 |
+
const items: EmbedDataFileMeta[] = [];
|
| 82 |
+
ymap.forEach((file) => {
|
| 83 |
+
items.push(file.meta);
|
| 84 |
+
});
|
| 85 |
+
return items.sort((a, b) => a.name.localeCompare(b.name));
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
function observe(callback: () => void) {
|
| 89 |
+
ymap.observe(callback);
|
| 90 |
+
return () => ymap.unobserve(callback);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
return {
|
| 94 |
+
get,
|
| 95 |
+
getContent,
|
| 96 |
+
getMeta,
|
| 97 |
+
has,
|
| 98 |
+
set,
|
| 99 |
+
remove,
|
| 100 |
+
rename,
|
| 101 |
+
keys,
|
| 102 |
+
list,
|
| 103 |
+
observe,
|
| 104 |
+
};
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
export type EmbedDataStore = ReturnType<typeof createEmbedDataStore>;
|
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Broadcasts "this user's AI agent is currently focused on this range" to
|
| 3 |
+
* every collaborator via Yjs awareness, and renders it as a ProseMirror
|
| 4 |
+
* decoration tinted with the owner's color.
|
| 5 |
+
*
|
| 6 |
+
* Design notes
|
| 7 |
+
* ------------
|
| 8 |
+
* - We combine two Tiptap patterns that already exist separately in the
|
| 9 |
+
* ecosystem:
|
| 10 |
+
* 1. the paid `@tiptap-pro/ai-toolkit` `setActiveSelection` feature,
|
| 11 |
+
* which freezes a range locally while the agent is working on it;
|
| 12 |
+
* 2. `@tiptap/extension-collaboration-caret`, which shares cursor
|
| 13 |
+
* positions across peers via Yjs awareness using relative
|
| 14 |
+
* positions.
|
| 15 |
+
*
|
| 16 |
+
* - Ranges are stored in awareness as **Y.js relative positions**
|
| 17 |
+
* (JSON form for safe transport) so they remain meaningful on every
|
| 18 |
+
* peer's doc even as Bob or Carol are typing concurrently. We resolve
|
| 19 |
+
* them back to absolute positions at decoration time.
|
| 20 |
+
*
|
| 21 |
+
* - Every peer (including self) gets both the inline tint AND the
|
| 22 |
+
* floating "<Name> agent" label. When the chat panel is open the
|
| 23 |
+
* editor often loses DOM focus to the chat textarea, which makes
|
| 24 |
+
* the native `::selection` fade or disappear: the PM decoration is
|
| 25 |
+
* then the ONLY thing keeping the range visible on the owner's
|
| 26 |
+
* screen. To avoid stacking two tints when the editor still has
|
| 27 |
+
* focus, `_ui.css` hides the native `::selection` inside
|
| 28 |
+
* `.ProseMirror` while `.editor-app--chat-open` is set.
|
| 29 |
+
*
|
| 30 |
+
* Usage (from App.tsx):
|
| 31 |
+
* editor.commands.setAgentFocus({ from, to }); // on chat open
|
| 32 |
+
* editor.commands.clearAgentFocus(); // on chat close
|
| 33 |
+
*/
|
| 34 |
+
import { Extension } from "@tiptap/core";
|
| 35 |
+
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
| 36 |
+
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
| 37 |
+
import {
|
| 38 |
+
absolutePositionToRelativePosition,
|
| 39 |
+
relativePositionToAbsolutePosition,
|
| 40 |
+
ySyncPluginKey,
|
| 41 |
+
} from "@tiptap/y-tiptap";
|
| 42 |
+
import * as Y from "yjs";
|
| 43 |
+
|
| 44 |
+
export interface AgentFocusUser {
|
| 45 |
+
name: string;
|
| 46 |
+
color: string;
|
| 47 |
+
avatarUrl?: string;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
export interface AgentFocusOptions {
|
| 51 |
+
// Any Yjs provider with a `.awareness` API (Hocuspocus, y-websocket, ...)
|
| 52 |
+
provider: any;
|
| 53 |
+
user: AgentFocusUser;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
interface AwarenessFocus {
|
| 57 |
+
anchor: unknown;
|
| 58 |
+
head: unknown;
|
| 59 |
+
user: AgentFocusUser;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
export const agentFocusPluginKey = new PluginKey("agent-focus");
|
| 63 |
+
|
| 64 |
+
declare module "@tiptap/core" {
|
| 65 |
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
| 66 |
+
interface Commands<ReturnType> {
|
| 67 |
+
agentFocus: {
|
| 68 |
+
/** Mark `[from, to]` as "my agent is working on this". */
|
| 69 |
+
setAgentFocus: (args: { from: number; to: number }) => ReturnType;
|
| 70 |
+
/** Remove our own agent-focus highlight. */
|
| 71 |
+
clearAgentFocus: () => ReturnType;
|
| 72 |
+
/** Update the awareness user (name / color / avatar). */
|
| 73 |
+
updateAgentFocusUser: (user: AgentFocusUser) => ReturnType;
|
| 74 |
+
};
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
export const AgentFocus = Extension.create<AgentFocusOptions>({
|
| 79 |
+
name: "agentFocus",
|
| 80 |
+
|
| 81 |
+
addOptions() {
|
| 82 |
+
return {
|
| 83 |
+
provider: null,
|
| 84 |
+
user: { name: "Anonymous", color: "#5c7cfa" },
|
| 85 |
+
};
|
| 86 |
+
},
|
| 87 |
+
|
| 88 |
+
onCreate() {
|
| 89 |
+
if (!this.options.provider) {
|
| 90 |
+
throw new Error('The "provider" option is required for AgentFocus');
|
| 91 |
+
}
|
| 92 |
+
},
|
| 93 |
+
|
| 94 |
+
addCommands() {
|
| 95 |
+
// None of these commands mutate the ProseMirror document - they only
|
| 96 |
+
// touch Yjs awareness. That's what guarantees they stay out of the
|
| 97 |
+
// undo stack: `Y.UndoManager` tracks CRDT ops, awareness is
|
| 98 |
+
// ephemeral metadata. Tiptap will still dispatch an empty transaction
|
| 99 |
+
// after each command returns `true`, but an empty tr has
|
| 100 |
+
// `tr.docChanged === false`, so y-sync ignores it and undo is clean.
|
| 101 |
+
return {
|
| 102 |
+
setAgentFocus:
|
| 103 |
+
({ from, to }) =>
|
| 104 |
+
({ state }) => {
|
| 105 |
+
const ystate = ySyncPluginKey.getState(state);
|
| 106 |
+
if (!ystate?.binding) return false;
|
| 107 |
+
|
| 108 |
+
const size = state.doc.content.size;
|
| 109 |
+
const a = Math.max(0, Math.min(from, size));
|
| 110 |
+
const b = Math.max(0, Math.min(to, size));
|
| 111 |
+
if (a === b) return false;
|
| 112 |
+
const [lo, hi] = a < b ? [a, b] : [b, a];
|
| 113 |
+
|
| 114 |
+
const anchor = absolutePositionToRelativePosition(
|
| 115 |
+
lo,
|
| 116 |
+
ystate.type,
|
| 117 |
+
ystate.binding.mapping,
|
| 118 |
+
);
|
| 119 |
+
const head = absolutePositionToRelativePosition(
|
| 120 |
+
hi,
|
| 121 |
+
ystate.type,
|
| 122 |
+
ystate.binding.mapping,
|
| 123 |
+
);
|
| 124 |
+
|
| 125 |
+
this.options.provider.awareness.setLocalStateField("agentFocus", {
|
| 126 |
+
anchor: Y.relativePositionToJSON(anchor),
|
| 127 |
+
head: Y.relativePositionToJSON(head),
|
| 128 |
+
user: this.options.user,
|
| 129 |
+
});
|
| 130 |
+
return true;
|
| 131 |
+
},
|
| 132 |
+
|
| 133 |
+
clearAgentFocus: () => () => {
|
| 134 |
+
this.options.provider.awareness.setLocalStateField("agentFocus", null);
|
| 135 |
+
return true;
|
| 136 |
+
},
|
| 137 |
+
|
| 138 |
+
updateAgentFocusUser: (user) => () => {
|
| 139 |
+
this.options.user = user;
|
| 140 |
+
// Re-emit current focus with the updated user payload, if any.
|
| 141 |
+
const current = this.options.provider.awareness.getLocalState()
|
| 142 |
+
?.agentFocus as AwarenessFocus | null | undefined;
|
| 143 |
+
if (current?.anchor && current?.head) {
|
| 144 |
+
this.options.provider.awareness.setLocalStateField("agentFocus", {
|
| 145 |
+
...current,
|
| 146 |
+
user,
|
| 147 |
+
});
|
| 148 |
+
}
|
| 149 |
+
return true;
|
| 150 |
+
},
|
| 151 |
+
};
|
| 152 |
+
},
|
| 153 |
+
|
| 154 |
+
addProseMirrorPlugins() {
|
| 155 |
+
const awareness = this.options.provider.awareness;
|
| 156 |
+
|
| 157 |
+
return [
|
| 158 |
+
new Plugin({
|
| 159 |
+
key: agentFocusPluginKey,
|
| 160 |
+
state: {
|
| 161 |
+
init: () => ({ stamp: 0 }),
|
| 162 |
+
apply(tr, prev) {
|
| 163 |
+
if (tr.getMeta(agentFocusPluginKey)) {
|
| 164 |
+
return { stamp: prev.stamp + 1 };
|
| 165 |
+
}
|
| 166 |
+
return prev;
|
| 167 |
+
},
|
| 168 |
+
},
|
| 169 |
+
props: {
|
| 170 |
+
decorations(state) {
|
| 171 |
+
const ystate = ySyncPluginKey.getState(state);
|
| 172 |
+
if (!ystate?.binding) return null;
|
| 173 |
+
|
| 174 |
+
const decorations: Decoration[] = [];
|
| 175 |
+
const localClientId = awareness.clientID;
|
| 176 |
+
|
| 177 |
+
awareness.getStates().forEach((s: any, clientId: number) => {
|
| 178 |
+
const focus = s?.agentFocus as
|
| 179 |
+
| AwarenessFocus
|
| 180 |
+
| null
|
| 181 |
+
| undefined;
|
| 182 |
+
if (!focus || !focus.anchor || !focus.head) return;
|
| 183 |
+
const isSelf = clientId === localClientId;
|
| 184 |
+
|
| 185 |
+
let from: number | null = null;
|
| 186 |
+
let to: number | null = null;
|
| 187 |
+
try {
|
| 188 |
+
from = relativePositionToAbsolutePosition(
|
| 189 |
+
ystate.doc,
|
| 190 |
+
ystate.type,
|
| 191 |
+
Y.createRelativePositionFromJSON(focus.anchor),
|
| 192 |
+
ystate.binding.mapping,
|
| 193 |
+
);
|
| 194 |
+
to = relativePositionToAbsolutePosition(
|
| 195 |
+
ystate.doc,
|
| 196 |
+
ystate.type,
|
| 197 |
+
Y.createRelativePositionFromJSON(focus.head),
|
| 198 |
+
ystate.binding.mapping,
|
| 199 |
+
);
|
| 200 |
+
} catch {
|
| 201 |
+
return;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
if (from == null || to == null || from === to) return;
|
| 205 |
+
const [lo, hi] = from < to ? [from, to] : [to, from];
|
| 206 |
+
|
| 207 |
+
const color = focus.user?.color || "#5c7cfa";
|
| 208 |
+
const name = focus.user?.name || "";
|
| 209 |
+
// Match Tiptap's `defaultSelectionBuilder` exactly: a flat
|
| 210 |
+
// `{color}70` tint (hex + alpha), no underline, no shadow -
|
| 211 |
+
// so an agent focus is visually the same as a collaborator
|
| 212 |
+
// selection, only the label name ("… agent") tells them apart.
|
| 213 |
+
const style = `background-color: ${color}70`;
|
| 214 |
+
|
| 215 |
+
decorations.push(
|
| 216 |
+
Decoration.inline(
|
| 217 |
+
lo,
|
| 218 |
+
hi,
|
| 219 |
+
{
|
| 220 |
+
class: `agent-focus${isSelf ? " agent-focus--self" : ""}`,
|
| 221 |
+
style,
|
| 222 |
+
"data-agent-focus-user": name,
|
| 223 |
+
"data-agent-focus-color": color,
|
| 224 |
+
} as any,
|
| 225 |
+
// inclusiveStart:false / inclusiveEnd:false -> typing
|
| 226 |
+
// at either edge should not extend the highlight, the
|
| 227 |
+
// Y.js relative position already pins the true
|
| 228 |
+
// boundary.
|
| 229 |
+
{ inclusiveStart: false, inclusiveEnd: false } as any,
|
| 230 |
+
),
|
| 231 |
+
);
|
| 232 |
+
|
| 233 |
+
// Floating label anchored at the END of the range, i.e.
|
| 234 |
+
// on the last line of the selection at the cursor
|
| 235 |
+
// position. This mirrors `.collaboration-cursor__label`,
|
| 236 |
+
// which also renders on the right of the last line (at
|
| 237 |
+
// the selection head). Using `hi` instead of `lo` keeps
|
| 238 |
+
// the badge visually coupled to "where the agent's cursor
|
| 239 |
+
// would sit if it were typing right now".
|
| 240 |
+
decorations.push(
|
| 241 |
+
Decoration.widget(
|
| 242 |
+
hi,
|
| 243 |
+
() => {
|
| 244 |
+
const anchor = document.createElement("span");
|
| 245 |
+
anchor.className = "agent-focus__anchor";
|
| 246 |
+
|
| 247 |
+
const label = document.createElement("span");
|
| 248 |
+
label.className = "agent-focus__label";
|
| 249 |
+
label.setAttribute(
|
| 250 |
+
"style",
|
| 251 |
+
`background-color: ${color}`,
|
| 252 |
+
);
|
| 253 |
+
|
| 254 |
+
const avatarUrl = focus.user?.avatarUrl;
|
| 255 |
+
if (avatarUrl) {
|
| 256 |
+
const avatar = document.createElement("img");
|
| 257 |
+
avatar.src = avatarUrl;
|
| 258 |
+
avatar.className = "agent-focus__avatar";
|
| 259 |
+
avatar.alt = "";
|
| 260 |
+
label.appendChild(avatar);
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
const text = document.createElement("span");
|
| 264 |
+
text.className = "agent-focus__text";
|
| 265 |
+
text.textContent = `${name || "Someone"} agent`;
|
| 266 |
+
label.appendChild(text);
|
| 267 |
+
|
| 268 |
+
anchor.appendChild(label);
|
| 269 |
+
return anchor;
|
| 270 |
+
},
|
| 271 |
+
{
|
| 272 |
+
// `side: 1` -> the widget stays on the RIGHT side of
|
| 273 |
+
// the `hi` position, so if the user types inside the
|
| 274 |
+
// range the label doesn't get pushed into the middle
|
| 275 |
+
// of new content. `key` lets PM reuse the DOM on
|
| 276 |
+
// every awareness tick instead of re-creating it.
|
| 277 |
+
side: 1,
|
| 278 |
+
key: `agent-focus-label-${clientId}-${name}`,
|
| 279 |
+
} as any,
|
| 280 |
+
),
|
| 281 |
+
);
|
| 282 |
+
});
|
| 283 |
+
|
| 284 |
+
if (decorations.length === 0) return null;
|
| 285 |
+
return DecorationSet.create(state.doc, decorations);
|
| 286 |
+
},
|
| 287 |
+
},
|
| 288 |
+
view(view) {
|
| 289 |
+
// `change` fires only when an awareness state is actually added,
|
| 290 |
+
// updated, or removed - NOT on heartbeat ticks. Listening to
|
| 291 |
+
// `update` instead (as some examples do) would trigger a
|
| 292 |
+
// re-render every ~30s for nothing.
|
| 293 |
+
//
|
| 294 |
+
// The dispatched transaction is meta-only: no steps, no doc
|
| 295 |
+
// change, no selection change. y-sync ignores it because
|
| 296 |
+
// `tr.docChanged` is false, and the Yjs UndoManager only
|
| 297 |
+
// tracks CRDT operations - so this never pollutes the undo
|
| 298 |
+
// stack. Agent-focus is 100% ephemeral decoration state.
|
| 299 |
+
const handler = () => {
|
| 300 |
+
if (view.isDestroyed) return;
|
| 301 |
+
view.dispatch(
|
| 302 |
+
view.state.tr.setMeta(agentFocusPluginKey, { refresh: true }),
|
| 303 |
+
);
|
| 304 |
+
};
|
| 305 |
+
awareness.on("change", handler);
|
| 306 |
+
return {
|
| 307 |
+
destroy() {
|
| 308 |
+
awareness.off("change", handler);
|
| 309 |
+
},
|
| 310 |
+
};
|
| 311 |
+
},
|
| 312 |
+
}),
|
| 313 |
+
];
|
| 314 |
+
},
|
| 315 |
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Demo-only Tiptap extension that lets the Playwright showcase script mark a
|
| 3 |
+
* paragraph as "the one an agent is currently working on" and have the
|
| 4 |
+
* highlight survive any amount of concurrent Yjs syncing, remote typing,
|
| 5 |
+
* or local selection changes.
|
| 6 |
+
*
|
| 7 |
+
* Why a ProseMirror decoration and not a DOM class?
|
| 8 |
+
* -------------------------------------------------
|
| 9 |
+
* In a Tiptap + Yjs editor, the DOM for a paragraph can be torn down and
|
| 10 |
+
* re-created at any time: every remote update from Bob or Carol triggers
|
| 11 |
+
* a sync transaction, and PM may decide to rebuild the `<p>` (especially
|
| 12 |
+
* under the collaboration plugin). Any `className` we slap on the `<p>`
|
| 13 |
+
* is lost on the next render, which is why previous versions of the
|
| 14 |
+
* demo relied on a 150 ms polling timer to re-apply the class. That
|
| 15 |
+
* worked, but it flickers and leaves a visible gap between "selection
|
| 16 |
+
* happens" and "class comes back".
|
| 17 |
+
*
|
| 18 |
+
* ProseMirror decorations solve this at the right layer: they live in
|
| 19 |
+
* editor state, are re-rendered on every view update, and are remapped
|
| 20 |
+
* through doc changes automatically. Here we store a `{ from, to, phase }`
|
| 21 |
+
* range in plugin state, decorate the paragraph containing `from` with
|
| 22 |
+
* the appropriate `demo-agent-{pending|rewriting}` class, and let PM do
|
| 23 |
+
* the rest.
|
| 24 |
+
*
|
| 25 |
+
* Usage (from the demo script or App.tsx):
|
| 26 |
+
* editor.commands.setAgentHighlight({ from, to, phase: "pending" });
|
| 27 |
+
* editor.commands.setAgentHighlight({ phase: "rewriting" });
|
| 28 |
+
* editor.commands.clearAgentHighlight();
|
| 29 |
+
*/
|
| 30 |
+
import { Extension } from "@tiptap/core";
|
| 31 |
+
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
| 32 |
+
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
| 33 |
+
|
| 34 |
+
export type AgentHighlightPhase = "pending" | "rewriting";
|
| 35 |
+
|
| 36 |
+
interface AgentHighlightState {
|
| 37 |
+
from: number | null;
|
| 38 |
+
to: number | null;
|
| 39 |
+
phase: AgentHighlightPhase | null;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
interface AgentHighlightMeta {
|
| 43 |
+
from?: number | null;
|
| 44 |
+
to?: number | null;
|
| 45 |
+
phase?: AgentHighlightPhase | null;
|
| 46 |
+
clear?: boolean;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
export const agentHighlightPluginKey =
|
| 50 |
+
new PluginKey<AgentHighlightState>("agent-highlight");
|
| 51 |
+
|
| 52 |
+
declare module "@tiptap/core" {
|
| 53 |
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
| 54 |
+
interface Commands<ReturnType> {
|
| 55 |
+
agentHighlight: {
|
| 56 |
+
/**
|
| 57 |
+
* Set (or update) the highlighted range and phase. Omitting
|
| 58 |
+
* `from`/`to` keeps the existing range; omitting `phase` keeps
|
| 59 |
+
* the existing phase. Both can be updated independently so the
|
| 60 |
+
* App.tsx rewrite handler can flip `pending -> rewriting` without
|
| 61 |
+
* recomputing the range.
|
| 62 |
+
*/
|
| 63 |
+
setAgentHighlight: (args: {
|
| 64 |
+
from?: number;
|
| 65 |
+
to?: number;
|
| 66 |
+
phase?: AgentHighlightPhase;
|
| 67 |
+
}) => ReturnType;
|
| 68 |
+
/** Remove any active highlight. */
|
| 69 |
+
clearAgentHighlight: () => ReturnType;
|
| 70 |
+
};
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
export const AgentHighlight = Extension.create({
|
| 75 |
+
name: "agentHighlight",
|
| 76 |
+
|
| 77 |
+
addProseMirrorPlugins() {
|
| 78 |
+
return [
|
| 79 |
+
new Plugin<AgentHighlightState>({
|
| 80 |
+
key: agentHighlightPluginKey,
|
| 81 |
+
state: {
|
| 82 |
+
init: (): AgentHighlightState => ({
|
| 83 |
+
from: null,
|
| 84 |
+
to: null,
|
| 85 |
+
phase: null,
|
| 86 |
+
}),
|
| 87 |
+
apply(tr, prev): AgentHighlightState {
|
| 88 |
+
const meta = tr.getMeta(agentHighlightPluginKey) as
|
| 89 |
+
| AgentHighlightMeta
|
| 90 |
+
| undefined;
|
| 91 |
+
if (meta) {
|
| 92 |
+
if (meta.clear) {
|
| 93 |
+
return { from: null, to: null, phase: null };
|
| 94 |
+
}
|
| 95 |
+
return {
|
| 96 |
+
from: meta.from !== undefined ? meta.from : prev.from,
|
| 97 |
+
to: meta.to !== undefined ? meta.to : prev.to,
|
| 98 |
+
phase: meta.phase !== undefined ? meta.phase : prev.phase,
|
| 99 |
+
};
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
if (prev.from !== null && prev.to !== null) {
|
| 103 |
+
const from = tr.mapping.map(prev.from, 1);
|
| 104 |
+
const to = tr.mapping.map(prev.to, -1);
|
| 105 |
+
if (to > from) {
|
| 106 |
+
if (from === prev.from && to === prev.to) return prev;
|
| 107 |
+
return { ...prev, from, to };
|
| 108 |
+
}
|
| 109 |
+
return { from: null, to: null, phase: null };
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
return prev;
|
| 113 |
+
},
|
| 114 |
+
},
|
| 115 |
+
props: {
|
| 116 |
+
decorations(state) {
|
| 117 |
+
const s = agentHighlightPluginKey.getState(state);
|
| 118 |
+
if (!s || s.from === null || s.to === null || !s.phase) {
|
| 119 |
+
return null;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
let $pos;
|
| 123 |
+
try {
|
| 124 |
+
$pos = state.doc.resolve(s.from);
|
| 125 |
+
} catch {
|
| 126 |
+
return null;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
const depth = $pos.depth;
|
| 130 |
+
if (depth <= 0) return null;
|
| 131 |
+
|
| 132 |
+
const nodeStart = $pos.before(depth);
|
| 133 |
+
const nodeEnd = $pos.after(depth);
|
| 134 |
+
const cls = `demo-agent-${s.phase}`;
|
| 135 |
+
|
| 136 |
+
return DecorationSet.create(state.doc, [
|
| 137 |
+
Decoration.node(nodeStart, nodeEnd, { class: cls }),
|
| 138 |
+
]);
|
| 139 |
+
},
|
| 140 |
+
},
|
| 141 |
+
}),
|
| 142 |
+
];
|
| 143 |
+
},
|
| 144 |
+
|
| 145 |
+
addCommands() {
|
| 146 |
+
return {
|
| 147 |
+
setAgentHighlight:
|
| 148 |
+
(args) =>
|
| 149 |
+
({ tr, dispatch }) => {
|
| 150 |
+
if (dispatch) {
|
| 151 |
+
tr.setMeta(agentHighlightPluginKey, args);
|
| 152 |
+
}
|
| 153 |
+
return true;
|
| 154 |
+
},
|
| 155 |
+
clearAgentHighlight:
|
| 156 |
+
() =>
|
| 157 |
+
({ tr, dispatch }) => {
|
| 158 |
+
if (dispatch) {
|
| 159 |
+
tr.setMeta(agentHighlightPluginKey, { clear: true });
|
| 160 |
+
}
|
| 161 |
+
return true;
|
| 162 |
+
},
|
| 163 |
+
};
|
| 164 |
+
},
|
| 165 |
+
});
|
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* AgentRewrite
|
| 3 |
+
* ============
|
| 4 |
+
*
|
| 5 |
+
* One canonical Tiptap command to perform an agent-driven replacement
|
| 6 |
+
* of a range in the document - optionally with a typewriter animation
|
| 7 |
+
* so the user sees the agent "typing" the new content in place of the
|
| 8 |
+
* old one.
|
| 9 |
+
*
|
| 10 |
+
* Called by:
|
| 11 |
+
* - the real agent's tool calls (applyDiff / rewrite / rephrase),
|
| 12 |
+
* - the Playwright showcase demo script via the `__demo-chat` event.
|
| 13 |
+
*
|
| 14 |
+
* Why this lives in an extension (and not in App.tsx or the demo)
|
| 15 |
+
* ---------------------------------------------------------------
|
| 16 |
+
* The previous design had an ad-hoc `setTimeout` loop in App.tsx that
|
| 17 |
+
* re-inserted characters at `startPos + i` - a fixed numeric origin.
|
| 18 |
+
* Under Yjs collaboration any concurrent remote edit that shifted
|
| 19 |
+
* `startPos` (e.g. another user inserting a character before it) would
|
| 20 |
+
* desync the animation and land subsequent chunks inside the wrong
|
| 21 |
+
* paragraph, producing a visible "write-over-itself" artifact.
|
| 22 |
+
*
|
| 23 |
+
* Here we track the insertion `cursor` in PM plugin state and remap it
|
| 24 |
+
* through `tr.mapping` on every transaction - local OR remote. This
|
| 25 |
+
* guarantees the typewriter resumes at the correct absolute position
|
| 26 |
+
* no matter what Bob or Carol are doing elsewhere in the doc.
|
| 27 |
+
*
|
| 28 |
+
* Cancellation
|
| 29 |
+
* ------------
|
| 30 |
+
* Each call to `agentRewriteRange` bumps a cancel token stored in
|
| 31 |
+
* plugin state. Any still-scheduled animation step that observes a
|
| 32 |
+
* different token aborts. That way, if the agent fires two
|
| 33 |
+
* overlapping rewrites, the first one stops immediately instead of
|
| 34 |
+
* fighting the second for control of the doc.
|
| 35 |
+
*
|
| 36 |
+
* Undo-friendliness
|
| 37 |
+
* -----------------
|
| 38 |
+
* Every animation step is a small atomic `insertText` transaction,
|
| 39 |
+
* so y-undo sees a tidy sequence of ReplaceSteps instead of one huge
|
| 40 |
+
* debug-hostile mega-op. Undo works as expected (one step per chunk).
|
| 41 |
+
*/
|
| 42 |
+
import { Extension } from "@tiptap/core";
|
| 43 |
+
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
| 44 |
+
|
| 45 |
+
export type AgentRewriteAnimation = "none" | "typewriter";
|
| 46 |
+
|
| 47 |
+
export interface AgentRewriteArgs {
|
| 48 |
+
/** Absolute PM position of the start of the range to replace. */
|
| 49 |
+
from: number;
|
| 50 |
+
/** Absolute PM position of the end of the range to replace. */
|
| 51 |
+
to: number;
|
| 52 |
+
/** New text to insert in place of the deleted range. */
|
| 53 |
+
text: string;
|
| 54 |
+
/**
|
| 55 |
+
* "none" -> replace the range atomically in one transaction.
|
| 56 |
+
* "typewriter" -> stream the replacement chunk-by-chunk with small
|
| 57 |
+
* delays so the UI shows the agent "typing".
|
| 58 |
+
* Default: "none" (safer for programmatic / non-demo callers).
|
| 59 |
+
*/
|
| 60 |
+
animation?: AgentRewriteAnimation;
|
| 61 |
+
/**
|
| 62 |
+
* Characters per animation tick. A random int in [min,max] is chosen
|
| 63 |
+
* each step, which makes the typing feel organic. Default [1, 2].
|
| 64 |
+
*/
|
| 65 |
+
chunkSize?: [number, number];
|
| 66 |
+
/**
|
| 67 |
+
* Milliseconds between animation ticks. Default [22, 48].
|
| 68 |
+
*/
|
| 69 |
+
chunkMs?: [number, number];
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
interface AgentRewriteState {
|
| 73 |
+
/**
|
| 74 |
+
* Current insertion position for the in-flight typewriter animation.
|
| 75 |
+
* Mapped through every transaction so local + remote edits never
|
| 76 |
+
* shift the cursor out of sync with the actual doc content.
|
| 77 |
+
*/
|
| 78 |
+
cursor: number | null;
|
| 79 |
+
/**
|
| 80 |
+
* Monotonically-increasing token. Any call to `agentRewriteRange`
|
| 81 |
+
* bumps this, which retroactively cancels any animation step still
|
| 82 |
+
* scheduled from a previous call.
|
| 83 |
+
*/
|
| 84 |
+
cancelToken: number;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
interface AgentRewriteMeta {
|
| 88 |
+
cancel?: boolean;
|
| 89 |
+
setCursor?: number | null;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
const agentRewriteKey = new PluginKey<AgentRewriteState>("agent-rewrite");
|
| 93 |
+
|
| 94 |
+
declare module "@tiptap/core" {
|
| 95 |
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
| 96 |
+
interface Commands<ReturnType> {
|
| 97 |
+
agentRewrite: {
|
| 98 |
+
/**
|
| 99 |
+
* Replace `[from, to]` with `text`. When `animation === "typewriter"`
|
| 100 |
+
* the insertion is streamed chunk-by-chunk; otherwise it's one
|
| 101 |
+
* atomic transaction.
|
| 102 |
+
*/
|
| 103 |
+
agentRewriteRange: (args: AgentRewriteArgs) => ReturnType;
|
| 104 |
+
/** Abort any in-flight typewriter animation immediately. */
|
| 105 |
+
cancelAgentRewrite: () => ReturnType;
|
| 106 |
+
};
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
export const AgentRewrite = Extension.create({
|
| 111 |
+
name: "agentRewrite",
|
| 112 |
+
|
| 113 |
+
addProseMirrorPlugins() {
|
| 114 |
+
return [
|
| 115 |
+
new Plugin<AgentRewriteState>({
|
| 116 |
+
key: agentRewriteKey,
|
| 117 |
+
state: {
|
| 118 |
+
init: (): AgentRewriteState => ({ cursor: null, cancelToken: 0 }),
|
| 119 |
+
apply(tr, prev): AgentRewriteState {
|
| 120 |
+
let next = prev;
|
| 121 |
+
// Remap the tracked cursor through any doc change (local or
|
| 122 |
+
// remote). This is the whole point of using a plugin state
|
| 123 |
+
// instead of a closure-captured number: Yjs pushes tr's for
|
| 124 |
+
// remote edits and we ride their mapping.
|
| 125 |
+
if (tr.docChanged && next.cursor !== null) {
|
| 126 |
+
next = { ...next, cursor: tr.mapping.map(next.cursor) };
|
| 127 |
+
}
|
| 128 |
+
const meta = tr.getMeta(agentRewriteKey) as
|
| 129 |
+
| AgentRewriteMeta
|
| 130 |
+
| undefined;
|
| 131 |
+
if (meta) {
|
| 132 |
+
if (meta.cancel) {
|
| 133 |
+
// `cancel` wipes the cursor AND advances the token so
|
| 134 |
+
// in-flight steps observe a mismatch and bail out.
|
| 135 |
+
next = {
|
| 136 |
+
cursor: null,
|
| 137 |
+
cancelToken: next.cancelToken + 1,
|
| 138 |
+
};
|
| 139 |
+
}
|
| 140 |
+
if (meta.setCursor !== undefined) {
|
| 141 |
+
// `setCursor` is the primitive used by the command to
|
| 142 |
+
// re-arm the plugin with a fresh starting position
|
| 143 |
+
// (AFTER any cancel has taken effect in the same meta).
|
| 144 |
+
next = { ...next, cursor: meta.setCursor };
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
return next;
|
| 148 |
+
},
|
| 149 |
+
},
|
| 150 |
+
}),
|
| 151 |
+
];
|
| 152 |
+
},
|
| 153 |
+
|
| 154 |
+
addCommands() {
|
| 155 |
+
return {
|
| 156 |
+
cancelAgentRewrite:
|
| 157 |
+
() =>
|
| 158 |
+
({ tr, dispatch }) => {
|
| 159 |
+
if (dispatch) {
|
| 160 |
+
tr.setMeta(agentRewriteKey, { cancel: true });
|
| 161 |
+
}
|
| 162 |
+
return true;
|
| 163 |
+
},
|
| 164 |
+
|
| 165 |
+
agentRewriteRange:
|
| 166 |
+
(args) =>
|
| 167 |
+
({ editor, state, view, tr, dispatch }) => {
|
| 168 |
+
const docSize = state.doc.content.size;
|
| 169 |
+
const a = Math.max(0, Math.min(args.from, docSize));
|
| 170 |
+
const b = Math.max(0, Math.min(args.to, docSize));
|
| 171 |
+
if (b < a) return false;
|
| 172 |
+
if (!dispatch) return true;
|
| 173 |
+
|
| 174 |
+
// Step 1: single atomic transaction that (a) cancels any
|
| 175 |
+
// in-flight rewrite, (b) deletes the target range, and (c)
|
| 176 |
+
// plants the insertion cursor at `a`. Done in one tr so the
|
| 177 |
+
// deletion and cursor seed land together - avoids a visible
|
| 178 |
+
// gap between delete and first insert.
|
| 179 |
+
tr.setMeta(agentRewriteKey, {
|
| 180 |
+
cancel: true,
|
| 181 |
+
setCursor: a,
|
| 182 |
+
} as AgentRewriteMeta);
|
| 183 |
+
if (b > a) tr.delete(a, b);
|
| 184 |
+
dispatch(tr);
|
| 185 |
+
|
| 186 |
+
const text = args.text ?? "";
|
| 187 |
+
if (!text) {
|
| 188 |
+
// Nothing to insert, just a deletion. Done.
|
| 189 |
+
view.dispatch(
|
| 190 |
+
view.state.tr.setMeta(agentRewriteKey, {
|
| 191 |
+
setCursor: null,
|
| 192 |
+
} as AgentRewriteMeta),
|
| 193 |
+
);
|
| 194 |
+
return true;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
const animation = args.animation ?? "none";
|
| 198 |
+
if (animation === "none") {
|
| 199 |
+
// Synchronous, one-shot insert. Single tr -> one undo step.
|
| 200 |
+
const cursor =
|
| 201 |
+
agentRewriteKey.getState(editor.state)?.cursor ?? a;
|
| 202 |
+
view.dispatch(
|
| 203 |
+
view.state.tr
|
| 204 |
+
.insertText(text, cursor)
|
| 205 |
+
.setMeta(agentRewriteKey, {
|
| 206 |
+
setCursor: null,
|
| 207 |
+
} as AgentRewriteMeta),
|
| 208 |
+
);
|
| 209 |
+
return true;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
// Typewriter: async stream of small insertText trs. Each step
|
| 213 |
+
// reads the CURRENT cursor from plugin state (mapped through
|
| 214 |
+
// every intervening tr, including remote Yjs updates), so the
|
| 215 |
+
// animation is immune to concurrent edits.
|
| 216 |
+
const [chunkMin, chunkMax] = args.chunkSize ?? [1, 2];
|
| 217 |
+
const [delayMin, delayMax] = args.chunkMs ?? [22, 48];
|
| 218 |
+
const myToken =
|
| 219 |
+
agentRewriteKey.getState(editor.state)?.cancelToken ?? 0;
|
| 220 |
+
let i = 0;
|
| 221 |
+
|
| 222 |
+
const step = () => {
|
| 223 |
+
if (view.isDestroyed) return;
|
| 224 |
+
const ps = agentRewriteKey.getState(view.state);
|
| 225 |
+
if (!ps) return;
|
| 226 |
+
// Another rewrite started (or cancel was called) -> stop.
|
| 227 |
+
if (ps.cancelToken !== myToken) return;
|
| 228 |
+
if (ps.cursor === null) return;
|
| 229 |
+
|
| 230 |
+
if (i >= text.length) {
|
| 231 |
+
// Done: clear the cursor marker so nothing lingers in
|
| 232 |
+
// plugin state. The caller is responsible for any visual
|
| 233 |
+
// cleanup (e.g. `clearAgentFocus`).
|
| 234 |
+
view.dispatch(
|
| 235 |
+
view.state.tr.setMeta(agentRewriteKey, {
|
| 236 |
+
setCursor: null,
|
| 237 |
+
} as AgentRewriteMeta),
|
| 238 |
+
);
|
| 239 |
+
return;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
const remaining = text.length - i;
|
| 243 |
+
const maxTake = Math.max(1, Math.min(chunkMax, remaining));
|
| 244 |
+
const minTake = Math.max(1, Math.min(chunkMin, maxTake));
|
| 245 |
+
const take =
|
| 246 |
+
minTake +
|
| 247 |
+
Math.floor(Math.random() * (maxTake - minTake + 1));
|
| 248 |
+
const chunk = text.slice(i, i + take);
|
| 249 |
+
|
| 250 |
+
// `insertText(chunk, cursor)` advances the cursor via the
|
| 251 |
+
// plugin's `apply` mapping (see state.apply above).
|
| 252 |
+
view.dispatch(view.state.tr.insertText(chunk, ps.cursor));
|
| 253 |
+
i += chunk.length;
|
| 254 |
+
|
| 255 |
+
const delay =
|
| 256 |
+
delayMin + Math.random() * Math.max(0, delayMax - delayMin);
|
| 257 |
+
setTimeout(step, delay);
|
| 258 |
+
};
|
| 259 |
+
// Kick off on a macrotask so the initial delete tr has fully
|
| 260 |
+
// applied (and the cursor seed is live in plugin state)
|
| 261 |
+
// before the first step reads it.
|
| 262 |
+
setTimeout(step, 0);
|
| 263 |
+
return true;
|
| 264 |
+
},
|
| 265 |
+
};
|
| 266 |
+
},
|
| 267 |
+
});
|
|
@@ -77,15 +77,6 @@ export function SettingsDrawer({ open, onClose, store, settingsMap }: SettingsDr
|
|
| 77 |
/>
|
| 78 |
</FieldGroup>
|
| 79 |
|
| 80 |
-
<FieldGroup label="Banner embed">
|
| 81 |
-
<input
|
| 82 |
-
className="form-input"
|
| 83 |
-
placeholder="banner.html"
|
| 84 |
-
value={data.banner}
|
| 85 |
-
onChange={(e) => update("banner", e.target.value)}
|
| 86 |
-
/>
|
| 87 |
-
</FieldGroup>
|
| 88 |
-
|
| 89 |
<FieldGroup label="Citation style">
|
| 90 |
<select
|
| 91 |
className="form-select"
|
|
|
|
| 77 |
/>
|
| 78 |
</FieldGroup>
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
<FieldGroup label="Citation style">
|
| 81 |
<select
|
| 82 |
className="form-select"
|
|
@@ -8,6 +8,7 @@ import type { Editor } from "@tiptap/core";
|
|
| 8 |
import type { UndoManager } from "yjs";
|
| 9 |
import type { UIMessage } from "ai";
|
| 10 |
import type { FrontmatterStore } from "../editor/frontmatter/frontmatter-store";
|
|
|
|
| 11 |
|
| 12 |
interface UseAgentChatOptions {
|
| 13 |
editor: Editor | null;
|
|
@@ -149,6 +150,18 @@ export function useAgentChat({ editor, undoManager, frontmatterStore, modelRef,
|
|
| 149 |
|
| 150 |
const executeToolCall = useCallback(
|
| 151 |
(toolCall: { toolName: string; args: unknown; toolCallId: string }) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
switch (toolCall.toolName) {
|
| 153 |
// --- Frontmatter tools (no editor needed) ---
|
| 154 |
case "updateFrontmatter": {
|
|
@@ -258,10 +271,29 @@ export function useAgentChat({ editor, undoManager, frontmatterStore, modelRef,
|
|
| 258 |
[editor, frontmatterStore, startAgentBatch, findTextPosition],
|
| 259 |
);
|
| 260 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
const { addToolOutput, ...chat } = useChat({
|
| 262 |
transport,
|
| 263 |
messages: initialMessages,
|
| 264 |
-
sendAutomaticallyWhen:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
|
| 266 |
async onToolCall({ toolCall }) {
|
| 267 |
if (toolCall.dynamic) return;
|
|
@@ -310,32 +342,6 @@ export function useAgentChat({ editor, undoManager, frontmatterStore, modelRef,
|
|
| 310 |
[chat, getEditorContext, endAgentBatch, modelRef],
|
| 311 |
);
|
| 312 |
|
| 313 |
-
const sendQuickAction = useCallback(
|
| 314 |
-
(action: string) => {
|
| 315 |
-
endAgentBatch();
|
| 316 |
-
const context = getEditorContext();
|
| 317 |
-
const prompts: Record<string, string> = {
|
| 318 |
-
rewrite:
|
| 319 |
-
"Rewrite the selected text to be clearer and more concise.",
|
| 320 |
-
expand:
|
| 321 |
-
"Expand the selected text with more detail and examples.",
|
| 322 |
-
summarize:
|
| 323 |
-
"Summarize the selected text into a shorter version.",
|
| 324 |
-
"fix-grammar":
|
| 325 |
-
"Fix any grammar and spelling errors in the selected text.",
|
| 326 |
-
translate:
|
| 327 |
-
"Translate the selected text to English (or French if already in English).",
|
| 328 |
-
simplify:
|
| 329 |
-
"Simplify the selected text so it's easier to understand.",
|
| 330 |
-
};
|
| 331 |
-
|
| 332 |
-
const prompt = prompts[action] || action;
|
| 333 |
-
const model = modelRef.current;
|
| 334 |
-
chat.sendMessage({ text: prompt }, { body: { context, model } });
|
| 335 |
-
},
|
| 336 |
-
[chat, getEditorContext, endAgentBatch, modelRef],
|
| 337 |
-
);
|
| 338 |
-
|
| 339 |
const isLoading =
|
| 340 |
chat.status === "streaming" || chat.status === "submitted";
|
| 341 |
|
|
@@ -353,7 +359,6 @@ export function useAgentChat({ editor, undoManager, frontmatterStore, modelRef,
|
|
| 353 |
isLoading,
|
| 354 |
error: chat.error,
|
| 355 |
sendMessage,
|
| 356 |
-
sendQuickAction,
|
| 357 |
clearMessages,
|
| 358 |
setMessages,
|
| 359 |
input,
|
|
|
|
| 8 |
import type { UndoManager } from "yjs";
|
| 9 |
import type { UIMessage } from "ai";
|
| 10 |
import type { FrontmatterStore } from "../editor/frontmatter/frontmatter-store";
|
| 11 |
+
import { executeTiptapCommand, TIPTAP_TOOL_NAMES } from "../editor/agent-executor";
|
| 12 |
|
| 13 |
interface UseAgentChatOptions {
|
| 14 |
editor: Editor | null;
|
|
|
|
| 150 |
|
| 151 |
const executeToolCall = useCallback(
|
| 152 |
(toolCall: { toolName: string; args: unknown; toolCallId: string }) => {
|
| 153 |
+
// --- Tiptap catalog commands (select, toggleBold, toggleHeading, ...) ---
|
| 154 |
+
if (TIPTAP_TOOL_NAMES.has(toolCall.toolName)) {
|
| 155 |
+
if (!editor) return "Editor not available";
|
| 156 |
+
startAgentBatch();
|
| 157 |
+
const result = executeTiptapCommand(
|
| 158 |
+
editor,
|
| 159 |
+
toolCall.toolName,
|
| 160 |
+
(toolCall.args as Record<string, unknown>) ?? {},
|
| 161 |
+
);
|
| 162 |
+
return result ?? `Unknown editor command: ${toolCall.toolName}`;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
switch (toolCall.toolName) {
|
| 166 |
// --- Frontmatter tools (no editor needed) ---
|
| 167 |
case "updateFrontmatter": {
|
|
|
|
| 271 |
[editor, frontmatterStore, startAgentBatch, findTextPosition],
|
| 272 |
);
|
| 273 |
|
| 274 |
+
/**
|
| 275 |
+
* Hard cap on consecutive auto-sent tool-call rounds to prevent
|
| 276 |
+
* runaway loops (e.g. a model that keeps calling tools instead of
|
| 277 |
+
* emitting a final text response).
|
| 278 |
+
*/
|
| 279 |
+
const MAX_TOOL_ROUNDS = 8;
|
| 280 |
const { addToolOutput, ...chat } = useChat({
|
| 281 |
transport,
|
| 282 |
messages: initialMessages,
|
| 283 |
+
sendAutomaticallyWhen: ({ messages }) => {
|
| 284 |
+
let toolRounds = 0;
|
| 285 |
+
for (let i = messages.length - 1; i >= 0; i--) {
|
| 286 |
+
const m = messages[i];
|
| 287 |
+
if (m.role !== "assistant") break;
|
| 288 |
+
const hasToolPart = (m.parts ?? []).some((p) =>
|
| 289 |
+
typeof p.type === "string" && p.type.startsWith("tool-"),
|
| 290 |
+
);
|
| 291 |
+
if (!hasToolPart) break;
|
| 292 |
+
toolRounds += 1;
|
| 293 |
+
}
|
| 294 |
+
if (toolRounds >= MAX_TOOL_ROUNDS) return false;
|
| 295 |
+
return lastAssistantMessageIsCompleteWithToolCalls({ messages });
|
| 296 |
+
},
|
| 297 |
|
| 298 |
async onToolCall({ toolCall }) {
|
| 299 |
if (toolCall.dynamic) return;
|
|
|
|
| 342 |
[chat, getEditorContext, endAgentBatch, modelRef],
|
| 343 |
);
|
| 344 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
const isLoading =
|
| 346 |
chat.status === "streaming" || chat.status === "submitted";
|
| 347 |
|
|
|
|
| 359 |
isLoading,
|
| 360 |
error: chat.error,
|
| 361 |
sendMessage,
|
|
|
|
| 362 |
clearMessages,
|
| 363 |
setMessages,
|
| 364 |
input,
|
|
@@ -6,17 +6,66 @@ import {
|
|
| 6 |
import { useCallback, useEffect, useRef, useState } from "react";
|
| 7 |
import type { UIMessage } from "ai";
|
| 8 |
import type { EmbedStore } from "../editor/embeds/embed-store";
|
|
|
|
| 9 |
import { loadMessages, saveMessages } from "../utils/chat-persistence";
|
| 10 |
|
| 11 |
interface UseEmbedChatOptions {
|
| 12 |
embedStore: EmbedStore | null;
|
|
|
|
| 13 |
/** Current embed src key (filename) */
|
| 14 |
src: string;
|
| 15 |
modelRef: React.RefObject<string>;
|
| 16 |
userId: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
}
|
| 18 |
|
| 19 |
const MAX_EMBED_SIZE = 512_000;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
const transport = new DefaultChatTransport({ api: "/api/embed-chat" });
|
| 22 |
|
|
@@ -24,12 +73,25 @@ function scopeKey(src: string): string {
|
|
| 24 |
return `embed:${src}`;
|
| 25 |
}
|
| 26 |
|
| 27 |
-
export function useEmbedChat({ embedStore, src, modelRef, userId }: UseEmbedChatOptions) {
|
| 28 |
const [input, setInput] = useState("");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
const srcRef = useRef(src);
|
| 30 |
-
srcRef
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
const userIdRef = useRef(userId);
|
| 32 |
userIdRef.current = userId;
|
|
|
|
|
|
|
| 33 |
|
| 34 |
// Guard: only allow sendAutomaticallyWhen AFTER the user has sent at
|
| 35 |
// least one message in this session. Prevents auto-send on mount when
|
|
@@ -38,13 +100,31 @@ export function useEmbedChat({ embedStore, src, modelRef, userId }: UseEmbedChat
|
|
| 38 |
|
| 39 |
const getEmbedContext = useCallback(() => {
|
| 40 |
if (!embedStore || !srcRef.current) return {};
|
| 41 |
-
const
|
| 42 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
return {
|
| 44 |
embedHtml: embedHtml || undefined,
|
| 45 |
isBanner,
|
|
|
|
|
|
|
| 46 |
};
|
| 47 |
-
}, [embedStore]);
|
| 48 |
|
| 49 |
const executeToolCall = useCallback(
|
| 50 |
(toolCall: { toolName: string; args: unknown; toolCallId: string }) => {
|
|
@@ -54,19 +134,58 @@ export function useEmbedChat({ embedStore, src, modelRef, userId }: UseEmbedChat
|
|
| 54 |
|
| 55 |
switch (toolCall.toolName) {
|
| 56 |
case "createEmbed": {
|
| 57 |
-
const { html, title } = toolCall.args as {
|
| 58 |
html: string;
|
| 59 |
title?: string;
|
| 60 |
source?: string;
|
|
|
|
| 61 |
};
|
| 62 |
if (!html?.trim()) return "ERROR: html cannot be empty";
|
| 63 |
const stripped = html.trim();
|
| 64 |
if (stripped.length > MAX_EMBED_SIZE) {
|
| 65 |
return `ERROR: chart too large (${stripped.length} bytes, max ${MAX_EMBED_SIZE}). Simplify the code.`;
|
| 66 |
}
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
const lines = stripped.split("\n").length;
|
| 69 |
-
|
|
|
|
| 70 |
}
|
| 71 |
|
| 72 |
case "patchEmbed": {
|
|
@@ -101,11 +220,40 @@ export function useEmbedChat({ embedStore, src, modelRef, userId }: UseEmbedChat
|
|
| 101 |
return current;
|
| 102 |
}
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
default:
|
| 105 |
return `Unknown tool: ${toolCall.toolName}`;
|
| 106 |
}
|
| 107 |
},
|
| 108 |
-
[embedStore],
|
| 109 |
);
|
| 110 |
|
| 111 |
const { addToolOutput, setMessages: chatSetMessages, ...chat } = useChat({
|
|
|
|
| 6 |
import { useCallback, useEffect, useRef, useState } from "react";
|
| 7 |
import type { UIMessage } from "ai";
|
| 8 |
import type { EmbedStore } from "../editor/embeds/embed-store";
|
| 9 |
+
import type { EmbedDataStore } from "../editor/embeds/embed-data-store";
|
| 10 |
import { loadMessages, saveMessages } from "../utils/chat-persistence";
|
| 11 |
|
| 12 |
interface UseEmbedChatOptions {
|
| 13 |
embedStore: EmbedStore | null;
|
| 14 |
+
dataStore?: EmbedDataStore | null;
|
| 15 |
/** Current embed src key (filename) */
|
| 16 |
src: string;
|
| 17 |
modelRef: React.RefObject<string>;
|
| 18 |
userId: string;
|
| 19 |
+
/**
|
| 20 |
+
* Called when the agent picks a descriptive filename via createEmbed.
|
| 21 |
+
* Parent is expected to update the htmlEmbed node's src attribute in
|
| 22 |
+
* the doc and lift the new src into its own state. The hook already
|
| 23 |
+
* moves the content in embedStore and migrates persisted messages.
|
| 24 |
+
*/
|
| 25 |
+
onRename?: (oldSrc: string, newSrc: string) => void;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* Slugify an arbitrary filename hint into a safe kebab-case slug.
|
| 30 |
+
* Keeps ASCII letters/digits/dashes, collapses whitespace, lowercases.
|
| 31 |
+
*/
|
| 32 |
+
function slugify(raw: string): string {
|
| 33 |
+
const base = raw
|
| 34 |
+
.toLowerCase()
|
| 35 |
+
.replace(/\.html?$/i, "")
|
| 36 |
+
.normalize("NFKD")
|
| 37 |
+
.replace(/[\u0300-\u036f]/g, "")
|
| 38 |
+
.replace(/[^a-z0-9]+/g, "-")
|
| 39 |
+
.replace(/^-+|-+$/g, "")
|
| 40 |
+
.slice(0, 60);
|
| 41 |
+
return base || "chart";
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/**
|
| 45 |
+
* Given a slug, find a unique .html filename in `embedStore`. If the
|
| 46 |
+
* desired name is free, return it; otherwise append `-2`, `-3`, ...
|
| 47 |
+
*/
|
| 48 |
+
function ensureUniqueFilename(
|
| 49 |
+
slug: string,
|
| 50 |
+
embedStore: EmbedStore,
|
| 51 |
+
currentSrc: string,
|
| 52 |
+
): string {
|
| 53 |
+
const base = `${slug}.html`;
|
| 54 |
+
if (base === currentSrc) return base;
|
| 55 |
+
if (!embedStore.has(base)) return base;
|
| 56 |
+
let i = 2;
|
| 57 |
+
while (embedStore.has(`${slug}-${i}.html`)) i++;
|
| 58 |
+
return `${slug}-${i}.html`;
|
| 59 |
}
|
| 60 |
|
| 61 |
const MAX_EMBED_SIZE = 512_000;
|
| 62 |
+
/**
|
| 63 |
+
* Hard cap on the number of characters we inline when the agent reads
|
| 64 |
+
* a data file. Above this, the tool returns a truncated preview with
|
| 65 |
+
* a warning so the model doesn't blow past its context window on a
|
| 66 |
+
* single tool call.
|
| 67 |
+
*/
|
| 68 |
+
const MAX_DATA_INLINE = 120_000;
|
| 69 |
|
| 70 |
const transport = new DefaultChatTransport({ api: "/api/embed-chat" });
|
| 71 |
|
|
|
|
| 73 |
return `embed:${src}`;
|
| 74 |
}
|
| 75 |
|
| 76 |
+
export function useEmbedChat({ embedStore, dataStore, src, modelRef, userId, onRename }: UseEmbedChatOptions) {
|
| 77 |
const [input, setInput] = useState("");
|
| 78 |
+
// srcRef is the source of truth for the active embed filename. It
|
| 79 |
+
// starts from the `src` prop but may change mid-session when the
|
| 80 |
+
// agent renames the chart via createEmbed({ filename }). We do NOT
|
| 81 |
+
// overwrite it from the prop on every render to avoid clobbering a
|
| 82 |
+
// freshly-chosen name before the parent has caught up.
|
| 83 |
const srcRef = useRef(src);
|
| 84 |
+
// Sync srcRef whenever the incoming `src` prop changes from outside.
|
| 85 |
+
// After an internal rename, we already set srcRef to the new name and
|
| 86 |
+
// asked the parent to lift it; when the parent's state arrives here
|
| 87 |
+
// the values match and this is a no-op.
|
| 88 |
+
if (src !== srcRef.current) {
|
| 89 |
+
srcRef.current = src;
|
| 90 |
+
}
|
| 91 |
const userIdRef = useRef(userId);
|
| 92 |
userIdRef.current = userId;
|
| 93 |
+
const onRenameRef = useRef(onRename);
|
| 94 |
+
onRenameRef.current = onRename;
|
| 95 |
|
| 96 |
// Guard: only allow sendAutomaticallyWhen AFTER the user has sent at
|
| 97 |
// least one message in this session. Prevents auto-send on mount when
|
|
|
|
| 100 |
|
| 101 |
const getEmbedContext = useCallback(() => {
|
| 102 |
if (!embedStore || !srcRef.current) return {};
|
| 103 |
+
const currentSrc = srcRef.current;
|
| 104 |
+
const embedHtml = embedStore.get(currentSrc);
|
| 105 |
+
const isBanner = currentSrc === "banner.html";
|
| 106 |
+
// A "scratch" file is a newly-created placeholder whose name is a
|
| 107 |
+
// hash-based auto-generated slug (see SlashMenu). When the chart is
|
| 108 |
+
// still empty under such a name, we explicitly ask the agent to
|
| 109 |
+
// pick a descriptive filename on the first createEmbed call.
|
| 110 |
+
const isScratch =
|
| 111 |
+
!isBanner && !embedHtml && /^d3-chart-[a-z0-9]+\.html$/i.test(currentSrc);
|
| 112 |
+
const dataFiles = dataStore
|
| 113 |
+
? dataStore.list().map((m) => ({
|
| 114 |
+
name: m.name,
|
| 115 |
+
ext: m.ext,
|
| 116 |
+
size: m.size,
|
| 117 |
+
rowCount: m.rowCount,
|
| 118 |
+
columns: m.columns,
|
| 119 |
+
}))
|
| 120 |
+
: [];
|
| 121 |
return {
|
| 122 |
embedHtml: embedHtml || undefined,
|
| 123 |
isBanner,
|
| 124 |
+
isScratch,
|
| 125 |
+
dataFiles,
|
| 126 |
};
|
| 127 |
+
}, [embedStore, dataStore]);
|
| 128 |
|
| 129 |
const executeToolCall = useCallback(
|
| 130 |
(toolCall: { toolName: string; args: unknown; toolCallId: string }) => {
|
|
|
|
| 134 |
|
| 135 |
switch (toolCall.toolName) {
|
| 136 |
case "createEmbed": {
|
| 137 |
+
const { html, title, filename } = toolCall.args as {
|
| 138 |
html: string;
|
| 139 |
title?: string;
|
| 140 |
source?: string;
|
| 141 |
+
filename?: string;
|
| 142 |
};
|
| 143 |
if (!html?.trim()) return "ERROR: html cannot be empty";
|
| 144 |
const stripped = html.trim();
|
| 145 |
if (stripped.length > MAX_EMBED_SIZE) {
|
| 146 |
return `ERROR: chart too large (${stripped.length} bytes, max ${MAX_EMBED_SIZE}). Simplify the code.`;
|
| 147 |
}
|
| 148 |
+
|
| 149 |
+
// Resolve the target src: if the agent proposed a filename
|
| 150 |
+
// slug, slugify + ensure uniqueness. Never rename the banner
|
| 151 |
+
// (its name is load-bearing for the article layout).
|
| 152 |
+
let targetSrc = currentSrc;
|
| 153 |
+
const isBanner = currentSrc === "banner.html";
|
| 154 |
+
if (filename && !isBanner) {
|
| 155 |
+
const slug = slugify(filename);
|
| 156 |
+
const candidate = ensureUniqueFilename(slug, embedStore, currentSrc);
|
| 157 |
+
if (candidate !== currentSrc) {
|
| 158 |
+
targetSrc = candidate;
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
if (targetSrc !== currentSrc) {
|
| 163 |
+
// Write html under the new key and remove the old one atomically.
|
| 164 |
+
embedStore.set(targetSrc, stripped);
|
| 165 |
+
embedStore.remove(currentSrc);
|
| 166 |
+
// Migrate persisted chat messages so the localStorage scope
|
| 167 |
+
// tracks the new file name.
|
| 168 |
+
try {
|
| 169 |
+
const uid = userIdRef.current;
|
| 170 |
+
const existing = loadMessages(uid, scopeKey(currentSrc));
|
| 171 |
+
if (existing && existing.length > 0) {
|
| 172 |
+
saveMessages(uid, scopeKey(targetSrc), existing);
|
| 173 |
+
}
|
| 174 |
+
saveMessages(uid, scopeKey(currentSrc), []);
|
| 175 |
+
} catch {
|
| 176 |
+
// Non-fatal: persistence is best-effort.
|
| 177 |
+
}
|
| 178 |
+
srcRef.current = targetSrc;
|
| 179 |
+
// Let the parent update the htmlEmbed node's src attribute
|
| 180 |
+
// in the ProseMirror doc and lift the new src into state.
|
| 181 |
+
onRenameRef.current?.(currentSrc, targetSrc);
|
| 182 |
+
} else {
|
| 183 |
+
embedStore.set(currentSrc, stripped);
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
const lines = stripped.split("\n").length;
|
| 187 |
+
const renamed = targetSrc !== currentSrc ? ` [renamed to ${targetSrc}]` : "";
|
| 188 |
+
return `chart created (${lines} lines, ${stripped.length} chars)${title ? ` - "${title}"` : ""}${renamed}`;
|
| 189 |
}
|
| 190 |
|
| 191 |
case "patchEmbed": {
|
|
|
|
| 220 |
return current;
|
| 221 |
}
|
| 222 |
|
| 223 |
+
case "listDataFiles": {
|
| 224 |
+
if (!dataStore) return "(no data files - data store unavailable)";
|
| 225 |
+
const files = dataStore.list();
|
| 226 |
+
if (files.length === 0) return "(no data files attached)";
|
| 227 |
+
return files
|
| 228 |
+
.map((f) => {
|
| 229 |
+
const cols = f.columns && f.columns.length > 0
|
| 230 |
+
? ` columns=[${f.columns.join(", ")}]`
|
| 231 |
+
: "";
|
| 232 |
+
const rows = f.rowCount !== undefined ? ` rows=${f.rowCount}` : "";
|
| 233 |
+
return `- ${f.name} (${f.ext}, ${f.size} bytes)${rows}${cols}`;
|
| 234 |
+
})
|
| 235 |
+
.join("\n");
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
case "readDataFile": {
|
| 239 |
+
if (!dataStore) return "ERROR: data store unavailable";
|
| 240 |
+
const { name } = toolCall.args as { name: string };
|
| 241 |
+
if (!name) return "ERROR: missing file name";
|
| 242 |
+
const file = dataStore.get(name);
|
| 243 |
+
if (!file) return `ERROR: data file "${name}" not found`;
|
| 244 |
+
const content = file.content;
|
| 245 |
+
if (content.length <= MAX_DATA_INLINE) return content;
|
| 246 |
+
return (
|
| 247 |
+
content.slice(0, MAX_DATA_INLINE) +
|
| 248 |
+
`\n\n[TRUNCATED - full file is ${content.length} chars, showing first ${MAX_DATA_INLINE}]`
|
| 249 |
+
);
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
default:
|
| 253 |
return `Unknown tool: ${toolCall.toolName}`;
|
| 254 |
}
|
| 255 |
},
|
| 256 |
+
[embedStore, dataStore],
|
| 257 |
);
|
| 258 |
|
| 259 |
const { addToolOutput, setMessages: chatSetMessages, ...chat } = useChat({
|
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useState } from "react";
|
| 2 |
+
import type {
|
| 3 |
+
EmbedDataFile,
|
| 4 |
+
EmbedDataFileMeta,
|
| 5 |
+
EmbedDataStore,
|
| 6 |
+
} from "../editor/embeds/embed-data-store";
|
| 7 |
+
import {
|
| 8 |
+
MAX_DATA_FILE_SIZE,
|
| 9 |
+
extFromName,
|
| 10 |
+
inferDataShape,
|
| 11 |
+
isAcceptedExt,
|
| 12 |
+
} from "../utils/data-files";
|
| 13 |
+
|
| 14 |
+
interface UseEmbedDataOptions {
|
| 15 |
+
dataStore: EmbedDataStore | null;
|
| 16 |
+
userId: string;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export interface UploadResult {
|
| 20 |
+
ok: boolean;
|
| 21 |
+
name?: string;
|
| 22 |
+
error?: string;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export function useEmbedData({ dataStore, userId }: UseEmbedDataOptions) {
|
| 26 |
+
const [files, setFiles] = useState<EmbedDataFileMeta[]>(() =>
|
| 27 |
+
dataStore ? dataStore.list() : [],
|
| 28 |
+
);
|
| 29 |
+
|
| 30 |
+
useEffect(() => {
|
| 31 |
+
if (!dataStore) {
|
| 32 |
+
setFiles([]);
|
| 33 |
+
return;
|
| 34 |
+
}
|
| 35 |
+
setFiles(dataStore.list());
|
| 36 |
+
return dataStore.observe(() => {
|
| 37 |
+
setFiles(dataStore.list());
|
| 38 |
+
});
|
| 39 |
+
}, [dataStore]);
|
| 40 |
+
|
| 41 |
+
const uploadFile = useCallback(
|
| 42 |
+
async (file: File): Promise<UploadResult> => {
|
| 43 |
+
if (!dataStore) return { ok: false, error: "Data store not ready" };
|
| 44 |
+
if (file.size > MAX_DATA_FILE_SIZE) {
|
| 45 |
+
return {
|
| 46 |
+
ok: false,
|
| 47 |
+
error: `File too large (max ${(MAX_DATA_FILE_SIZE / (1024 * 1024)).toFixed(0)} MB)`,
|
| 48 |
+
};
|
| 49 |
+
}
|
| 50 |
+
const ext = extFromName(file.name);
|
| 51 |
+
if (!isAcceptedExt(ext)) {
|
| 52 |
+
return {
|
| 53 |
+
ok: false,
|
| 54 |
+
error: `Unsupported file type ".${ext}". Use CSV, TSV, JSON, NDJSON or TXT.`,
|
| 55 |
+
};
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
const content = await file.text();
|
| 59 |
+
const shape = inferDataShape(ext, content);
|
| 60 |
+
const record: EmbedDataFile = {
|
| 61 |
+
meta: {
|
| 62 |
+
name: file.name,
|
| 63 |
+
ext,
|
| 64 |
+
size: new Blob([content]).size,
|
| 65 |
+
uploader: userId,
|
| 66 |
+
addedAt: Date.now(),
|
| 67 |
+
rowCount: shape.rowCount,
|
| 68 |
+
columns: shape.columns,
|
| 69 |
+
},
|
| 70 |
+
content,
|
| 71 |
+
};
|
| 72 |
+
dataStore.set(record);
|
| 73 |
+
return { ok: true, name: file.name };
|
| 74 |
+
},
|
| 75 |
+
[dataStore, userId],
|
| 76 |
+
);
|
| 77 |
+
|
| 78 |
+
const uploadFiles = useCallback(
|
| 79 |
+
async (fileList: FileList | File[]): Promise<UploadResult[]> => {
|
| 80 |
+
const arr = Array.from(fileList);
|
| 81 |
+
const results: UploadResult[] = [];
|
| 82 |
+
for (const f of arr) {
|
| 83 |
+
results.push(await uploadFile(f));
|
| 84 |
+
}
|
| 85 |
+
return results;
|
| 86 |
+
},
|
| 87 |
+
[uploadFile],
|
| 88 |
+
);
|
| 89 |
+
|
| 90 |
+
const removeFile = useCallback(
|
| 91 |
+
(name: string) => {
|
| 92 |
+
dataStore?.remove(name);
|
| 93 |
+
},
|
| 94 |
+
[dataStore],
|
| 95 |
+
);
|
| 96 |
+
|
| 97 |
+
const getFile = useCallback(
|
| 98 |
+
(name: string) => dataStore?.get(name),
|
| 99 |
+
[dataStore],
|
| 100 |
+
);
|
| 101 |
+
|
| 102 |
+
return {
|
| 103 |
+
files,
|
| 104 |
+
uploadFile,
|
| 105 |
+
uploadFiles,
|
| 106 |
+
removeFile,
|
| 107 |
+
getFile,
|
| 108 |
+
};
|
| 109 |
+
}
|
|
@@ -174,10 +174,31 @@
|
|
| 174 |
border-radius: 6px;
|
| 175 |
color: var(--muted-color);
|
| 176 |
margin-top: 4px;
|
|
|
|
| 177 |
}
|
| 178 |
|
| 179 |
.es-message__tool-name {
|
| 180 |
font-weight: 600;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
}
|
| 182 |
|
| 183 |
.es-message__tool-status--running {
|
|
@@ -303,6 +324,20 @@
|
|
| 303 |
flex: 1;
|
| 304 |
position: relative;
|
| 305 |
overflow: hidden;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
}
|
| 307 |
|
| 308 |
.es-hidden {
|
|
@@ -338,3 +373,356 @@
|
|
| 338 |
word-break: break-all;
|
| 339 |
color: var(--text-color);
|
| 340 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
border-radius: 6px;
|
| 175 |
color: var(--muted-color);
|
| 176 |
margin-top: 4px;
|
| 177 |
+
max-width: 100%;
|
| 178 |
}
|
| 179 |
|
| 180 |
.es-message__tool-name {
|
| 181 |
font-weight: 600;
|
| 182 |
+
white-space: nowrap;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.es-message__tool-subtitle {
|
| 186 |
+
font-family: var(--font-mono);
|
| 187 |
+
font-size: 10.5px;
|
| 188 |
+
opacity: 0.75;
|
| 189 |
+
overflow: hidden;
|
| 190 |
+
text-overflow: ellipsis;
|
| 191 |
+
white-space: nowrap;
|
| 192 |
+
max-width: 180px;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.es-message__tool--running {
|
| 196 |
+
color: var(--primary-color);
|
| 197 |
+
background: color-mix(in srgb, var(--primary-color) 12%, var(--code-bg));
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
.es-message__tool--done {
|
| 201 |
+
color: var(--success-color, var(--muted-color));
|
| 202 |
}
|
| 203 |
|
| 204 |
.es-message__tool-status--running {
|
|
|
|
| 324 |
flex: 1;
|
| 325 |
position: relative;
|
| 326 |
overflow: hidden;
|
| 327 |
+
/* The iframe body is set to `background: transparent` by build-doc,
|
| 328 |
+
so whatever sits behind the iframe is what actually shows. Pin the
|
| 329 |
+
container to --page-bg so the preview tracks the theme instead of
|
| 330 |
+
falling through to the browser's default white. */
|
| 331 |
+
background: var(--page-bg);
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
.es-preview__frame-container iframe {
|
| 335 |
+
/* Some browsers paint a default white canvas on `srcdoc` iframes
|
| 336 |
+
before the document's own <style> applies. Forcing the element
|
| 337 |
+
itself to transparent lets --page-bg show through, which keeps
|
| 338 |
+
dark mode clean while the chart is (re)loading. */
|
| 339 |
+
background: transparent;
|
| 340 |
+
color-scheme: light dark;
|
| 341 |
}
|
| 342 |
|
| 343 |
.es-hidden {
|
|
|
|
| 373 |
word-break: break-all;
|
| 374 |
color: var(--text-color);
|
| 375 |
}
|
| 376 |
+
|
| 377 |
+
/* --- Files sidebar (far left) --- */
|
| 378 |
+
|
| 379 |
+
.es-files {
|
| 380 |
+
width: 240px;
|
| 381 |
+
min-width: 220px;
|
| 382 |
+
max-width: 260px;
|
| 383 |
+
display: flex;
|
| 384 |
+
flex-direction: column;
|
| 385 |
+
border-right: 1px solid var(--border-color);
|
| 386 |
+
background: var(--surface-bg);
|
| 387 |
+
overflow-y: auto;
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
.es-files__section {
|
| 391 |
+
display: flex;
|
| 392 |
+
flex-direction: column;
|
| 393 |
+
padding: 10px 12px 14px;
|
| 394 |
+
border-bottom: 1px solid var(--border-color);
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
.es-files__section:last-child {
|
| 398 |
+
border-bottom: none;
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
.es-files__section-title {
|
| 402 |
+
display: flex;
|
| 403 |
+
align-items: center;
|
| 404 |
+
gap: 6px;
|
| 405 |
+
text-transform: uppercase;
|
| 406 |
+
font-size: 10.5px;
|
| 407 |
+
font-weight: 700;
|
| 408 |
+
letter-spacing: 0.06em;
|
| 409 |
+
color: var(--muted-color);
|
| 410 |
+
margin-bottom: 8px;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
.es-files__count {
|
| 414 |
+
margin-left: auto;
|
| 415 |
+
font-size: 10.5px;
|
| 416 |
+
font-weight: 600;
|
| 417 |
+
color: var(--muted-color);
|
| 418 |
+
background: var(--code-bg);
|
| 419 |
+
padding: 1px 6px;
|
| 420 |
+
border-radius: 10px;
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
.es-files__list {
|
| 424 |
+
list-style: none;
|
| 425 |
+
margin: 0 0 8px;
|
| 426 |
+
padding: 0;
|
| 427 |
+
display: flex;
|
| 428 |
+
flex-direction: column;
|
| 429 |
+
gap: 2px;
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
.es-files__empty {
|
| 433 |
+
font-size: 11.5px;
|
| 434 |
+
color: var(--muted-color);
|
| 435 |
+
padding: 4px 0 6px;
|
| 436 |
+
font-style: italic;
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
.es-files__item {
|
| 440 |
+
display: flex;
|
| 441 |
+
align-items: center;
|
| 442 |
+
gap: 6px;
|
| 443 |
+
padding: 5px 8px;
|
| 444 |
+
border-radius: 6px;
|
| 445 |
+
font-size: 12px;
|
| 446 |
+
color: var(--text-color);
|
| 447 |
+
min-width: 0;
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
.es-files__item--current {
|
| 451 |
+
background: color-mix(in srgb, var(--primary-color) 14%, transparent);
|
| 452 |
+
color: var(--text-color);
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
.es-files__item--data {
|
| 456 |
+
padding: 0;
|
| 457 |
+
overflow: hidden;
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
.es-files__item-main {
|
| 461 |
+
flex: 1;
|
| 462 |
+
display: flex;
|
| 463 |
+
align-items: center;
|
| 464 |
+
gap: 6px;
|
| 465 |
+
background: transparent;
|
| 466 |
+
border: none;
|
| 467 |
+
padding: 5px 8px;
|
| 468 |
+
font-size: 12px;
|
| 469 |
+
color: inherit;
|
| 470 |
+
cursor: pointer;
|
| 471 |
+
text-align: left;
|
| 472 |
+
min-width: 0;
|
| 473 |
+
border-radius: 6px;
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
.es-files__item-main:hover {
|
| 477 |
+
background: var(--code-bg);
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
.es-files__item--current .es-files__item-main:hover {
|
| 481 |
+
background: color-mix(in srgb, var(--primary-color) 20%, transparent);
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
.es-files__name {
|
| 485 |
+
flex: 1;
|
| 486 |
+
overflow: hidden;
|
| 487 |
+
text-overflow: ellipsis;
|
| 488 |
+
white-space: nowrap;
|
| 489 |
+
min-width: 0;
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
.es-files__size {
|
| 493 |
+
font-size: 10.5px;
|
| 494 |
+
color: var(--muted-color);
|
| 495 |
+
font-variant-numeric: tabular-nums;
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
.es-files__badge {
|
| 499 |
+
font-size: 9.5px;
|
| 500 |
+
text-transform: uppercase;
|
| 501 |
+
letter-spacing: 0.06em;
|
| 502 |
+
font-weight: 700;
|
| 503 |
+
color: var(--primary-color);
|
| 504 |
+
background: var(--page-bg);
|
| 505 |
+
padding: 1px 5px;
|
| 506 |
+
border-radius: 10px;
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
.es-files__item-remove {
|
| 510 |
+
background: transparent;
|
| 511 |
+
border: none;
|
| 512 |
+
color: var(--muted-color);
|
| 513 |
+
cursor: pointer;
|
| 514 |
+
padding: 4px 8px;
|
| 515 |
+
display: flex;
|
| 516 |
+
align-items: center;
|
| 517 |
+
opacity: 0;
|
| 518 |
+
transition: opacity 120ms ease, color 120ms ease;
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
.es-files__item--data:hover .es-files__item-remove {
|
| 522 |
+
opacity: 1;
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
.es-files__item-remove:hover {
|
| 526 |
+
color: var(--danger-color, #e44);
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
.es-files__error {
|
| 530 |
+
display: flex;
|
| 531 |
+
align-items: center;
|
| 532 |
+
gap: 6px;
|
| 533 |
+
margin-top: 6px;
|
| 534 |
+
padding: 6px 8px;
|
| 535 |
+
font-size: 11.5px;
|
| 536 |
+
color: var(--danger-color, #e44);
|
| 537 |
+
background: color-mix(in srgb, var(--danger-color, #e44) 10%, transparent);
|
| 538 |
+
border-radius: 6px;
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
/* --- Upload zone --- */
|
| 542 |
+
|
| 543 |
+
.es-upload-zone {
|
| 544 |
+
display: flex;
|
| 545 |
+
align-items: center;
|
| 546 |
+
justify-content: center;
|
| 547 |
+
gap: 8px;
|
| 548 |
+
padding: 14px 10px;
|
| 549 |
+
margin-top: 4px;
|
| 550 |
+
border: 1.5px dashed var(--border-color);
|
| 551 |
+
border-radius: 8px;
|
| 552 |
+
color: var(--muted-color);
|
| 553 |
+
font-size: 12px;
|
| 554 |
+
cursor: pointer;
|
| 555 |
+
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
|
| 556 |
+
text-align: center;
|
| 557 |
+
flex-direction: column;
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
.es-upload-zone:hover,
|
| 561 |
+
.es-upload-zone:focus-visible {
|
| 562 |
+
border-color: var(--primary-color);
|
| 563 |
+
color: var(--text-color);
|
| 564 |
+
background: color-mix(in srgb, var(--primary-color) 6%, transparent);
|
| 565 |
+
outline: none;
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
.es-upload-zone--active {
|
| 569 |
+
border-color: var(--primary-color);
|
| 570 |
+
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
|
| 571 |
+
color: var(--text-color);
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
.es-upload-zone--compact {
|
| 575 |
+
flex-direction: row;
|
| 576 |
+
padding: 8px 10px;
|
| 577 |
+
font-size: 11.5px;
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
.es-upload-zone__text {
|
| 581 |
+
display: flex;
|
| 582 |
+
flex-direction: column;
|
| 583 |
+
gap: 2px;
|
| 584 |
+
align-items: center;
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
.es-upload-zone__text strong {
|
| 588 |
+
color: var(--text-color);
|
| 589 |
+
font-size: 12.5px;
|
| 590 |
+
font-weight: 600;
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
.es-upload-zone__text span {
|
| 594 |
+
font-size: 10.5px;
|
| 595 |
+
color: var(--muted-color);
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
/* --- Data file viewer --- */
|
| 599 |
+
|
| 600 |
+
.es-data-viewer {
|
| 601 |
+
position: absolute;
|
| 602 |
+
inset: 0;
|
| 603 |
+
display: flex;
|
| 604 |
+
flex-direction: column;
|
| 605 |
+
background: var(--page-bg);
|
| 606 |
+
z-index: 3;
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
.es-preview {
|
| 610 |
+
position: relative;
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
.es-data-viewer__header {
|
| 614 |
+
display: flex;
|
| 615 |
+
align-items: center;
|
| 616 |
+
gap: 12px;
|
| 617 |
+
padding: 8px 12px;
|
| 618 |
+
border-bottom: 1px solid var(--border-color);
|
| 619 |
+
background: var(--surface-bg);
|
| 620 |
+
flex-shrink: 0;
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
.es-data-viewer__title {
|
| 624 |
+
display: flex;
|
| 625 |
+
flex-direction: column;
|
| 626 |
+
gap: 2px;
|
| 627 |
+
min-width: 0;
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
.es-data-viewer__title strong {
|
| 631 |
+
font-size: 13px;
|
| 632 |
+
font-weight: 600;
|
| 633 |
+
overflow: hidden;
|
| 634 |
+
text-overflow: ellipsis;
|
| 635 |
+
white-space: nowrap;
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
.es-data-viewer__meta {
|
| 639 |
+
font-size: 11px;
|
| 640 |
+
color: var(--muted-color);
|
| 641 |
+
font-variant-numeric: tabular-nums;
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
.es-data-viewer__actions {
|
| 645 |
+
margin-left: auto;
|
| 646 |
+
display: flex;
|
| 647 |
+
align-items: center;
|
| 648 |
+
gap: 8px;
|
| 649 |
+
}
|
| 650 |
+
|
| 651 |
+
.es-data-viewer__tabs {
|
| 652 |
+
display: flex;
|
| 653 |
+
gap: 2px;
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
.es-data-viewer__body {
|
| 657 |
+
flex: 1;
|
| 658 |
+
overflow: auto;
|
| 659 |
+
padding: 12px;
|
| 660 |
+
min-height: 0;
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
.es-data-viewer__raw {
|
| 664 |
+
margin: 0;
|
| 665 |
+
padding: 12px 14px;
|
| 666 |
+
background: var(--code-bg);
|
| 667 |
+
border-radius: 6px;
|
| 668 |
+
font-family: var(--font-mono);
|
| 669 |
+
font-size: 12px;
|
| 670 |
+
line-height: 1.6;
|
| 671 |
+
color: var(--text-color);
|
| 672 |
+
overflow-x: auto;
|
| 673 |
+
white-space: pre;
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
.es-data-table-wrap {
|
| 677 |
+
border: 1px solid var(--border-color);
|
| 678 |
+
border-radius: 6px;
|
| 679 |
+
overflow: auto;
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
.es-data-table {
|
| 683 |
+
width: 100%;
|
| 684 |
+
border-collapse: collapse;
|
| 685 |
+
font-family: var(--font-mono);
|
| 686 |
+
font-size: 11.5px;
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
.es-data-table th,
|
| 690 |
+
.es-data-table td {
|
| 691 |
+
padding: 6px 10px;
|
| 692 |
+
border-right: 1px solid var(--border-color);
|
| 693 |
+
border-bottom: 1px solid var(--border-color);
|
| 694 |
+
text-align: left;
|
| 695 |
+
vertical-align: top;
|
| 696 |
+
max-width: 280px;
|
| 697 |
+
overflow: hidden;
|
| 698 |
+
text-overflow: ellipsis;
|
| 699 |
+
white-space: nowrap;
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
.es-data-table th:last-child,
|
| 703 |
+
.es-data-table td:last-child {
|
| 704 |
+
border-right: none;
|
| 705 |
+
}
|
| 706 |
+
|
| 707 |
+
.es-data-table thead th {
|
| 708 |
+
position: sticky;
|
| 709 |
+
top: 0;
|
| 710 |
+
background: var(--surface-bg);
|
| 711 |
+
font-weight: 600;
|
| 712 |
+
color: var(--text-color);
|
| 713 |
+
z-index: 1;
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
.es-data-table tbody tr:nth-child(even) {
|
| 717 |
+
background: color-mix(in srgb, var(--border-color) 20%, transparent);
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
.es-data-table__footer {
|
| 721 |
+
padding: 8px 12px;
|
| 722 |
+
font-size: 11px;
|
| 723 |
+
color: var(--muted-color);
|
| 724 |
+
border-top: 1px solid var(--border-color);
|
| 725 |
+
background: var(--surface-bg);
|
| 726 |
+
font-style: italic;
|
| 727 |
+
text-align: center;
|
| 728 |
+
}
|
|
@@ -44,16 +44,35 @@
|
|
| 44 |
border: 1px solid var(--border-color);
|
| 45 |
border-radius: 10px;
|
| 46 |
padding: 0.35rem;
|
| 47 |
-
min-width:
|
|
|
|
|
|
|
|
|
|
| 48 |
box-shadow: var(--shadow-lg);
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
}
|
| 51 |
|
| 52 |
.slash-menu-item {
|
| 53 |
display: flex;
|
| 54 |
align-items: center;
|
| 55 |
gap: 0.75rem;
|
| 56 |
-
padding: 0.
|
| 57 |
border-radius: 6px;
|
| 58 |
cursor: pointer;
|
| 59 |
transition: background 0.1s;
|
|
|
|
| 44 |
border: 1px solid var(--border-color);
|
| 45 |
border-radius: 10px;
|
| 46 |
padding: 0.35rem;
|
| 47 |
+
min-width: 280px;
|
| 48 |
+
max-width: 340px;
|
| 49 |
+
max-height: 360px;
|
| 50 |
+
overflow-y: auto;
|
| 51 |
box-shadow: var(--shadow-lg);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.slash-menu-group + .slash-menu-group {
|
| 55 |
+
margin-top: 0.25rem;
|
| 56 |
+
padding-top: 0.35rem;
|
| 57 |
+
border-top: 1px solid var(--border-color);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.slash-menu-section {
|
| 61 |
+
font-size: 0.65rem;
|
| 62 |
+
font-weight: 600;
|
| 63 |
+
letter-spacing: 0.06em;
|
| 64 |
+
text-transform: uppercase;
|
| 65 |
+
color: var(--muted-color);
|
| 66 |
+
padding: 0.35rem 0.65rem 0.25rem;
|
| 67 |
+
user-select: none;
|
| 68 |
+
pointer-events: none;
|
| 69 |
}
|
| 70 |
|
| 71 |
.slash-menu-item {
|
| 72 |
display: flex;
|
| 73 |
align-items: center;
|
| 74 |
gap: 0.75rem;
|
| 75 |
+
padding: 0.45rem 0.6rem;
|
| 76 |
border-radius: 6px;
|
| 77 |
cursor: pointer;
|
| 78 |
transition: background 0.1s;
|
|
@@ -13,6 +13,8 @@ export interface NormalizedToolPart {
|
|
| 13 |
toolName: string;
|
| 14 |
/** "result" when the call has produced output, otherwise the in-flight state. */
|
| 15 |
state: string;
|
|
|
|
|
|
|
| 16 |
result?: unknown;
|
| 17 |
}
|
| 18 |
|
|
@@ -34,12 +36,13 @@ export function normalizeToolPart(part: AnyPart): NormalizedToolPart | null {
|
|
| 34 |
|
| 35 |
if (type === "tool-invocation") {
|
| 36 |
const inv = anyPart.toolInvocation as
|
| 37 |
-
| { toolName?: string; state?: string; result?: unknown }
|
| 38 |
| undefined;
|
| 39 |
if (!inv?.toolName) return null;
|
| 40 |
return {
|
| 41 |
toolName: inv.toolName,
|
| 42 |
state: inv.state ?? "call",
|
|
|
|
| 43 |
result: inv.result,
|
| 44 |
};
|
| 45 |
}
|
|
@@ -52,6 +55,7 @@ export function normalizeToolPart(part: AnyPart): NormalizedToolPart | null {
|
|
| 52 |
return {
|
| 53 |
toolName,
|
| 54 |
state: normalized,
|
|
|
|
| 55 |
result: anyPart.output,
|
| 56 |
};
|
| 57 |
}
|
|
|
|
| 13 |
toolName: string;
|
| 14 |
/** "result" when the call has produced output, otherwise the in-flight state. */
|
| 15 |
state: string;
|
| 16 |
+
/** Arguments passed to the tool (v5: part.toolInvocation.args, v6: part.input). */
|
| 17 |
+
input?: unknown;
|
| 18 |
result?: unknown;
|
| 19 |
}
|
| 20 |
|
|
|
|
| 36 |
|
| 37 |
if (type === "tool-invocation") {
|
| 38 |
const inv = anyPart.toolInvocation as
|
| 39 |
+
| { toolName?: string; state?: string; args?: unknown; result?: unknown }
|
| 40 |
| undefined;
|
| 41 |
if (!inv?.toolName) return null;
|
| 42 |
return {
|
| 43 |
toolName: inv.toolName,
|
| 44 |
state: inv.state ?? "call",
|
| 45 |
+
input: inv.args,
|
| 46 |
result: inv.result,
|
| 47 |
};
|
| 48 |
}
|
|
|
|
| 55 |
return {
|
| 56 |
toolName,
|
| 57 |
state: normalized,
|
| 58 |
+
input: anyPart.input,
|
| 59 |
result: anyPart.output,
|
| 60 |
};
|
| 61 |
}
|
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { EmbedDataFileMeta } from "../editor/embeds/embed-data-store";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Light-weight client-side parsing of uploaded data files. Extracts
|
| 5 |
+
* column names and row counts so the sidebar and agent system prompt
|
| 6 |
+
* can preview dataset shape without shipping the full content every
|
| 7 |
+
* time.
|
| 8 |
+
*
|
| 9 |
+
* Parsing is deliberately forgiving: it never throws on malformed
|
| 10 |
+
* input, and returns `undefined` metadata fields when shape cannot be
|
| 11 |
+
* inferred.
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
export const ACCEPTED_DATA_EXTS = ["csv", "tsv", "json", "txt", "ndjson"] as const;
|
| 15 |
+
export type AcceptedDataExt = (typeof ACCEPTED_DATA_EXTS)[number];
|
| 16 |
+
|
| 17 |
+
export const MAX_DATA_FILE_SIZE = 3 * 1024 * 1024;
|
| 18 |
+
|
| 19 |
+
export function extFromName(name: string): string {
|
| 20 |
+
const match = name.toLowerCase().match(/\.([a-z0-9]+)$/);
|
| 21 |
+
return match ? match[1] : "";
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export function isAcceptedExt(ext: string): ext is AcceptedDataExt {
|
| 25 |
+
return (ACCEPTED_DATA_EXTS as readonly string[]).includes(ext);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function splitCsvLine(line: string, delim: string): string[] {
|
| 29 |
+
const out: string[] = [];
|
| 30 |
+
let cur = "";
|
| 31 |
+
let inQuotes = false;
|
| 32 |
+
for (let i = 0; i < line.length; i++) {
|
| 33 |
+
const c = line[i];
|
| 34 |
+
if (inQuotes) {
|
| 35 |
+
if (c === '"') {
|
| 36 |
+
if (line[i + 1] === '"') {
|
| 37 |
+
cur += '"';
|
| 38 |
+
i++;
|
| 39 |
+
} else {
|
| 40 |
+
inQuotes = false;
|
| 41 |
+
}
|
| 42 |
+
} else {
|
| 43 |
+
cur += c;
|
| 44 |
+
}
|
| 45 |
+
} else if (c === '"') {
|
| 46 |
+
inQuotes = true;
|
| 47 |
+
} else if (c === delim) {
|
| 48 |
+
out.push(cur);
|
| 49 |
+
cur = "";
|
| 50 |
+
} else {
|
| 51 |
+
cur += c;
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
out.push(cur);
|
| 55 |
+
return out;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
interface ParsedShape {
|
| 59 |
+
rowCount?: number;
|
| 60 |
+
columns?: string[];
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function parseDelimited(content: string, delim: string): ParsedShape {
|
| 64 |
+
const lines = content
|
| 65 |
+
.split(/\r\n|\n|\r/)
|
| 66 |
+
.filter((l) => l.length > 0);
|
| 67 |
+
if (lines.length === 0) return {};
|
| 68 |
+
const header = splitCsvLine(lines[0], delim).map((c) => c.trim());
|
| 69 |
+
return {
|
| 70 |
+
columns: header,
|
| 71 |
+
rowCount: Math.max(0, lines.length - 1),
|
| 72 |
+
};
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
function parseJson(content: string): ParsedShape {
|
| 76 |
+
try {
|
| 77 |
+
const parsed = JSON.parse(content);
|
| 78 |
+
if (Array.isArray(parsed)) {
|
| 79 |
+
const first = parsed.find((r) => r && typeof r === "object");
|
| 80 |
+
return {
|
| 81 |
+
rowCount: parsed.length,
|
| 82 |
+
columns: first ? Object.keys(first as Record<string, unknown>) : undefined,
|
| 83 |
+
};
|
| 84 |
+
}
|
| 85 |
+
if (parsed && typeof parsed === "object") {
|
| 86 |
+
return { columns: Object.keys(parsed as Record<string, unknown>) };
|
| 87 |
+
}
|
| 88 |
+
} catch {
|
| 89 |
+
// swallow - return empty shape
|
| 90 |
+
}
|
| 91 |
+
return {};
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
function parseNdjson(content: string): ParsedShape {
|
| 95 |
+
const lines = content.split(/\r\n|\n|\r/).filter((l) => l.trim().length > 0);
|
| 96 |
+
if (lines.length === 0) return {};
|
| 97 |
+
let columns: string[] | undefined;
|
| 98 |
+
try {
|
| 99 |
+
const first = JSON.parse(lines[0]);
|
| 100 |
+
if (first && typeof first === "object" && !Array.isArray(first)) {
|
| 101 |
+
columns = Object.keys(first as Record<string, unknown>);
|
| 102 |
+
}
|
| 103 |
+
} catch {
|
| 104 |
+
// ignore
|
| 105 |
+
}
|
| 106 |
+
return { rowCount: lines.length, columns };
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
export function inferDataShape(ext: string, content: string): ParsedShape {
|
| 110 |
+
switch (ext) {
|
| 111 |
+
case "csv":
|
| 112 |
+
return parseDelimited(content, ",");
|
| 113 |
+
case "tsv":
|
| 114 |
+
return parseDelimited(content, "\t");
|
| 115 |
+
case "json":
|
| 116 |
+
return parseJson(content);
|
| 117 |
+
case "ndjson":
|
| 118 |
+
return parseNdjson(content);
|
| 119 |
+
default:
|
| 120 |
+
return {};
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
/**
|
| 125 |
+
* Build a lightweight manifest line per file suitable for inclusion in
|
| 126 |
+
* the agent system prompt. Keeps it to one line per file so many
|
| 127 |
+
* datasets can coexist without exploding prompt size.
|
| 128 |
+
*/
|
| 129 |
+
export function formatManifestLine(meta: EmbedDataFileMeta): string {
|
| 130 |
+
const size = formatBytes(meta.size);
|
| 131 |
+
const shape =
|
| 132 |
+
meta.rowCount !== undefined
|
| 133 |
+
? ` - ${meta.rowCount} rows`
|
| 134 |
+
: "";
|
| 135 |
+
const cols = meta.columns && meta.columns.length > 0
|
| 136 |
+
? ` - columns: ${meta.columns.slice(0, 12).join(", ")}${meta.columns.length > 12 ? ", ..." : ""}`
|
| 137 |
+
: "";
|
| 138 |
+
return `- ${meta.name} (${meta.ext.toUpperCase()}, ${size}${shape})${cols}`;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
export function formatBytes(bytes: number): string {
|
| 142 |
+
if (bytes < 1024) return `${bytes} B`;
|
| 143 |
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
| 144 |
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
| 145 |
+
}
|