@docscode/core 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,60 +0,0 @@
1
- import * as Y from 'yjs';
2
-
3
- export class StreamBuffer {
4
- private text: Y.Text;
5
- private clientID: number;
6
- private buffer: string = '';
7
- private flushInterval: number;
8
- private timer: any = null;
9
- private currentIndex: number;
10
-
11
- constructor(text: Y.Text, clientID: number, startIndex: number, flushInterval: number = 50) {
12
- this.text = text;
13
- this.clientID = clientID;
14
- this.currentIndex = startIndex;
15
- this.flushInterval = flushInterval;
16
- }
17
-
18
- /**
19
- * Add a new token to the buffer
20
- */
21
- public push(token: string) {
22
- this.buffer += token;
23
- if (!this.timer) {
24
- this.timer = setTimeout(() => this.flush(), this.flushInterval);
25
- }
26
- }
27
-
28
- /**
29
- * Immediately flush the buffer to the Yjs document
30
- */
31
- public flush() {
32
- if (this.buffer.length === 0) {
33
- this.timer = null;
34
- return;
35
- }
36
-
37
- const contentToInsert = this.buffer;
38
- this.buffer = '';
39
-
40
- this.text.doc?.transact(() => {
41
- this.text.insert(this.currentIndex, contentToInsert, {
42
- 'ai-generated': true,
43
- 'ai-client-id': this.clientID
44
- });
45
- this.currentIndex += contentToInsert.length;
46
- }, this.clientID);
47
-
48
- this.timer = null;
49
- }
50
-
51
- /**
52
- * Stop any pending flushes
53
- */
54
- public stop() {
55
- if (this.timer) {
56
- clearTimeout(this.timer);
57
- this.timer = null;
58
- }
59
- }
60
- }
@@ -1,100 +0,0 @@
1
- import * as Y from 'yjs';
2
- import { KairoPlugin } from './KairoPlugin.js';
3
- import { AICollaborator } from './AICollaborator.js';
4
- import type { LLMAdapter } from './LLMAdapter.js';
5
-
6
- /**
7
- * SummarizationPlugin — Trigger document summarization with '/summarize' in the text.
8
- * The AI peer generates a summary and inserts it as a blockquote below the trigger.
9
- */
10
- export class SummarizationPlugin extends KairoPlugin {
11
- private targetTextName: string;
12
- private adapter: LLMAdapter;
13
- private _deepObserver: (events: Y.YEvent<any>[]) => void;
14
-
15
- constructor(ai: AICollaborator, adapter: LLMAdapter, targetTextName: string = 'content') {
16
- super(ai);
17
- this.adapter = adapter;
18
- this.targetTextName = targetTextName;
19
- this._deepObserver = (events: Y.YEvent<any>[]) => {
20
- for (const event of events) {
21
- if (event instanceof Y.YTextEvent) {
22
- this._handleUpdate(event);
23
- }
24
- }
25
- };
26
- }
27
-
28
- public setup() {
29
- try {
30
- const arr = this.ai.doc.getArray(this.targetTextName);
31
- arr.observeDeep(this._deepObserver);
32
- } catch { }
33
- console.log(`[Kairo] SummarizationPlugin initialized — model: ${this.adapter.model}`);
34
- }
35
-
36
- public destroy() {
37
- try {
38
- const arr = this.ai.doc.getArray(this.targetTextName);
39
- arr.unobserveDeep(this._deepObserver);
40
- } catch { }
41
- }
42
-
43
-
44
- private _handleUpdate(event: Y.YTextEvent) {
45
- if (!this.enabled) return;
46
- if (event.transaction.origin === this.ai.clientID) return;
47
-
48
- const text = event.target as Y.Text;
49
- const str = text.toString();
50
-
51
- if (str.endsWith('/summarize')) {
52
- this._performSummarization(text);
53
- }
54
- }
55
-
56
- private async _performSummarization(text: Y.Text) {
57
- this.ai.setThinking();
58
-
59
- try {
60
- const fullText = text.toString();
61
- const TRIGGER = '/summarize';
62
- const contentToSummarize = fullText.slice(0, -TRIGGER.length).trim();
63
-
64
- if (contentToSummarize.length < 20) {
65
- throw new Error('Content too short to summarize');
66
- }
67
-
68
- // Use cache to avoid re-summarizing identical content
69
- const cached = this.ai.cache.get(contentToSummarize);
70
- const summary = cached ?? await this.adapter.complete(
71
- `Summarize the following document concisely in 2-3 sentences:\n\n${contentToSummarize}`,
72
- {
73
- systemPrompt: 'You are a document summarizer. Output only the summary — no preamble.',
74
- maxTokens: 150,
75
- }
76
- );
77
-
78
- if (!cached) {
79
- this.ai.cache.set(contentToSummarize, summary);
80
- }
81
-
82
- this.ai.setWriting();
83
-
84
- this.ai.doc.transact(() => {
85
- const idx = text.length - TRIGGER.length;
86
- text.delete(idx, TRIGGER.length);
87
- text.insert(idx, `\n\n> **AI Summary:** ${summary.trim()}`, {
88
- 'ai-generated': true,
89
- 'ai-client-id': this.ai.clientID,
90
- 'ai-timestamp': Date.now(),
91
- });
92
- }, this.ai.clientID);
93
-
94
- } catch (error) {
95
- console.error('[Kairo] Summarization error:', error);
96
- } finally {
97
- this.ai.setIdle();
98
- }
99
- }
100
- }
@@ -1,48 +0,0 @@
1
- import { pipeline } from '@huggingface/transformers';
2
- import { LLMAdapter } from './LLMAdapter.js';
3
-
4
- export class TransformersAdapter implements LLMAdapter {
5
- public name = 'transformers.js';
6
- private modelName: string;
7
- private pipe: any = null;
8
-
9
- constructor(modelName: string = 'Xenova/gpt2') {
10
- this.modelName = modelName;
11
- }
12
-
13
- public async generate(prompt: string, options: any = {}): Promise<string> {
14
- if (!this.pipe) {
15
- this.pipe = await pipeline('text-generation', this.modelName);
16
- }
17
-
18
- const output = await this.pipe(prompt, {
19
- max_new_tokens: 20,
20
- do_sample: true,
21
- ...options
22
- });
23
-
24
- const generatedText = output[0].generated_text;
25
- // Extract only the new part if the model returns the whole prompt
26
- return generatedText.startsWith(prompt)
27
- ? generatedText.substring(prompt.length)
28
- : generatedText;
29
- }
30
-
31
- public async generateStream(prompt: string, onToken: (token: string) => void, options: any = {}): Promise<void> {
32
- if (!this.pipe) {
33
- this.pipe = await pipeline('text-generation', this.modelName);
34
- }
35
-
36
- // Transformers.js supports stream-like output via callbacks in some versions,
37
- // or we can simulate it for now if the specific model doesn't support it natively.
38
- // For GPT-2, we'll simulate the stream for the demo, but use the real iterator if available.
39
- const result = await this.generate(prompt, options);
40
-
41
- // Simulate streaming by breaking the result into chunks
42
- const chunks = result.split(/(\s+)/);
43
- for (const chunk of chunks) {
44
- onToken(chunk);
45
- await new Promise(resolve => setTimeout(resolve, 30));
46
- }
47
- }
48
- }
@@ -1,72 +0,0 @@
1
- import type { LLMAdapter, StreamOptions } from '../LLMAdapter.js';
2
-
3
- /**
4
- * AnthropicAdapter — Production Claude streaming via raw fetch SSE.
5
- * Zero SDK dependency. Uses Messages API with streaming.
6
- */
7
- export class AnthropicAdapter implements LLMAdapter {
8
- readonly provider = 'anthropic';
9
- readonly model: string;
10
- private apiKey: string;
11
-
12
- constructor(options: { apiKey?: string; model?: string } = {}) {
13
- this.apiKey = options.apiKey ?? process.env['ANTHROPIC_API_KEY'] ?? '';
14
- this.model = options.model ?? 'claude-3-5-sonnet-20241022';
15
- }
16
-
17
- async *stream(prompt: string, options: StreamOptions = {}): AsyncGenerator<string> {
18
- const response = await fetch('https://api.anthropic.com/v1/messages', {
19
- method: 'POST',
20
- headers: {
21
- 'x-api-key': this.apiKey,
22
- 'anthropic-version': '2023-06-01',
23
- 'content-type': 'application/json',
24
- },
25
- body: JSON.stringify({
26
- model: this.model,
27
- max_tokens: options.maxTokens ?? 2000,
28
- system: options.systemPrompt ?? 'You are Kairo, an AI document collaborator.',
29
- messages: [{ role: 'user', content: prompt }],
30
- stream: true,
31
- }),
32
- signal: options.signal,
33
- });
34
-
35
- if (!response.ok) {
36
- const err = await response.text();
37
- throw new Error(`[Kairo/Anthropic] API error ${response.status}: ${err}`);
38
- }
39
-
40
- const reader = response.body!.getReader();
41
- const decoder = new TextDecoder();
42
- let buf = '';
43
-
44
- try {
45
- while (true) {
46
- const { done, value } = await reader.read();
47
- if (done) break;
48
- buf += decoder.decode(value, { stream: true });
49
- const lines = buf.split('\n');
50
- buf = lines.pop() ?? '';
51
- for (const line of lines) {
52
- const t = line.trim();
53
- if (!t.startsWith('data: ')) continue;
54
- try {
55
- const json = JSON.parse(t.slice(6));
56
- if (json.type === 'content_block_delta' && json.delta?.text) {
57
- yield json.delta.text;
58
- }
59
- } catch { /* skip */ }
60
- }
61
- }
62
- } finally {
63
- reader.releaseLock();
64
- }
65
- }
66
-
67
- async complete(prompt: string, options: StreamOptions = {}): Promise<string> {
68
- let out = '';
69
- for await (const t of this.stream(prompt, options)) out += t;
70
- return out;
71
- }
72
- }
@@ -1,72 +0,0 @@
1
- import type { LLMAdapter, StreamOptions } from '../LLMAdapter.js';
2
-
3
- /**
4
- * GeminiAdapter — Google Gemini streaming via raw fetch SSE.
5
- * Supports Gemini 2.0 Flash, Gemini 1.5 Pro/Flash.
6
- * Zero SDK dependency.
7
- */
8
- export class GeminiAdapter implements LLMAdapter {
9
- readonly provider = 'gemini';
10
- readonly model: string;
11
- private apiKey: string;
12
-
13
- constructor(options: { apiKey?: string; model?: string } = {}) {
14
- this.apiKey = options.apiKey ?? process.env['GOOGLE_AI_API_KEY'] ?? '';
15
- this.model = options.model ?? 'gemini-2.0-flash';
16
- }
17
-
18
- async *stream(prompt: string, options: StreamOptions = {}): AsyncGenerator<string> {
19
- const url = `https://generativelanguage.googleapis.com/v1beta/models/${this.model}:streamGenerateContent?alt=sse&key=${this.apiKey}`;
20
-
21
- const contents = options.systemPrompt
22
- ? [{ role: 'user', parts: [{ text: `${options.systemPrompt}\n\n${prompt}` }] }]
23
- : [{ role: 'user', parts: [{ text: prompt }] }];
24
-
25
- const response = await fetch(url, {
26
- method: 'POST',
27
- headers: { 'Content-Type': 'application/json' },
28
- body: JSON.stringify({
29
- contents,
30
- generationConfig: {
31
- maxOutputTokens: options.maxTokens ?? 2000,
32
- temperature: options.temperature ?? 0.7,
33
- },
34
- }),
35
- signal: options.signal,
36
- });
37
-
38
- if (!response.ok) {
39
- throw new Error(`[Kairo/Gemini] Error ${response.status}: ${await response.text()}`);
40
- }
41
-
42
- const reader = response.body!.getReader();
43
- const decoder = new TextDecoder();
44
- let buf = '';
45
-
46
- try {
47
- while (true) {
48
- const { done, value } = await reader.read();
49
- if (done) break;
50
- buf += decoder.decode(value, { stream: true });
51
- const lines = buf.split('\n');
52
- buf = lines.pop() ?? '';
53
- for (const line of lines) {
54
- if (!line.startsWith('data: ')) continue;
55
- try {
56
- const json = JSON.parse(line.slice(6));
57
- const text = json.candidates?.[0]?.content?.parts?.[0]?.text;
58
- if (text) yield text;
59
- } catch { /* skip */ }
60
- }
61
- }
62
- } finally {
63
- reader.releaseLock();
64
- }
65
- }
66
-
67
- async complete(prompt: string, options: StreamOptions = {}): Promise<string> {
68
- let out = '';
69
- for await (const t of this.stream(prompt, options)) out += t;
70
- return out;
71
- }
72
- }
@@ -1,81 +0,0 @@
1
- import type { LLMAdapter, StreamOptions } from '../LLMAdapter.js';
2
-
3
- /**
4
- * OllamaAdapter — Local-first LLM streaming via Ollama REST API.
5
- * No cloud. No API key. Pure local inference.
6
- * Works with llama3, mistral, codellama, phi3, gemma2, qwen2.5, etc.
7
- */
8
- export class OllamaAdapter implements LLMAdapter {
9
- readonly provider = 'ollama';
10
- readonly model: string;
11
- private baseUrl: string;
12
-
13
- constructor(options: { model?: string; baseUrl?: string } = {}) {
14
- this.model = options.model ?? 'llama3.2';
15
- this.baseUrl = options.baseUrl ?? 'http://localhost:11434';
16
- }
17
-
18
- async *stream(prompt: string, options: StreamOptions = {}): AsyncGenerator<string> {
19
- const fullPrompt = options.systemPrompt
20
- ? `${options.systemPrompt}\n\nUser: ${prompt}\nAssistant:`
21
- : prompt;
22
-
23
- const response = await fetch(`${this.baseUrl}/api/generate`, {
24
- method: 'POST',
25
- headers: { 'Content-Type': 'application/json' },
26
- body: JSON.stringify({
27
- model: this.model,
28
- prompt: fullPrompt,
29
- stream: true,
30
- options: {
31
- num_predict: options.maxTokens ?? 2000,
32
- temperature: options.temperature ?? 0.7,
33
- },
34
- }),
35
- signal: options.signal,
36
- });
37
-
38
- if (!response.ok) {
39
- throw new Error(`[Kairo/Ollama] Error ${response.status}. Is Ollama running at ${this.baseUrl}?`);
40
- }
41
-
42
- const reader = response.body!.getReader();
43
- const decoder = new TextDecoder();
44
-
45
- try {
46
- while (true) {
47
- const { done, value } = await reader.read();
48
- if (done) break;
49
- const text = decoder.decode(value);
50
- for (const line of text.split('\n')) {
51
- if (!line.trim()) continue;
52
- try {
53
- const json = JSON.parse(line) as { response?: string; done?: boolean };
54
- if (json.response) yield json.response;
55
- if (json.done) return;
56
- } catch { /* skip */ }
57
- }
58
- }
59
- } finally {
60
- reader.releaseLock();
61
- }
62
- }
63
-
64
- async complete(prompt: string, options: StreamOptions = {}): Promise<string> {
65
- let out = '';
66
- for await (const t of this.stream(prompt, options)) out += t;
67
- return out;
68
- }
69
-
70
- /** Check if Ollama is running and model is available */
71
- async isAvailable(): Promise<boolean> {
72
- try {
73
- const r = await fetch(`${this.baseUrl}/api/tags`, { signal: AbortSignal.timeout(2000) });
74
- if (!r.ok) return false;
75
- const json = await r.json() as { models: { name: string }[] };
76
- return json.models.some(m => m.name.startsWith(this.model));
77
- } catch {
78
- return false;
79
- }
80
- }
81
- }
@@ -1,87 +0,0 @@
1
- import type { LLMAdapter, StreamOptions } from '../LLMAdapter.js';
2
-
3
- /**
4
- * OpenAIAdapter — Production streaming adapter using raw fetch (zero SDK).
5
- * Works with OpenAI API and any OpenAI-compatible endpoint (Together, Groq, etc.)
6
- */
7
- export class OpenAIAdapter implements LLMAdapter {
8
- readonly provider = 'openai';
9
- readonly model: string;
10
- private apiKey: string;
11
- private baseUrl: string;
12
-
13
- constructor(options: { apiKey?: string; model?: string; baseUrl?: string } = {}) {
14
- this.apiKey = options.apiKey ?? process.env['OPENAI_API_KEY'] ?? '';
15
- this.model = options.model ?? 'gpt-4o-mini';
16
- this.baseUrl = options.baseUrl ?? 'https://api.openai.com/v1';
17
- }
18
-
19
- async *stream(prompt: string, options: StreamOptions = {}): AsyncGenerator<string> {
20
- const response = await fetch(`${this.baseUrl}/chat/completions`, {
21
- method: 'POST',
22
- headers: {
23
- Authorization: `Bearer ${this.apiKey}`,
24
- 'Content-Type': 'application/json',
25
- },
26
- body: JSON.stringify({
27
- model: this.model,
28
- messages: [
29
- ...(options.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),
30
- { role: 'user', content: prompt },
31
- ],
32
- max_tokens: options.maxTokens ?? 2000,
33
- temperature: options.temperature ?? 0.7,
34
- stream: true,
35
- }),
36
- signal: options.signal,
37
- });
38
-
39
- if (!response.ok) {
40
- const err = await response.text();
41
- throw new Error(`[Kairo/OpenAI] API error ${response.status}: ${err}`);
42
- }
43
-
44
- const reader = response.body!.getReader();
45
- const decoder = new TextDecoder();
46
- let buf = '';
47
-
48
- try {
49
- while (true) {
50
- const { done, value } = await reader.read();
51
- if (done) break;
52
- buf += decoder.decode(value, { stream: true });
53
- const lines = buf.split('\n');
54
- buf = lines.pop() ?? '';
55
- for (const line of lines) {
56
- const t = line.trim();
57
- if (!t || t === 'data: [DONE]') continue;
58
- if (!t.startsWith('data: ')) continue;
59
- try {
60
- const json = JSON.parse(t.slice(6));
61
- const token = json.choices?.[0]?.delta?.content;
62
- if (token) yield token;
63
- } catch { /* malformed chunk, skip */ }
64
- }
65
- }
66
- } finally {
67
- reader.releaseLock();
68
- }
69
- }
70
-
71
- async complete(prompt: string, options: StreamOptions = {}): Promise<string> {
72
- let out = '';
73
- for await (const t of this.stream(prompt, options)) out += t;
74
- return out;
75
- }
76
-
77
- async embed(text: string): Promise<number[]> {
78
- const r = await fetch(`${this.baseUrl}/embeddings`, {
79
- method: 'POST',
80
- headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
81
- body: JSON.stringify({ model: 'text-embedding-3-small', input: text }),
82
- });
83
- if (!r.ok) throw new Error(`[Kairo/OpenAI] Embed error ${r.status}`);
84
- const json = await r.json() as { data: { embedding: number[] }[] };
85
- return json.data[0].embedding;
86
- }
87
- }
@@ -1,164 +0,0 @@
1
- import * as Y from 'yjs';
2
-
3
- /**
4
- * Kairo Canonical Document Model — The universal CRDT schema.
5
- *
6
- * Every document format (DOCX, PDF, MD, HTML, GDoc) normalizes to this schema.
7
- * The AI peer reads and writes exclusively via this model.
8
- *
9
- * Schema:
10
- * metadata: Y.Map — title, author, format, source, lastModified, etc.
11
- * content: Y.Array — ordered array of Block Y.Maps
12
- *
13
- * Block structure (Y.Map):
14
- * type: 'p' | 'h1'–'h6' | 'li' | 'code' | 'table' | 'blockquote' | 'hr' | 'image'
15
- * id: string (stable UUID for anchor references)
16
- * text: Y.Text (the actual content, with rich attributes for formatting)
17
- * meta: Y.Map (optional, e.g. { lang: 'typescript' } for code blocks)
18
- *
19
- * Text attributes (Y.Text marks):
20
- * ai-generated: true — marks AI-inserted ranges
21
- * ai-client-id: number — which AI peer inserted this
22
- * ai-timestamp: number — when
23
- * ai-model: string — which model
24
- * bold / italic / code: — inline formatting
25
- */
26
-
27
- export type BlockType =
28
- | 'p' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
29
- | 'li' | 'oli' | 'code' | 'table' | 'blockquote' | 'hr' | 'image';
30
-
31
- export interface CanonicalBlockMeta {
32
- lang?: string; // code block language
33
- level?: number; // heading level (redundant with type, kept for tooling)
34
- src?: string; // image src
35
- alt?: string; // image alt text
36
- aiConfidence?: number; // confidence score for AI-generated blocks
37
- [key: string]: unknown;
38
- }
39
-
40
- export class CanonicalDoc {
41
- constructor(public readonly yDoc: Y.Doc) {
42
- // Ensure maps exist on construction
43
- this.yDoc.getMap('metadata');
44
- this.yDoc.getArray('content');
45
- }
46
-
47
- get metadata(): Y.Map<any> {
48
- return this.yDoc.getMap('metadata');
49
- }
50
-
51
- get content(): Y.Array<any> {
52
- return this.yDoc.getArray('content');
53
- }
54
-
55
- // ─── Block Factory ─────────────────────────────────────────────────
56
-
57
- /**
58
- * Add a block to the document.
59
- * Returns the Y.Text of the block so callers can further instrument it.
60
- */
61
- addBlock(type: BlockType | string, text: string = '', meta?: CanonicalBlockMeta): Y.Text {
62
- const block = new Y.Map<any>();
63
- const yText = new Y.Text(text);
64
- const id = `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
65
-
66
- block.set('type', type);
67
- block.set('id', id);
68
- block.set('text', yText);
69
-
70
- if (meta) {
71
- const yMeta = new Y.Map<any>();
72
- for (const [k, v] of Object.entries(meta)) {
73
- yMeta.set(k, v);
74
- }
75
- block.set('meta', yMeta);
76
- }
77
-
78
- this.content.push([block]);
79
- return yText;
80
- }
81
-
82
- /** Convenience: add a paragraph block */
83
- addParagraph(text: string = ''): Y.Text {
84
- return this.addBlock('p', text);
85
- }
86
-
87
- /** Convenience: add a heading block */
88
- addHeading(level: 1 | 2 | 3 | 4 | 5 | 6, text: string): Y.Text {
89
- return this.addBlock(`h${level}`, text);
90
- }
91
-
92
- /** Set or update metadata on an existing block's Y.Text */
93
- setBlockMeta(yText: Y.Text, meta: CanonicalBlockMeta): void {
94
- // Walk blocks to find the one owning this Y.Text
95
- for (const block of this.content.toArray()) {
96
- if (block.get('text') === yText) {
97
- let yMeta = block.get('meta') as Y.Map<any> | undefined;
98
- if (!yMeta) {
99
- yMeta = new Y.Map<any>();
100
- block.set('meta', yMeta);
101
- }
102
- for (const [k, v] of Object.entries(meta)) {
103
- yMeta.set(k, v);
104
- }
105
- return;
106
- }
107
- }
108
- }
109
-
110
- /** Get block by index */
111
- getBlock(index: number): Y.Map<any> | undefined {
112
- return this.content.get(index);
113
- }
114
-
115
- /** Find block by id */
116
- findById(id: string): Y.Map<any> | undefined {
117
- return this.content.toArray().find(b => b.get('id') === id);
118
- }
119
-
120
- /** Get all text content as plain string */
121
- toPlainText(): string {
122
- return this.content.toArray()
123
- .map(block => {
124
- const yText = block.get('text') as Y.Text | undefined;
125
- return yText?.toString() ?? '';
126
- })
127
- .filter(Boolean)
128
- .join('\n\n');
129
- }
130
-
131
- /** Get document stats */
132
- stats(): { blockCount: number; charCount: number; wordCount: number } {
133
- const text = this.toPlainText();
134
- const words = text.split(/\s+/).filter(Boolean);
135
- return {
136
- blockCount: this.content.length,
137
- charCount: text.length,
138
- wordCount: words.length,
139
- };
140
- }
141
-
142
- /** Get AI-contributed character count */
143
- aiContributions(): { charCount: number; blockCount: number } {
144
- let charCount = 0;
145
- let blockCount = 0;
146
-
147
- for (const block of this.content.toArray()) {
148
- const yText = block.get('text') as Y.Text | undefined;
149
- if (!yText) continue;
150
-
151
- let blockHasAI = false;
152
- // Walk deltas to count AI-attributed characters
153
- for (const delta of yText.toDelta()) {
154
- if (delta.attributes?.['ai-generated'] && delta.insert) {
155
- charCount += delta.insert.length;
156
- blockHasAI = true;
157
- }
158
- }
159
- if (blockHasAI) blockCount++;
160
- }
161
-
162
- return { charCount, blockCount };
163
- }
164
- }