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.
249 lines
7 KiB
Svelte
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>
|