@saena-io/create 0.1.0 → 0.2.0
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.
- package/dist/index.js +9 -9
- package/package.json +1 -1
- package/template/base/package.json +44 -2
- package/template/base/scripts/ui-update.ts +83 -0
- package/template/base/src/components/ui/accordion.tsx +75 -0
- package/template/base/src/components/ui/alert-dialog.tsx +162 -0
- package/template/base/src/components/ui/alert.tsx +73 -0
- package/template/base/src/components/ui/app-sidebar.tsx +183 -0
- package/template/base/src/components/ui/aspect-ratio.tsx +22 -0
- package/template/base/src/components/ui/asset-input.tsx +211 -0
- package/template/base/src/components/ui/avatar.tsx +91 -0
- package/template/base/src/components/ui/badge.tsx +50 -0
- package/template/base/src/components/ui/breadcrumb.tsx +104 -0
- package/template/base/src/components/ui/button-group.tsx +78 -0
- package/template/base/src/components/ui/button.tsx +56 -0
- package/template/base/src/components/ui/calendar.tsx +205 -0
- package/template/base/src/components/ui/card.tsx +85 -0
- package/template/base/src/components/ui/carousel.tsx +232 -0
- package/template/base/src/components/ui/chart.tsx +337 -0
- package/template/base/src/components/ui/checkbox.tsx +29 -0
- package/template/base/src/components/ui/collapsible.tsx +15 -0
- package/template/base/src/components/ui/combobox.tsx +276 -0
- package/template/base/src/components/ui/command.tsx +190 -0
- package/template/base/src/components/ui/context-menu.tsx +243 -0
- package/template/base/src/components/ui/dialog.tsx +134 -0
- package/template/base/src/components/ui/direction.tsx +4 -0
- package/template/base/src/components/ui/drawer.tsx +120 -0
- package/template/base/src/components/ui/dropdown-menu.tsx +254 -0
- package/template/base/src/components/ui/empty.tsx +94 -0
- package/template/base/src/components/ui/field.tsx +222 -0
- package/template/base/src/components/ui/focal-point-picker.tsx +175 -0
- package/template/base/src/components/ui/hover-card.tsx +46 -0
- package/template/base/src/components/ui/input-group.tsx +149 -0
- package/template/base/src/components/ui/input-otp.tsx +85 -0
- package/template/base/src/components/ui/input.tsx +20 -0
- package/template/base/src/components/ui/item.tsx +188 -0
- package/template/base/src/components/ui/kbd.tsx +26 -0
- package/template/base/src/components/ui/label.tsx +20 -0
- package/template/base/src/components/ui/menubar.tsx +268 -0
- package/template/base/src/components/ui/native-select.tsx +58 -0
- package/template/base/src/components/ui/nav-main.tsx +70 -0
- package/template/base/src/components/ui/nav-projects.tsx +97 -0
- package/template/base/src/components/ui/nav-secondary.tsx +37 -0
- package/template/base/src/components/ui/nav-user.tsx +108 -0
- package/template/base/src/components/ui/navigation-menu.tsx +164 -0
- package/template/base/src/components/ui/pagination.tsx +123 -0
- package/template/base/src/components/ui/popover.tsx +80 -0
- package/template/base/src/components/ui/progress.tsx +66 -0
- package/template/base/src/components/ui/radio-group.tsx +36 -0
- package/template/base/src/components/ui/resizable.tsx +42 -0
- package/template/base/src/components/ui/rich-text/ai-chat-editor.tsx +20 -0
- package/template/base/src/components/ui/rich-text/ai-command.tsx +90 -0
- package/template/base/src/components/ui/rich-text/ai-copilot.tsx +67 -0
- package/template/base/src/components/ui/rich-text/ai-menu.tsx +456 -0
- package/template/base/src/components/ui/rich-text/ai-node.tsx +42 -0
- package/template/base/src/components/ui/rich-text/ai-toolbar-button.tsx +29 -0
- package/template/base/src/components/ui/rich-text/block-draggable.tsx +187 -0
- package/template/base/src/components/ui/rich-text/block-selection.tsx +17 -0
- package/template/base/src/components/ui/rich-text/code-block-node.tsx +204 -0
- package/template/base/src/components/ui/rich-text/codec.ts +63 -0
- package/template/base/src/components/ui/rich-text/extension.ts +53 -0
- package/template/base/src/components/ui/rich-text/ghost-text.tsx +23 -0
- package/template/base/src/components/ui/rich-text/import-export-toolbar.tsx +103 -0
- package/template/base/src/components/ui/rich-text/link.tsx +18 -0
- package/template/base/src/components/ui/rich-text/list-node.tsx +65 -0
- package/template/base/src/components/ui/rich-text/nodes.tsx +44 -0
- package/template/base/src/components/ui/rich-text/plugins.ts +233 -0
- package/template/base/src/components/ui/rich-text/rich-text-editor.tsx +82 -0
- package/template/base/src/components/ui/rich-text/static.tsx +117 -0
- package/template/base/src/components/ui/rich-text/table-node.tsx +934 -0
- package/template/base/src/components/ui/rich-text/table-toolbar.tsx +232 -0
- package/template/base/src/components/ui/rich-text/toggle-node.tsx +36 -0
- package/template/base/src/components/ui/rich-text/toolbar-slots.ts +41 -0
- package/template/base/src/components/ui/rich-text/toolbar.tsx +668 -0
- package/template/base/src/components/ui/rich-text/use-ai-chat.ts +35 -0
- package/template/base/src/components/ui/rich-text/variable-type.ts +4 -0
- package/template/base/src/components/ui/rich-text/variable.tsx +97 -0
- package/template/base/src/components/ui/scroll-area.tsx +49 -0
- package/template/base/src/components/ui/select.tsx +202 -0
- package/template/base/src/components/ui/separator.tsx +19 -0
- package/template/base/src/components/ui/sheet.tsx +126 -0
- package/template/base/src/components/ui/sidebar.tsx +695 -0
- package/template/base/src/components/ui/skeleton.tsx +13 -0
- package/template/base/src/components/ui/slider.tsx +52 -0
- package/template/base/src/components/ui/sonner.tsx +50 -0
- package/template/base/src/components/ui/spinner.tsx +18 -0
- package/template/base/src/components/ui/switch.tsx +30 -0
- package/template/base/src/components/ui/table.tsx +89 -0
- package/template/base/src/components/ui/tabs.tsx +73 -0
- package/template/base/src/components/ui/textarea.tsx +18 -0
- package/template/base/src/components/ui/toggle-group.tsx +85 -0
- package/template/base/src/components/ui/toggle.tsx +45 -0
- package/template/base/src/components/ui/toolbar.tsx +451 -0
- package/template/base/src/components/ui/tooltip.tsx +52 -0
- package/template/base/src/hooks/use-mobile.ts +19 -0
- package/template/base/src/lib/utils.ts +6 -0
- package/template/base/src/routes/__root.tsx +1 -1
- package/template/base/src/server/auth.ts +2 -2
- package/template/base/src/styles/globals.css +230 -0
- package/template/base/vite.config.ts +15 -1
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { CopilotPlugin } from '@platejs/ai/react';
|
|
2
|
+
import { stripMarkdown } from '@platejs/markdown';
|
|
3
|
+
import type { RichTextExtension } from './extension';
|
|
4
|
+
import { GhostText } from './ghost-text';
|
|
5
|
+
|
|
6
|
+
// The AI copilot — the EDITOR half of the AI feature (ADR-0010). A RichTextExtension that wires @platejs/ai's
|
|
7
|
+
// ghost-text autocomplete to a SAENA endpoint: as you type (debounced) or on Ctrl+Space, it requests a short
|
|
8
|
+
// continuation, shows it as muted ghost text, and Tab accepts. The endpoint resolves the shared 'ai'
|
|
9
|
+
// capability (@saena-io/ai) — the SAME integration the translation plugin uses — and returns { text };
|
|
10
|
+
// @platejs/ai POSTs { prompt, system } and reads { text } back (non-streaming). Authored against @saena-io/ui
|
|
11
|
+
// only (the host injects the endpoint path), so neither @saena-io/ai nor platejs leaks across the plugin boundary.
|
|
12
|
+
|
|
13
|
+
const DEFAULT_SYSTEM = `You are an inline writing copilot that predicts and continues the user's text.
|
|
14
|
+
Rules:
|
|
15
|
+
- Continue the text naturally up to the next punctuation mark (. , ; : ? or !).
|
|
16
|
+
- Match the existing style and tone; never repeat the given text.
|
|
17
|
+
- Stay in the same block — do NOT start a new block or use block formatting (>, #, 1., -, etc.).
|
|
18
|
+
- Always end with a punctuation mark.
|
|
19
|
+
- If there is no useful continuation, reply with exactly "0" and nothing else.`;
|
|
20
|
+
|
|
21
|
+
export interface AiCopilotOptions {
|
|
22
|
+
/** Endpoint the copilot POSTs { prompt, system } to and reads { text } from.
|
|
23
|
+
* Default: the @saena-io/ai capability via the plugin dispatch. */
|
|
24
|
+
api?: string;
|
|
25
|
+
/** Override the system instruction sent with each completion. */
|
|
26
|
+
system?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build the AI copilot extension. Pass it to `useRichTextEditor({ extensions })` (and, if you later add AI
|
|
31
|
+
* toolbar controls, to `RichTextToolbar`). The endpoint defaults to the shared `@saena-io/ai` capability; a
|
|
32
|
+
* failure (no key configured, provider error) degrades silently to "no suggestion".
|
|
33
|
+
*/
|
|
34
|
+
export function createAiCopilotExtension(options: AiCopilotOptions = {}): RichTextExtension {
|
|
35
|
+
const endpoint = options.api ?? '/api/plugin/ai.complete';
|
|
36
|
+
const system = options.system ?? DEFAULT_SYSTEM;
|
|
37
|
+
return {
|
|
38
|
+
id: 'ai',
|
|
39
|
+
platePlugins: [
|
|
40
|
+
CopilotPlugin.configure(({ api }) => ({
|
|
41
|
+
options: {
|
|
42
|
+
completeOptions: {
|
|
43
|
+
api: endpoint,
|
|
44
|
+
// `feature: 'copilot'` routes server-side model selection to the copilot's configured model
|
|
45
|
+
// (Settings → AI), so it can run on a fast model (e.g. MiMo) independently of translation.
|
|
46
|
+
body: { system, feature: 'copilot' },
|
|
47
|
+
onError: () => {
|
|
48
|
+
// No key / provider error → leave no suggestion (graceful degradation).
|
|
49
|
+
},
|
|
50
|
+
onFinish: (_prompt, completion) => {
|
|
51
|
+
if (completion === '0' || completion.trim().length === 0) return;
|
|
52
|
+
api.copilot.setBlockSuggestion({ text: stripMarkdown(completion) });
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
debounceDelay: 500,
|
|
56
|
+
renderGhostText: GhostText,
|
|
57
|
+
},
|
|
58
|
+
shortcuts: {
|
|
59
|
+
accept: { keys: 'tab' },
|
|
60
|
+
acceptNextWord: { keys: 'mod+right' },
|
|
61
|
+
reject: { keys: 'escape' },
|
|
62
|
+
triggerSuggestion: { keys: 'ctrl+space' },
|
|
63
|
+
},
|
|
64
|
+
})),
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AiMagicIcon,
|
|
5
|
+
ArrowDown01Icon,
|
|
6
|
+
ArrowExpandIcon,
|
|
7
|
+
ArrowReloadHorizontalIcon,
|
|
8
|
+
BubbleChatIcon,
|
|
9
|
+
Cancel01Icon,
|
|
10
|
+
Loading03Icon,
|
|
11
|
+
MagicWand01Icon,
|
|
12
|
+
QuillWrite01Icon,
|
|
13
|
+
SparklesIcon,
|
|
14
|
+
TextAlignLeftIcon,
|
|
15
|
+
Tick02Icon,
|
|
16
|
+
TickDouble01Icon,
|
|
17
|
+
} from '@hugeicons/core-free-icons';
|
|
18
|
+
import { HugeiconsIcon } from '@hugeicons/react';
|
|
19
|
+
import { replacePlaceholders } from '@platejs/ai';
|
|
20
|
+
import { AIChatPlugin, AIPlugin, useEditorChat, useLastAssistantMessage } from '@platejs/ai/react';
|
|
21
|
+
import { useIsSelecting } from '@platejs/selection/react';
|
|
22
|
+
import { Button } from '@saena-io/ui/components/button';
|
|
23
|
+
import { Command, CommandGroup, CommandItem, CommandList } from '@saena-io/ui/components/command';
|
|
24
|
+
import { Popover, PopoverContent } from '@saena-io/ui/components/popover';
|
|
25
|
+
import { cn } from '@saena-io/ui/lib/utils';
|
|
26
|
+
import { Command as CommandPrimitive } from 'cmdk';
|
|
27
|
+
import { type NodeEntry, type SlateEditor, isHotkey } from 'platejs';
|
|
28
|
+
import {
|
|
29
|
+
type PlateEditor,
|
|
30
|
+
useEditorPlugin,
|
|
31
|
+
useEditorRef,
|
|
32
|
+
useFocusedLast,
|
|
33
|
+
useHotkeys,
|
|
34
|
+
usePluginOption,
|
|
35
|
+
} from 'platejs/react';
|
|
36
|
+
import * as React from 'react';
|
|
37
|
+
import { AiChatEditor } from './ai-chat-editor';
|
|
38
|
+
|
|
39
|
+
// The AI command menu (ADR-0010), vendored from @platejs/ai's example and adapted to SAENA: pinned to the
|
|
40
|
+
// installed 53.2.2 `chat`-injection model (the chat instance is owned by use-ai-chat + injected via the
|
|
41
|
+
// plugin's useHooks, not a `chatOptions` config), built on SAENA's cmdk `command` + base-ui `popover`
|
|
42
|
+
// primitives (no Radix PopoverAnchor — the popover anchors via the Positioner `anchor` prop), and scoped to
|
|
43
|
+
// generate-mode only (no @platejs/suggestion diff machinery, which SAENA doesn't ship). The menu opens on
|
|
44
|
+
// space-in-empty-block, mod+J, or the toolbar button; on a collapsed cursor it streams a generated draft into
|
|
45
|
+
// the document (accept/discard); on a selection it previews a result to replace / insert-below.
|
|
46
|
+
|
|
47
|
+
// ── Command catalog ──────────────────────────────────────────────────────────────────────────────────────
|
|
48
|
+
// Prompts are resolved CLIENT-side with replacePlaceholders ({editor}/{block}/{blockSelection}/{prompt}) and
|
|
49
|
+
// passed to aiChat.submit as a plain prompt — the SAENA route maps messages → model and does NOT expand
|
|
50
|
+
// placeholders or rebuild the document (unlike the plate-ui example's heavier server).
|
|
51
|
+
|
|
52
|
+
type MenuItemContext = { aiEditor: SlateEditor; editor: PlateEditor; input: string };
|
|
53
|
+
type MenuItem = {
|
|
54
|
+
icon: typeof SparklesIcon;
|
|
55
|
+
label: string;
|
|
56
|
+
value: string;
|
|
57
|
+
onSelect: (ctx: MenuItemContext) => void;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function submitGenerate(
|
|
61
|
+
editor: PlateEditor,
|
|
62
|
+
input: string,
|
|
63
|
+
template: string,
|
|
64
|
+
mode?: 'insert' | 'chat',
|
|
65
|
+
) {
|
|
66
|
+
const prompt = replacePlaceholders(editor, template, { prompt: input });
|
|
67
|
+
void editor.getApi(AIChatPlugin).aiChat.submit(input, { prompt, toolName: 'generate', mode });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const aiChatItems = {
|
|
71
|
+
continueWrite: {
|
|
72
|
+
icon: QuillWrite01Icon,
|
|
73
|
+
label: 'Continue writing',
|
|
74
|
+
value: 'continueWrite',
|
|
75
|
+
onSelect: ({ editor, input }) =>
|
|
76
|
+
submitGenerate(
|
|
77
|
+
editor,
|
|
78
|
+
input,
|
|
79
|
+
'Continue writing the document naturally from where it ends. Return ONLY the continuation (a sentence or short paragraph), no preamble:\n\n{editor}',
|
|
80
|
+
'insert',
|
|
81
|
+
),
|
|
82
|
+
},
|
|
83
|
+
brainstorm: {
|
|
84
|
+
icon: AiMagicIcon,
|
|
85
|
+
label: 'Brainstorm ideas',
|
|
86
|
+
value: 'brainstorm',
|
|
87
|
+
onSelect: ({ editor, input }) =>
|
|
88
|
+
submitGenerate(
|
|
89
|
+
editor,
|
|
90
|
+
input,
|
|
91
|
+
input.trim().length > 0
|
|
92
|
+
? 'Brainstorm a short, useful list of ideas about: {prompt}'
|
|
93
|
+
: 'Brainstorm a short, useful list of ideas relevant to this document:\n\n{editor}',
|
|
94
|
+
'insert',
|
|
95
|
+
),
|
|
96
|
+
},
|
|
97
|
+
summarize: {
|
|
98
|
+
icon: TextAlignLeftIcon,
|
|
99
|
+
label: 'Summarize',
|
|
100
|
+
value: 'summarize',
|
|
101
|
+
onSelect: ({ editor, input }) =>
|
|
102
|
+
submitGenerate(
|
|
103
|
+
editor,
|
|
104
|
+
input,
|
|
105
|
+
'Write a concise summary of the document:\n\n{editor}',
|
|
106
|
+
'insert',
|
|
107
|
+
),
|
|
108
|
+
},
|
|
109
|
+
improveWriting: {
|
|
110
|
+
icon: MagicWand01Icon,
|
|
111
|
+
label: 'Improve writing',
|
|
112
|
+
value: 'improveWriting',
|
|
113
|
+
onSelect: ({ editor, input }) =>
|
|
114
|
+
submitGenerate(
|
|
115
|
+
editor,
|
|
116
|
+
input,
|
|
117
|
+
'Improve the writing for clarity and flow without changing the meaning. Return ONLY the improved text:\n\n{blockSelection}',
|
|
118
|
+
),
|
|
119
|
+
},
|
|
120
|
+
fixSpelling: {
|
|
121
|
+
icon: Tick02Icon,
|
|
122
|
+
label: 'Fix spelling & grammar',
|
|
123
|
+
value: 'fixSpelling',
|
|
124
|
+
onSelect: ({ editor, input }) =>
|
|
125
|
+
submitGenerate(
|
|
126
|
+
editor,
|
|
127
|
+
input,
|
|
128
|
+
'Fix spelling, grammar, and punctuation without changing meaning or tone. Return ONLY the corrected text:\n\n{blockSelection}',
|
|
129
|
+
),
|
|
130
|
+
},
|
|
131
|
+
makeShorter: {
|
|
132
|
+
icon: ArrowDown01Icon,
|
|
133
|
+
label: 'Make shorter',
|
|
134
|
+
value: 'makeShorter',
|
|
135
|
+
onSelect: ({ editor, input }) =>
|
|
136
|
+
submitGenerate(
|
|
137
|
+
editor,
|
|
138
|
+
input,
|
|
139
|
+
'Make the following text shorter and more concise without losing essential meaning. Return ONLY the shortened text:\n\n{blockSelection}',
|
|
140
|
+
),
|
|
141
|
+
},
|
|
142
|
+
makeLonger: {
|
|
143
|
+
icon: ArrowExpandIcon,
|
|
144
|
+
label: 'Make longer',
|
|
145
|
+
value: 'makeLonger',
|
|
146
|
+
onSelect: ({ editor, input }) =>
|
|
147
|
+
submitGenerate(
|
|
148
|
+
editor,
|
|
149
|
+
input,
|
|
150
|
+
'Expand and elaborate on the following text without changing its meaning. Return ONLY the expanded text:\n\n{blockSelection}',
|
|
151
|
+
),
|
|
152
|
+
},
|
|
153
|
+
simplify: {
|
|
154
|
+
icon: SparklesIcon,
|
|
155
|
+
label: 'Simplify language',
|
|
156
|
+
value: 'simplify',
|
|
157
|
+
onSelect: ({ editor, input }) =>
|
|
158
|
+
submitGenerate(
|
|
159
|
+
editor,
|
|
160
|
+
input,
|
|
161
|
+
'Simplify the language of the following text using clearer, plainer wording without changing meaning. Return ONLY the simplified text:\n\n{blockSelection}',
|
|
162
|
+
),
|
|
163
|
+
},
|
|
164
|
+
explain: {
|
|
165
|
+
icon: BubbleChatIcon,
|
|
166
|
+
label: 'Explain',
|
|
167
|
+
value: 'explain',
|
|
168
|
+
onSelect: ({ editor, input }) =>
|
|
169
|
+
submitGenerate(editor, input, 'Explain the following text clearly:\n\n{blockSelection}'),
|
|
170
|
+
},
|
|
171
|
+
// ── Suggestion actions (after a result exists) ──
|
|
172
|
+
accept: {
|
|
173
|
+
icon: TickDouble01Icon,
|
|
174
|
+
label: 'Accept',
|
|
175
|
+
value: 'accept',
|
|
176
|
+
onSelect: ({ editor }) => {
|
|
177
|
+
editor.getTransforms(AIChatPlugin).aiChat.accept();
|
|
178
|
+
editor.tf.focus({ edge: 'end' });
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
replace: {
|
|
182
|
+
icon: TickDouble01Icon,
|
|
183
|
+
label: 'Replace selection',
|
|
184
|
+
value: 'replace',
|
|
185
|
+
onSelect: ({ aiEditor, editor }) => {
|
|
186
|
+
void editor.getTransforms(AIChatPlugin).aiChat.replaceSelection(aiEditor);
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
insertBelow: {
|
|
190
|
+
icon: ArrowDown01Icon,
|
|
191
|
+
label: 'Insert below',
|
|
192
|
+
value: 'insertBelow',
|
|
193
|
+
onSelect: ({ aiEditor, editor }) => {
|
|
194
|
+
void editor.getTransforms(AIChatPlugin).aiChat.insertBelow(aiEditor, { format: 'none' });
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
tryAgain: {
|
|
198
|
+
icon: ArrowReloadHorizontalIcon,
|
|
199
|
+
label: 'Try again',
|
|
200
|
+
value: 'tryAgain',
|
|
201
|
+
onSelect: ({ editor }) => {
|
|
202
|
+
void editor.getApi(AIChatPlugin).aiChat.reload();
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
discard: {
|
|
206
|
+
icon: Cancel01Icon,
|
|
207
|
+
label: 'Discard',
|
|
208
|
+
value: 'discard',
|
|
209
|
+
onSelect: ({ editor }) => {
|
|
210
|
+
editor.getTransforms(AIPlugin).ai.undo();
|
|
211
|
+
editor.getApi(AIChatPlugin).aiChat.hide();
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
} satisfies Record<string, MenuItem>;
|
|
215
|
+
|
|
216
|
+
type MenuState = 'cursorCommand' | 'cursorSuggestion' | 'selectionCommand' | 'selectionSuggestion';
|
|
217
|
+
|
|
218
|
+
const menuStateItems: Record<MenuState, MenuItem[]> = {
|
|
219
|
+
cursorCommand: [aiChatItems.continueWrite, aiChatItems.summarize, aiChatItems.brainstorm],
|
|
220
|
+
cursorSuggestion: [aiChatItems.accept, aiChatItems.discard, aiChatItems.tryAgain],
|
|
221
|
+
selectionCommand: [
|
|
222
|
+
aiChatItems.improveWriting,
|
|
223
|
+
aiChatItems.fixSpelling,
|
|
224
|
+
aiChatItems.makeShorter,
|
|
225
|
+
aiChatItems.makeLonger,
|
|
226
|
+
aiChatItems.simplify,
|
|
227
|
+
aiChatItems.explain,
|
|
228
|
+
],
|
|
229
|
+
selectionSuggestion: [
|
|
230
|
+
aiChatItems.replace,
|
|
231
|
+
aiChatItems.insertBelow,
|
|
232
|
+
aiChatItems.discard,
|
|
233
|
+
aiChatItems.tryAgain,
|
|
234
|
+
],
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// ── Menu ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
238
|
+
export function AiMenu() {
|
|
239
|
+
const { api, editor } = useEditorPlugin(AIChatPlugin);
|
|
240
|
+
const mode = usePluginOption(AIChatPlugin, 'mode');
|
|
241
|
+
const streaming = usePluginOption(AIChatPlugin, 'streaming');
|
|
242
|
+
const isSelecting = useIsSelecting();
|
|
243
|
+
const isFocusedLast = useFocusedLast();
|
|
244
|
+
const open = usePluginOption(AIChatPlugin, 'open') && isFocusedLast;
|
|
245
|
+
const [value, setValue] = React.useState('');
|
|
246
|
+
const [input, setInput] = React.useState('');
|
|
247
|
+
const chat = usePluginOption(AIChatPlugin, 'chat');
|
|
248
|
+
const { messages, status } = chat;
|
|
249
|
+
const [anchorElement, setAnchorElement] = React.useState<HTMLElement | null>(null);
|
|
250
|
+
const content = useLastAssistantMessage()?.parts.find((part) => part.type === 'text')?.text;
|
|
251
|
+
|
|
252
|
+
// Key on `streaming` only (api/editor are re-derived each render and would over-run this) — matches the
|
|
253
|
+
// upstream @platejs/ai example. Re-anchors the popover at the live AI anchor node while a stream is active.
|
|
254
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: api/editor are stable editor refs, intentionally omitted.
|
|
255
|
+
React.useEffect(() => {
|
|
256
|
+
if (!streaming) return;
|
|
257
|
+
const anchorEntry = api.aiChat.node({ anchor: true });
|
|
258
|
+
if (!anchorEntry) return;
|
|
259
|
+
const dom = editor.api.toDOMNode(anchorEntry[0]);
|
|
260
|
+
if (dom) setAnchorElement(dom);
|
|
261
|
+
}, [streaming]);
|
|
262
|
+
|
|
263
|
+
const setOpen = (next: boolean) => {
|
|
264
|
+
if (next) api.aiChat.show();
|
|
265
|
+
else api.aiChat.hide();
|
|
266
|
+
};
|
|
267
|
+
const show = (el: HTMLElement) => {
|
|
268
|
+
setAnchorElement(el);
|
|
269
|
+
setOpen(true);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
useEditorChat({
|
|
273
|
+
onOpenChange: (next) => {
|
|
274
|
+
if (!next) {
|
|
275
|
+
setAnchorElement(null);
|
|
276
|
+
setInput('');
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
onOpenCursor: () => {
|
|
280
|
+
const ancestor = editor.api.block({ highest: true });
|
|
281
|
+
const dom = ancestor && editor.api.toDOMNode(ancestor[0]);
|
|
282
|
+
if (dom) show(dom);
|
|
283
|
+
},
|
|
284
|
+
onOpenSelection: () => {
|
|
285
|
+
const last = editor.api.blocks().at(-1);
|
|
286
|
+
const dom = last && editor.api.toDOMNode(last[0]);
|
|
287
|
+
if (dom) show(dom);
|
|
288
|
+
},
|
|
289
|
+
onOpenBlockSelection: (blocks: NodeEntry[]) => {
|
|
290
|
+
const last = blocks.at(-1);
|
|
291
|
+
const dom = last && editor.api.toDOMNode(last[0]);
|
|
292
|
+
if (dom) show(dom);
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const isLoading = status === 'streaming' || status === 'submitted';
|
|
297
|
+
|
|
298
|
+
useHotkeys(
|
|
299
|
+
'escape',
|
|
300
|
+
() => {
|
|
301
|
+
if (isLoading) api.aiChat.stop();
|
|
302
|
+
else api.aiChat.hide();
|
|
303
|
+
},
|
|
304
|
+
{ enableOnContentEditable: true, enableOnFormTags: true },
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
// 53.2.2's AIChatPlugin has no built-in `show` shortcut — wire mod+J to open the menu at the cursor.
|
|
308
|
+
useHotkeys(
|
|
309
|
+
'mod+j',
|
|
310
|
+
(e) => {
|
|
311
|
+
e.preventDefault();
|
|
312
|
+
api.aiChat.show();
|
|
313
|
+
},
|
|
314
|
+
{ enableOnContentEditable: true },
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
// In insert mode the result streams into the document, so the menu UI itself stays hidden while loading.
|
|
318
|
+
if (isLoading && mode === 'insert') return null;
|
|
319
|
+
|
|
320
|
+
return (
|
|
321
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
322
|
+
<PopoverContent
|
|
323
|
+
anchor={anchorElement ?? undefined}
|
|
324
|
+
className="w-[440px] max-w-[85vw] overflow-hidden p-0 ring-1 ring-foreground/10"
|
|
325
|
+
side="bottom"
|
|
326
|
+
align="start"
|
|
327
|
+
>
|
|
328
|
+
<Command value={value} onValueChange={setValue} shouldFilter={false} className="bg-popover">
|
|
329
|
+
{mode === 'chat' && content && (
|
|
330
|
+
<div className="border-b">
|
|
331
|
+
<AiChatEditor content={content} />
|
|
332
|
+
</div>
|
|
333
|
+
)}
|
|
334
|
+
|
|
335
|
+
{isLoading ? (
|
|
336
|
+
<div className="flex items-center gap-2 p-3 text-muted-foreground text-xs">
|
|
337
|
+
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin" />
|
|
338
|
+
{messages.length > 1 ? 'Editing…' : 'Thinking…'}
|
|
339
|
+
</div>
|
|
340
|
+
) : (
|
|
341
|
+
<CommandPrimitive.Input
|
|
342
|
+
className={cn(
|
|
343
|
+
'flex h-9 w-full min-w-0 border-b bg-transparent px-3 py-1 text-sm outline-none',
|
|
344
|
+
'placeholder:text-muted-foreground',
|
|
345
|
+
)}
|
|
346
|
+
value={input}
|
|
347
|
+
onValueChange={setInput}
|
|
348
|
+
onKeyDown={(e) => {
|
|
349
|
+
if (isHotkey('backspace')(e) && input.length === 0) {
|
|
350
|
+
e.preventDefault();
|
|
351
|
+
api.aiChat.hide();
|
|
352
|
+
}
|
|
353
|
+
if (isHotkey('enter')(e) && !e.shiftKey && !value) {
|
|
354
|
+
e.preventDefault();
|
|
355
|
+
if (input.trim().length === 0) return;
|
|
356
|
+
if (isSelecting) {
|
|
357
|
+
submitGenerate(
|
|
358
|
+
editor,
|
|
359
|
+
input,
|
|
360
|
+
'{prompt}\n\nApply the instruction above to the following text and return ONLY the result:\n\n{blockSelection}',
|
|
361
|
+
);
|
|
362
|
+
} else {
|
|
363
|
+
void editor.getApi(AIChatPlugin).aiChat.submit(input, { toolName: 'generate' });
|
|
364
|
+
}
|
|
365
|
+
setInput('');
|
|
366
|
+
}
|
|
367
|
+
}}
|
|
368
|
+
placeholder="Ask AI to write or edit…"
|
|
369
|
+
autoFocus
|
|
370
|
+
/>
|
|
371
|
+
)}
|
|
372
|
+
|
|
373
|
+
{!isLoading && (
|
|
374
|
+
<CommandList>
|
|
375
|
+
<AiMenuItems input={input} setInput={setInput} setValue={setValue} />
|
|
376
|
+
</CommandList>
|
|
377
|
+
)}
|
|
378
|
+
</Command>
|
|
379
|
+
</PopoverContent>
|
|
380
|
+
</Popover>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function AiMenuItems({
|
|
385
|
+
input,
|
|
386
|
+
setInput,
|
|
387
|
+
setValue,
|
|
388
|
+
}: {
|
|
389
|
+
input: string;
|
|
390
|
+
setInput: (v: string) => void;
|
|
391
|
+
setValue: (v: string) => void;
|
|
392
|
+
}) {
|
|
393
|
+
const editor = useEditorRef();
|
|
394
|
+
const { messages } = usePluginOption(AIChatPlugin, 'chat');
|
|
395
|
+
const aiEditor = usePluginOption(AIChatPlugin, 'aiEditor');
|
|
396
|
+
const isSelecting = useIsSelecting();
|
|
397
|
+
|
|
398
|
+
const menuState: MenuState = React.useMemo(() => {
|
|
399
|
+
if (messages && messages.length > 0) {
|
|
400
|
+
return isSelecting ? 'selectionSuggestion' : 'cursorSuggestion';
|
|
401
|
+
}
|
|
402
|
+
return isSelecting ? 'selectionCommand' : 'cursorCommand';
|
|
403
|
+
}, [isSelecting, messages]);
|
|
404
|
+
|
|
405
|
+
const items = menuStateItems[menuState];
|
|
406
|
+
|
|
407
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: re-seed the highlighted value when the set changes.
|
|
408
|
+
React.useEffect(() => {
|
|
409
|
+
const first = items[0];
|
|
410
|
+
if (first) setValue(first.value);
|
|
411
|
+
}, [menuState, setValue]);
|
|
412
|
+
|
|
413
|
+
return (
|
|
414
|
+
<CommandGroup>
|
|
415
|
+
{items.map((item) => (
|
|
416
|
+
<CommandItem
|
|
417
|
+
key={item.value}
|
|
418
|
+
className="gap-2 [&_svg]:text-muted-foreground"
|
|
419
|
+
value={item.value}
|
|
420
|
+
onSelect={() => {
|
|
421
|
+
item.onSelect({ aiEditor: aiEditor as SlateEditor, editor, input });
|
|
422
|
+
setInput('');
|
|
423
|
+
}}
|
|
424
|
+
>
|
|
425
|
+
<HugeiconsIcon icon={item.icon} className="size-4" strokeWidth={2} />
|
|
426
|
+
<span>{item.label}</span>
|
|
427
|
+
</CommandItem>
|
|
428
|
+
))}
|
|
429
|
+
</CommandGroup>
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ── Loading bar (afterContainer) ─────────────────────────────────────────────────────────────────────────
|
|
434
|
+
export function AiLoadingBar() {
|
|
435
|
+
const { api } = useEditorPlugin(AIChatPlugin);
|
|
436
|
+
const chat = usePluginOption(AIChatPlugin, 'chat');
|
|
437
|
+
const mode = usePluginOption(AIChatPlugin, 'mode');
|
|
438
|
+
const isLoading = chat.status === 'streaming' || chat.status === 'submitted';
|
|
439
|
+
|
|
440
|
+
if (!isLoading || mode !== 'insert') return null;
|
|
441
|
+
|
|
442
|
+
return (
|
|
443
|
+
<div className="-translate-x-1/2 absolute bottom-4 left-1/2 z-20 flex items-center gap-2 rounded-md border bg-popover px-3 py-1.5 text-muted-foreground text-xs shadow-md">
|
|
444
|
+
<HugeiconsIcon icon={Loading03Icon} className="size-3.5 animate-spin" />
|
|
445
|
+
<span>{chat.status === 'submitted' ? 'Thinking…' : 'Writing…'}</span>
|
|
446
|
+
<Button
|
|
447
|
+
size="sm"
|
|
448
|
+
variant="ghost"
|
|
449
|
+
className="h-6 gap-1 text-xs"
|
|
450
|
+
onClick={() => api.aiChat.stop()}
|
|
451
|
+
>
|
|
452
|
+
Stop
|
|
453
|
+
</Button>
|
|
454
|
+
</div>
|
|
455
|
+
);
|
|
456
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { AIChatPlugin } from '@platejs/ai/react';
|
|
4
|
+
import { cn } from '@saena-io/ui/lib/utils';
|
|
5
|
+
import {
|
|
6
|
+
PlateElement,
|
|
7
|
+
type PlateElementProps,
|
|
8
|
+
PlateText,
|
|
9
|
+
type PlateTextProps,
|
|
10
|
+
usePluginOption,
|
|
11
|
+
} from 'platejs/react';
|
|
12
|
+
|
|
13
|
+
// AI node renderers for the command menu (ADR-0010), vendored from @platejs/ai's example and adapted to
|
|
14
|
+
// SAENA's theme tokens. `AILeaf` styles text that the AI is actively streaming into the document (insert
|
|
15
|
+
// mode) with a subtle highlight + a pulsing caret on the last node. `AIAnchorElement` is a zero-height
|
|
16
|
+
// invisible block the floating menu popover anchors to while a cursor-mode stream is in flight.
|
|
17
|
+
|
|
18
|
+
export function AILeaf(props: PlateTextProps) {
|
|
19
|
+
const streaming = usePluginOption(AIChatPlugin, 'streaming');
|
|
20
|
+
const streamingLeaf = props.editor.getApi(AIChatPlugin).aiChat.node({ streaming: true });
|
|
21
|
+
const isLast = streamingLeaf?.[0] === props.text;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<PlateText
|
|
25
|
+
className={cn(
|
|
26
|
+
'bg-primary/10 text-foreground/90 transition-colors duration-150',
|
|
27
|
+
isLast &&
|
|
28
|
+
streaming &&
|
|
29
|
+
'after:ml-0.5 after:inline-block after:h-3 after:w-[2px] after:animate-pulse after:bg-primary after:align-middle after:content-[""]',
|
|
30
|
+
)}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function AIAnchorElement(props: PlateElementProps) {
|
|
37
|
+
return (
|
|
38
|
+
<PlateElement {...props}>
|
|
39
|
+
<div className="h-[0.1px]" />
|
|
40
|
+
</PlateElement>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { SparklesIcon } from '@hugeicons/core-free-icons';
|
|
4
|
+
import { HugeiconsIcon } from '@hugeicons/react';
|
|
5
|
+
import { AIChatPlugin } from '@platejs/ai/react';
|
|
6
|
+
import { Button } from '@saena-io/ui/components/button';
|
|
7
|
+
import { useActiveEditor } from './toolbar';
|
|
8
|
+
|
|
9
|
+
// Toolbar trigger for the AI command menu, placed in the reserved 'ai' toolbar slot. SAENA's toolbar exposes
|
|
10
|
+
// no ToolbarButton, so this is a plain @saena-io/ui Button. useActiveEditor() (not useEditorPlugin) resolves the
|
|
11
|
+
// last-focused editor — opening the menu blurs the editor, so we must not depend on live focus. onMouseDown
|
|
12
|
+
// preventDefault keeps the editor selection intact when the button is pressed.
|
|
13
|
+
export function AiToolbarButton() {
|
|
14
|
+
const editor = useActiveEditor();
|
|
15
|
+
return (
|
|
16
|
+
<Button
|
|
17
|
+
type="button"
|
|
18
|
+
variant="ghost"
|
|
19
|
+
size="sm"
|
|
20
|
+
className="gap-1.5 text-muted-foreground"
|
|
21
|
+
disabled={!editor}
|
|
22
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
23
|
+
onClick={() => editor?.getApi(AIChatPlugin).aiChat.show()}
|
|
24
|
+
>
|
|
25
|
+
<HugeiconsIcon icon={SparklesIcon} className="size-3.5" strokeWidth={2} />
|
|
26
|
+
Ask AI
|
|
27
|
+
</Button>
|
|
28
|
+
);
|
|
29
|
+
}
|