File size: 7,043 Bytes
5e0e4e8
 
 
 
 
 
638c2d6
5e0e4e8
 
 
 
638c2d6
5e0e4e8
 
 
 
 
0d61133
 
5e0e4e8
 
 
 
 
 
 
 
 
 
 
 
 
638c2d6
 
 
 
 
5e0e4e8
638c2d6
 
 
 
16221a7
 
5e0e4e8
638c2d6
 
 
45a40b2
638c2d6
5e0e4e8
638c2d6
 
 
 
 
 
 
5e0e4e8
638c2d6
 
0d61133
 
638c2d6
5e0e4e8
0d61133
638c2d6
0d61133
638c2d6
 
0d61133
638c2d6
45a40b2
5e0e4e8
 
0d61133
 
638c2d6
0d61133
638c2d6
 
45a40b2
638c2d6
 
0d61133
638c2d6
 
5e0e4e8
45a40b2
638c2d6
5e0e4e8
45a40b2
5e0e4e8
638c2d6
5e0e4e8
0d61133
5e0e4e8
 
 
0d61133
638c2d6
5e0e4e8
45a40b2
638c2d6
5e0e4e8
 
16221a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5e0e4e8
16221a7
 
5e0e4e8
16221a7
5e0e4e8
 
 
638c2d6
5e0e4e8
16221a7
5e0e4e8
f20c69c
5e0e4e8
 
0d61133
 
 
 
 
 
 
16221a7
0d61133
 
 
 
 
 
 
 
638c2d6
 
 
 
 
16221a7
638c2d6
 
 
16221a7
 
 
 
 
638c2d6
 
 
0d61133
 
16221a7
638c2d6
 
5e0e4e8
638c2d6
5e0e4e8
16221a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
638c2d6
 
 
 
5e0e4e8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
// @ts-check

/**
 * Model Slash Plugin for Milkdown Crepe
 * 
 * This module implements a custom slash command interface for model selection
 * using Milkdown's slash plugin following the proper factory pattern.
 * 
 * Features:
 * - Custom slash menu UI with model icons and metadata
 * - Support for auth-required models with visual indicators
 * - Dynamic model list updates via getter function
 * - Async command execution with error handling
 */

import { slashFactory, SlashProvider } from '@milkdown/plugin-slash';

import './model-slash.css';

/**
 * @typedef {{
 *  id: string,
 *  name: string,
 *  size?: string,
 *  requiresAuth?: boolean
 * }} ModelInfo
 */

// Create the slash plugin factory
export const modelSlash = slashFactory('ModelCommands');

/**
 * Creates and configures the model slash plugin
 * @param {{
 *  getModels: () => ModelInfo[],
 *  onSlashCommand?: (modelId: string) => void | boolean | Promise<void | boolean>
 * }} options
 */
