rustpbx 0.4.4

A SIP PBX implementation in Rust
Documentation
{% extends "console/layout.html" %}
{% block title %}{{ "transcript.title" | t }} ยท {{site_name|default('RustPBX')}}{% endblock %}
{% block content %}
<div class="p-6" x-data='transcriptSettings({
    config: {{ config | default({}) | tojson }},
    modelReady: {{ model_ready | default(false) | tojson }},
    basePath: {{ base_path | default("/console") | tojson }},
    translations: {{ t | tojson }}
})'>
    <div class="mx-auto max-w-7xl space-y-6">
        <!-- Header -->
        <div class="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
            <div>
                <p class="text-[11px] font-semibold uppercase tracking-wide text-sky-600">{{ "transcript.subtitle" | t }}</p>
                <h1 class="text-2xl font-semibold text-slate-900">{{ "transcript.title" | t }}</h1>
                <p class="text-sm text-slate-500">{{ "transcript.subtitle_desc" | t }}</p>
            </div>
        </div>

        <div class="grid gap-6 lg:grid-cols-1">
            <form @submit.prevent="saveSettings" class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
                <div class="flex items-center justify-between">
                    <div>
                        <h2 class="text-base font-semibold text-slate-900" x-text="t.section_config"></h2>
                        <p class="text-xs text-slate-500" x-text="t.section_config_desc"></p>
                    </div>
                    <button type="submit"
                        class="inline-flex items-center justify-center rounded-md bg-sky-600 px-3 py-2 text-xs font-semibold text-white shadow-sm transition hover:bg-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60"
                        :disabled="saving">
                        <span x-text="saving ? t.btn_saving : t.btn_save"></span>
                    </button>
                </div>

                <div class="mt-6 grid gap-6 sm:grid-cols-2">
                    <div>
                        <label class="block text-xs font-semibold uppercase tracking-wide text-slate-400" x-text="t.field_command"></label>
                        <input type="text" x-model="config.command"
                            class="mt-1 w-full rounded-md border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
                            :placeholder="t.field_command_placeholder">
                        <p class="mt-1 text-xs text-slate-400" x-text="t.field_command_hint"></p>
                    </div>
                    <div>
                        <label class="block text-xs font-semibold uppercase tracking-wide text-slate-400" x-text="t.field_models_path"></label>
                        <input type="text" x-model="config.models_path"
                            class="mt-1 w-full rounded-md border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
                            :placeholder="t.field_models_path_placeholder">
                        <p class="mt-1 text-xs text-slate-400" x-text="t.field_models_path_hint"></p>
                    </div>
                    <div>
                        <label class="block text-xs font-semibold uppercase tracking-wide text-slate-400" x-text="t.field_default_language"></label>
                        <select x-model="config.default_language"
                            class="mt-1 w-full rounded-md border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200">
                            <option value="auto" x-text="t.lang_auto"></option>
                            <option value="zh" x-text="t.lang_zh"></option>
                            <option value="en" x-text="t.lang_en"></option>
                            <option value="ja" x-text="t.lang_ja"></option>
                            <option value="ko" x-text="t.lang_ko"></option>
                            <option value="yue" x-text="t.lang_yue"></option>
                        </select>
                    </div>
                    <div>
                        <label class="block text-xs font-semibold uppercase tracking-wide text-slate-400" x-text="t.field_timeout"></label>
                        <input type="number" x-model.number="config.timeout_secs"
                            class="mt-1 w-full rounded-md border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200">
                    </div>
                    <div>
                        <label class="block text-xs font-semibold uppercase tracking-wide text-slate-400" x-text="t.field_hf_endpoint"></label>
                        <input type="text" x-model="config.hf_endpoint"
                            class="mt-1 w-full rounded-md border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
                            :placeholder="t.field_hf_endpoint_placeholder">
                        <p class="mt-1 text-xs text-slate-400" x-text="t.field_hf_endpoint_hint"></p>
                    </div>
                </div>

                <div class="mt-8 border-t border-slate-100 pt-6">
                    <h3 class="text-sm font-semibold text-slate-900" x-text="t.section_model_status"></h3>
                    <div
                        class="mt-4 flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 p-4">
                        <div class="flex items-center gap-3">
                            <div class="flex h-10 w-10 items-center justify-center rounded-full"
                                :class="modelReady ? 'bg-emerald-100 text-emerald-600' : 'bg-amber-100 text-amber-600'">
                                <svg x-show="modelReady" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
                                    stroke="currentColor" stroke-width="2">
                                    <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
                                </svg>
                                <svg x-show="!modelReady" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
                                    stroke="currentColor" stroke-width="2">
                                    <path stroke-linecap="round" stroke-linejoin="round"
                                        d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
                                </svg>
                            </div>
                            <div>
                                <div class="text-sm font-semibold text-slate-900"
                                    x-text="modelReady ? t.model_available : t.model_missing"></div>
                                <div class="text-xs text-slate-500"
                                    x-text="modelReady ? t.model_available_desc : t.model_missing_desc">
                                </div>
                            </div>
                        </div>
                        <button type="button"
                            class="inline-flex items-center justify-center rounded-md border border-slate-200 bg-white px-3 py-2 text-xs font-semibold text-slate-700 shadow-sm transition hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-sky-200 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60"
                            @click="downloadModel" :disabled="downloading || modelReady">
                            <span x-text="downloading ? t.btn_downloading : (modelReady ? t.btn_redownload : t.btn_download)"></span>
                        </button>
                    </div>

                    <div x-show="downloadMessage" x-transition class="mt-4 rounded-md p-3 text-xs"
                        :class="downloadStatus === 'success' ? 'bg-emerald-50 text-emerald-700' : (downloadStatus === 'error' ? 'bg-rose-50 text-rose-700' : 'bg-slate-100 text-slate-700')">
                        <p x-text="downloadMessage"></p>
                    </div>
                </div>

                <div x-show="message" x-transition class="mt-4 rounded-md p-3 text-xs"
                    :class="status === 'success' ? 'bg-emerald-50 text-emerald-700' : 'bg-rose-50 text-rose-700'">
                    <p x-text="message"></p>
                </div>
            </form>
        </div>
    </div>
