J.A.R.V.I.S-rust/frontend/src/routes/commands/index.svelte
Bossiara13 a16d2401e7 arch + features: Lua jarvis.speak() / jarvis.llm() API, /commands GUI page, games / mouse / random_choice packs
Architecture — DRY at the Lua API surface:

  lua/api/tts.rs (new): jarvis.speak(text, opts?). opts.lang = ISO
  two-letter code (default "ru"); auto-picks first installed voice
  matching the culture. opts.async = true|false (default true). The
  9 packs that previously inlined a 60-character PS-SAPI string for
  every spoken reply can now reduce to one line. Refactored fun/,
  dice/, clipboard_read/ as proof — fun/ask.lua went from 75 lines
  of HTTP+SAPI boilerplate to ~28 lines of intent code.

  lua/api/llm.rs (new): jarvis.llm(messages, opts?). messages is a
  list of {role, content} tables, opts.max_tokens / .temperature /
  .top_p. Returns the assistant string or nil. Gated by sandbox
  allows_http() (so Standard+ packs only). Internally uses the
  existing jarvis_core::llm::LlmClient::complete_with, no duplicated
  HTTP plumbing.

  llm/client.rs: split complete() into complete_with(messages,
  max_tokens, temperature, top_p) for the new caller; old complete()
  is now a thin wrapper at temp=0.7 top_p=1.0 for backward compat.

GUI — /commands page rewrite:

  Replaces the "Раздел в разработке" placeholder. Calls Tauri command
  get_commands_list (already existed in tauri_commands/commands.rs,
  was wired but unused). Renders a search-filterable card grid:
    - cmd id (monospace)
    - type badge with colour by kind (lua=blue, ahk=red, cli=grey,
      voice=green, terminate=red, stop_chaining=violet)
    - sandbox badge
    - description line (if non-empty)
    - all phrases for currentLanguage, fallback to en, fallback to
      first available; each phrase in a small inline pill
  Toolbar: text input with magnifier icon + reload button (↻).
  Counter line shows visible/total + "Скажи Джарвис + любую фразу"
  hint. Counts down as you type the filter.

New packs (3, total 33 → 36):

  games/ — launch_game / list_games. Reads or creates
  %USERPROFILE%\Documents\jarvis-games.json (the sample preloads dota,
  cs2, elden ring, witcher 3, factorio + minecraft launcher path +
  fortnite epic URI). Trigger-strip + fuzzy name/alias match, then
  launches via steam://rungameid/N (Steam), epic:// (Epic Games), or
  start "" "path" (anything else). list_games speaks the configured
  library.

  mouse/ — left/right/middle/double click + scroll up/down. Single
  dispatch.lua + _mouse_helper.ps1. PS P/Invokes user32!mouse_event
  with the right flag pair (LEFTDOWN+LEFTUP / etc.), 30ms gap, doubles
  do two LEFT cycles with a 50ms gap, wheel uses ±360 ticks.

  random_choice/ — "выбери из X или Y или Z". Strips the trigger,
  splits on " или " / " or " / ", " / " либо " / " чи ", math.random
  picks one, speaks "Я выбираю N".

cargo test commands::tests still 3/3. 36 packs verified via
jarvis-gui startup log.
2026-05-15 12:58:16 +03:00

249 lines
7 KiB
Svelte