export function createModelSlashPlugin({ getModels, onSlashCommand }) {
  // Create the menu DOM element
  const menu = document.createElement('div');
  menu.className = "slash-menu";
  // Start hidden; provider may only control positioning. We'll manage visibility.
  menu.style.display = 'none';

  // Function to rebuild menu content
  function rebuildMenu() {
    menu.innerHTML = '';

    const availableModels = getModels();

    if (availableModels.length === 0) {
      const noModels = document.createElement('div');
      noModels.className = "px-3 py-4 text-sm text-gray-500 text-center";
      noModels.textContent = "No models available";
      menu.appendChild(noModels);
      return;
    }

    // Create model list
    const modelList = document.createElement('ul');
    modelList.className = 'model-list';

    availableModels.forEach((model, index) => {
      const item = document.createElement('li');
      item.className = 'model-entry';
      item.dataset.modelId = model.id;

      // Create icon
      const icon = document.createElement('span');
      icon.className = 'model-icon';
      icon.textContent = model.requiresAuth ? 'πŸ”’' : 'πŸ€–';

      // Create text container
      const textContainer = document.createElement('div');
      textContainer.className = 'model-text-container';

      const name = document.createElement('div');
      name.className = 'name';
      name.textContent = model.name;
      textContainer.appendChild(name);

      if (model.size) {
        const subtitle = document.createElement('div');
        subtitle.className = 'size';
        subtitle.textContent = `(${model.size})`;
        textContainer.appendChild(subtitle);
      }

      item.appendChild(icon);
      item.appendChild(textContainer);

      // Add auth indicator if needed
      if (model.requiresAuth) {
        const authSpan = document.createElement('span');
        authSpan.className = 'auth';
        authSpan.textContent = "Auth Required";
        item.appendChild(authSpan);
      }

      modelList.appendChild(item);
    });

    menu.appendChild(modelList);
  }

  // We'll attach click handler after potential wrapping defined later

  // Track current editor view for mutation operations (removing the slash)
  let currentView = null;

  // Helper: check if cursor is directly after a solitary '/'
  function hasTriggerSlash(view) {
    if (!view) return false;
    const { state } = view;
    const { from } = state.selection;
    if (from === 0) return false;
    const $pos = state.doc.resolve(from);
    // Get char before cursor
    const prevChar = state.doc.textBetween(from - 1, from, '\n', '\n');
    if (prevChar !== '/') return false;
    // Optional: ensure it's start of line or preceded by space (avoid paths / urls)
    const beforePrev = from - 2 >= 0 ? state.doc.textBetween(from - 2, from - 1, '\n', '\n') : '';
    if (beforePrev && /[\w/]/.test(beforePrev)) return false; // part of word or // sequence
    return true;
  }

  // Helper: remove the trigger slash silently
  function removeTriggerSlash(view) {
    try {
      if (!view) return;
      const { state } = view;
      const { from } = state.selection;
      if (from === 0) return;
      const prevChar = state.doc.textBetween(from - 1, from, '\n', '\n');
      if (prevChar === '/') {
        const tr = state.tr.delete(from - 1, from);
        view.dispatch(tr);
      }
    } catch (e) {
      // ignore
    }
  }

  // Create the slash provider
  const provider = new SlashProvider({
    content: menu,
    shouldShow(view) {
      return hasTriggerSlash(view);
    },
    offset: 15,
  });

  // Hide on Escape key β€” attach a document listener and remove it on destroy
  function onKeyDown(e) {
    if (!e) return;
    const key = e.key || e.keyCode;
    if (key === 'Escape' || key === 'Esc' || key === 27) {
      try {
        provider.hide();
        removeTriggerSlash(currentView);
      } catch (err) {
        // ignore
      }
    }
  }

  document.addEventListener('keydown', onKeyDown);

  // Configuration function for the slash plugin
  const slashConfig = (ctx) => {
    ctx.set(modelSlash.key, {
      view: () => ({
        update: (view, prevState) => {
          currentView = view;
          // Rebuild menu content on each update to reflect current models
          rebuildMenu();
          provider.update(view, prevState);
          if (hasTriggerSlash(view)) {
            menu.style.display = '';
          } else {
            menu.style.display = 'none';
          }
        },
        destroy: () => {
          provider.destroy();
          // cleanup the document key listener
          document.removeEventListener('keydown', onKeyDown);
          document.removeEventListener('mousedown', onOutsideMouseDown, true);
        },
      }),
    });
  };

  // Expose a public helper so external code (onSlashCommand) can explicitly close & clean
  function finalize() {
    provider.hide();
    removeTriggerSlash(currentView);
    menu.style.display = 'none';
  }

  // Wrapped handler: no finalize here so UI hides immediately on click
  const wrapped = onSlashCommand ? async (modelId) => {
    try {
      await onSlashCommand(modelId);
    } catch (error) {
      console.error('Error executing slash command:', error);
    }
  } : null;

  // Attach click handler now
  menu.addEventListener('click', async (e) => {
    if (!e.target || !(e.target instanceof Element)) return;
    const target = e.target.closest('li[data-model-id]');
    if (!target || !(target instanceof HTMLElement)) return;
    const modelId = target.dataset.modelId;
    if (!modelId) return;
    // Hide immediately
    finalize();
    if (wrapped) await wrapped(modelId);
  });

  // Outside click handler to dismiss menu
  function onOutsideMouseDown(e) {
    if (menu.style.display === 'none') return;
    if (e.target instanceof Node && !menu.contains(e.target)) {
      finalize();
    }
  }
  document.addEventListener('mousedown', onOutsideMouseDown, true);

  return {
    plugin: modelSlash,
    config: slashConfig
  };
}