</div>

<script>
    document.addEventListener('alpine:init', () => {
        Alpine.data('transcriptSettings', (initialData) => ({
            t: (initialData.translations || {}).transcript || {},
            config: initialData.config,
            modelReady: initialData.modelReady,
            basePath: initialData.basePath,
            saving: false,
            downloading: false,
            message: '',
            status: '',
            downloadMessage: '',
            downloadStatus: '',

            async saveSettings() {
                this.saving = true;
                this.message = '';
                try {
                    const response = await fetch(window.location.pathname, {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify(this.config)
                    });

                    const data = await response.json();

                    if (response.ok) {
                        this.status = 'success';
                        this.message = this.t.save_success || 'Settings saved successfully.';
                        setTimeout(() => this.message = '', 3000);
                    } else {
                        throw new Error(data.message || this.t.save_failed || 'Failed to save settings');
                    }
                } catch (e) {
                    this.status = 'error';
                    this.message = e.message;
                } finally {
                    this.saving = false;
                }
            },

            downloadModel() {
                window.dispatchEvent(new CustomEvent('console:confirm', {
                    detail: {
                        title: this.t.download_confirm_title || 'Download Model',
                        message: this.t.download_confirm_message || 'This will download ~500MB of model files. Continue?',
                        confirmLabel: this.t.download_confirm_label || 'Download',
                        onConfirm: async () => { await this._doDownloadModel(); },
                    },
                }));
            },

            async _doDownloadModel() {
                this.downloading = true;
                this.downloadMessage = this.t.download_starting || 'Starting download... this may take a while.';
                this.downloadStatus = 'info';

                try {
                    const url = `${this.basePath}/transcript/download-model`;
                    const response = await fetch(url, {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify({
                            command: this.config.command,
                            models_path: this.config.models_path,
                            hf_endpoint: this.config.hf_endpoint
                        })
                    });

                    const data = await response.json();

                    if (response.ok) {
                        this.downloadStatus = 'success';
                        this.downloadMessage = this.t.download_success || 'Model downloaded successfully.';
                        this.modelReady = true;
                    } else {
                        throw new Error(data.message || this.t.download_failed || 'Download failed');
                    }
                } catch (e) {
                    this.downloadStatus = 'error';
                    this.downloadMessage = e.message;
                } finally {
                    this.downloading = false;
                }
            }
        }));
    });
</script>
{% endblock %}