SPB Git

spb/zyquo-cloud-web Public MIT

Zyquo Cloud Web — every cloud model, one beautiful chat, entirely in your browser.

TypeScript 81.9% CSS 8.9% JavaScript 7.5% Shell 1.1% HTML 0.6%
6.5 KB · 200 lines tsx
Raw Blame History
1/*2 *  SlashMenu.tsx3 *  Zyquo Cloud Web4 *5 *  Author: Simon-Pierre Boucher6 *  Mail: contact@spboucher.ai7 *8 *  Slash-command menu: typing "/" at the start of the input surfaces prompt9 *  templates and quick tools (/summarize, /translate, /rewrite, /explain) and10 *  personas. Selecting a template substitutes {{input}} with the rest of the11 *  draft (native PromptLibraryStore.apply behavior).12 */1314import { useEffect, useMemo, useRef, useState } from 'react'15import { PERSONAS } from '../features/personasData'16import { PROMPT_TEMPLATES } from '../features/promptLibraryData'17import { summarizeConversation } from '../features/conversationTools'18import { useStore } from '../state/store'19import { IconCommand, IconDoc, IconLightbulb } from './icons'2021interface SlashItem {22  id: string23  section: string24  title: string25  subtitle: string26  apply: () => void27}2829/** Applies a template body: {{input}} ← the user's remaining draft text. */30export function applyTemplate(body: string, input: string): string {31  if (body.includes('{{input}}')) return body.replace('{{input}}', input)32  return input === '' ? body : `${body}\n\n${input}`33}3435const QUICK_TOOLS = [36  { key: 'summarize', title: '/summarize', body: 'Summarize the following clearly and concisely, preserving the key facts:\n\n{{input}}' },37  { key: 'translate', title: '/translate', body: 'Translate the following text to English (or to French if it is already English), keeping the tone natural:\n\n{{input}}' },38  { key: 'rewrite', title: '/rewrite', body: 'Rewrite the following for clarity and flow, preserving meaning and voice:\n\n{{input}}' },39  { key: 'explain', title: '/explain', body: 'Explain the following simply, step by step, for a smart non-expert:\n\n{{input}}' },40]4142export default function SlashMenu({43  query,44  input,45  conversationID,46  onApply,47  onClose,48}: {49  /** Text after the leading slash, up to the first space. */50  query: string51  /** The draft text after the slash command (used as {{input}}). */52  input: string53  conversationID: string54  onApply: (text: string) => void55  onClose: () => void56}) {57  const [highlight, setHighlight] = useState(0)58  const ref = useRef<HTMLDivElement>(null)5960  const items: SlashItem[] = useMemo(() => {61    const q = query.toLowerCase()62    const match = (...targets: string[]) =>63      q === '' || targets.some((t) => t.toLowerCase().includes(q))64    const out: SlashItem[] = []65    for (const tool of QUICK_TOOLS) {66      if (!match(tool.title, tool.key)) continue67      if (tool.key === 'summarize' && input.trim() === '') {68        out.push({69          id: `tool-${tool.key}`,70          section: 'Tools',71          title: tool.title,72          subtitle: 'Summarize this conversation',73          apply: () => {74            onApply('')75            void summarizeConversation(conversationID)76          },77        })78      } else {79        out.push({80          id: `tool-${tool.key}`,81          section: 'Tools',82          title: tool.title,83          subtitle: 'Quick tool',84          apply: () => onApply(applyTemplate(tool.body, input.trim())),85        })86      }87    }88    for (const template of PROMPT_TEMPLATES) {89      if (!match(template.title, template.category)) continue90      out.push({91        id: `tpl-${template.id}`,92        section: template.category,93        title: template.title,94        subtitle: template.category,95        apply: () => onApply(applyTemplate(template.body, input.trim())),96      })97    }98    for (const persona of PERSONAS) {99      if (!match(persona.name)) continue100      out.push({101        id: `persona-${persona.id}`,102        section: 'Personas',103        title: persona.name,104        subtitle: 'Apply persona to this chat',105        apply: () => {106          const state = useStore.getState()107          state.updateConversation(conversationID, {108            systemPrompt: persona.systemPrompt,109            personaID: persona.id,110          })111          state.toast(`Persona: ${persona.name}`, 'success')112          onApply(input)113        },114      })115    }116    return out.slice(0, 12)117  }, [query, input, conversationID, onApply])118119  useEffect(() => setHighlight(0), [query])120121  // Expose keyboard handling to the parent textarea via a custom event target.122  useEffect(() => {123    const onKey = (e: KeyboardEvent) => {124      if (e.key === 'ArrowDown') {125        e.preventDefault()126        setHighlight((h) => Math.min(h + 1, items.length - 1))127      } else if (e.key === 'ArrowUp') {128        e.preventDefault()129        setHighlight((h) => Math.max(h - 1, 0))130      } else if (e.key === 'Enter' || e.key === 'Tab') {131        e.preventDefault()132        items[highlight]?.apply()133      } else if (e.key === 'Escape') {134        e.preventDefault()135        onClose()136      }137    }138    window.addEventListener('keydown', onKey, { capture: true })139    return () => window.removeEventListener('keydown', onKey, { capture: true })140  }, [items, highlight, onClose])141142  if (items.length === 0) return null143144  let lastSection = ''145  return (146    <div147      ref={ref}148      style={{149        position: 'absolute',150        bottom: '100%',151        left: 0,152        right: 0,153        marginBottom: 8,154        background: 'var(--z-surface)',155        border: 'var(--z-hairline) solid var(--z-border)',156        borderRadius: 'var(--z-radius-medium)',157        boxShadow: 'var(--z-shadow-panel)',158        maxHeight: 300,159        overflowY: 'auto',160        padding: 'var(--z-space-xxs)',161        zIndex: 20,162      }}163    >164      {items.map((item, index) => {165        const header =166          item.section !== lastSection ? (167            <div className="panel-section-header" key={`h-${item.section}-${index}`}>168              {item.section}169            </div>170          ) : null171        lastSection = item.section172        return (173          <div key={item.id}>174            {header}175            <button176              className={`model-row${index === highlight ? ' highlighted' : ''}`}177              onClick={() => item.apply()}178              onMouseMove={() => setHighlight(index)}179            >180              <span className="glyph">181                {item.section === 'Tools' ? (182                  <IconCommand size={11} />183                ) : item.section === 'Personas' ? (184                  <IconLightbulb size={11} />185                ) : (186                  <IconDoc size={11} />187                )}188              </span>189              <span className="model-row-name">{item.title}</span>190              <span className="model-row-provider" style={{ marginLeft: 'auto' }}>191                {item.subtitle}192              </span>193            </button>194          </div>195        )196      })}197    </div>198  )199}200