<script lang="ts">
import { onMount } from "svelte"
import { TextInput, Space, Badge, Tooltip, Loader } from "@svelteuidev/core"
import { invoke } from "@tauri-apps/api/core"
import { MagnifyingGlass } from "radix-icons-svelte"
import HDivider from "@/components/elements/HDivider.svelte"
import Footer from "@/components/Footer.svelte"
import { currentLanguage, translations, translate } from "@/stores"
$: t = (key: string) => translate($translations, key)
type JCommand = {
id: string
type: string
description?: string
script?: string
sandbox?: string
timeout?: number
phrases: Record<string, string[]>
sounds?: Record<string, string[]>
}
let commands: JCommand[] = []
let loading = true
let filter = ""
let errorMessage = ""
async function load() {
loading = true
errorMessage = ""
try {
commands = await invoke<JCommand[]>("get_commands_list")
commands = [...commands].sort((a, b) => a.id.localeCompare(b.id))
} catch (err) {
errorMessage = String(err)
console.error("commands list load failed:", err)
} finally {
loading = false
}
}
onMount(load)
function phrasesForLang(c: JCommand, lang: string): string[] {
return c.phrases?.[lang] ?? c.phrases?.["en"] ?? Object.values(c.phrases ?? {})[0] ?? []
}
function matches(c: JCommand, q: string): boolean {
if (!q) return true
const needle = q.toLowerCase()
if (c.id.toLowerCase().includes(needle)) return true
if ((c.description ?? "").toLowerCase().includes(needle)) return true
if ((c.type ?? "").toLowerCase().includes(needle)) return true
for (const list of Object.values(c.phrases ?? {})) {
for (const p of list) {
if (p.toLowerCase().includes(needle)) return true
}
}
return false
}
function typeColor(type: string): string {
switch (type) {
case "lua": return "#5b8def"
case "ahk": return "#b7411a"
case "cli": return "#8a8d90"
case "voice": return "#3aaf72"
case "terminate": return "#c93a3a"
case "stop_chaining": return "#9d6abf"
default: return "#6c6e71"
}
}
$: visible = commands.filter(c => matches(c, filter))
$: countLabel = filter
? `${visible.length} / ${commands.length}`
: `${commands.length}`
</script>
<Space h="md" />
<div class="toolbar">
<TextInput
bind:value={filter}
placeholder={$currentLanguage === "ru" ? "Поиск по имени или фразе..." : "Search by id or phrase..."}
icon={MagnifyingGlass}
size="sm"
radius="md"
override={{ flex: 1 }}
/>
<Tooltip label={$currentLanguage === "ru" ? "Перечитать" : "Reload"} position="bottom">
<button class="reload-btn" on:click={load} aria-label="reload"></button>
</Tooltip>
</div>
<div class="meta">
<span class="count">{countLabel}</span>
<span class="dot">·</span>
<span class="hint">
{$currentLanguage === "ru"
? "Скажи «Джарвис» + любую фразу из карточки"
: "Say \"Jarvis\" + any phrase from a card"}
</span>
</div>
{#if loading}
<div class="state"><Loader size="sm" /></div>
{:else if errorMessage}
<div class="state error">{errorMessage}</div>
{:else if visible.length === 0}
<div class="state">
{$currentLanguage === "ru" ? "Ничего не найдено" : "Nothing found"}
</div>
{:else}
<div class="grid">
{#each visible as cmd (cmd.id)}
<div class="card">
<div class="card-head">
<span class="cmd-id">{cmd.id}</span>
<Badge
size="xs"
radius="sm"
variant="filled"
override={{ backgroundColor: typeColor(cmd.type), color: "#fff" }}
>
{cmd.type}
</Badge>
{#if cmd.sandbox}
<Badge size="xs" radius="sm" variant="outline">{cmd.sandbox}</Badge>
{/if}
</div>
{#if cmd.description}
<div class="card-desc">{cmd.description}</div>
{/if}
<ul class="phrases">
{#each phrasesForLang(cmd, $currentLanguage) as p}
<li>«{p}»</li>
{/each}
</ul>
</div>
{/each}
</div>
{/if}
<HDivider />
<Footer />
<style lang="scss">
.toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
}
.reload-btn {
background: transparent;
border: 1px solid #3a3d40;
color: #c8cacc;
border-radius: 8px;
height: 34px;
width: 34px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: 0.2s;
&:hover {
border-color: #5b8def;
color: #5b8def;
}
}
.meta {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px 12px;
font-size: 12px;
color: #8a8d90;
.count {
background: #5b8def;
color: #fff;
padding: 1px 8px;
border-radius: 10px;
font-weight: 600;
}
.dot { color: #46484b; }
}
.state {
padding: 30px 12px;
text-align: center;
color: #8a8d90;
font-size: 13px;
&.error {
color: #c93a3a;
font-family: monospace;
}
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
padding: 0 10px 10px;
}
.card {
background: #1c1d20;
border: 1px solid #2a2c2f;
border-radius: 8px;
padding: 10px 12px;
transition: border-color 0.15s;
&:hover {
border-color: #3a3d40;
}
}
.card-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
flex-wrap: wrap;
}
.cmd-id {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: #e0e2e4;
font-weight: 600;
margin-right: auto;
}
.card-desc {
font-size: 12px;
color: #a0a3a6;
margin-bottom: 6px;
}
.phrases {
list-style: none;
margin: 0;
padding: 0;
font-size: 12px;
color: #c8cacc;
line-height: 1.6em;
li {
display: inline-block;
background: #25272a;
border-radius: 4px;
padding: 1px 7px;
margin: 2px 4px 2px 0;
}
}
</style>