|
1 | 1 | <html>
|
2 |
| - |
3 | 2 | <head>
|
4 | 3 | <meta charset="UTF-8">
|
5 | 4 | <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
|
132 | 131 | align-items: stretch;
|
133 | 132 | }
|
134 | 133 |
|
135 |
| - .right { |
| 134 | + .message-controls { |
136 | 135 | display: flex;
|
137 |
| - flex-direction: row; |
138 |
| - gap: 0.5em; |
139 | 136 | justify-content: flex-end;
|
140 | 137 | }
|
| 138 | + .message-controls > div:nth-child(2) { |
| 139 | + display: flex; |
| 140 | + flex-direction: column; |
| 141 | + gap: 0.5em; |
| 142 | + } |
| 143 | + .message-controls > div:nth-child(2) > div { |
| 144 | + display: flex; |
| 145 | + margin-left: auto; |
| 146 | + gap: 0.5em; |
| 147 | + } |
141 | 148 |
|
142 | 149 | fieldset {
|
143 | 150 | border: none;
|
|
276 | 283 |
|
277 | 284 | import { llama } from './completion.js';
|
278 | 285 | import { SchemaConverter } from './json-schema-to-grammar.mjs';
|
| 286 | + |
279 | 287 | let selected_image = false;
|
280 | 288 | var slot_id = -1;
|
281 | 289 |
|
|
447 | 455 |
|
448 | 456 | /* END: Support for storing prompt templates and parameters in browsers LocalStorage */
|
449 | 457 |
|
| 458 | + const tts = window.speechSynthesis; |
| 459 | + const ttsVoice = signal(null) |
| 460 | + |
450 | 461 | const llamaStats = signal(null)
|
451 | 462 | const controller = signal(null)
|
452 | 463 |
|
|
596 | 607 | });
|
597 | 608 | }
|
598 | 609 |
|
| 610 | + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; |
| 611 | + const talkRecognition = SpeechRecognition ? new SpeechRecognition() : null; |
599 | 612 | function MessageInput() {
|
600 |
| - const message = useSignal("") |
| 613 | + const message = useSignal(""); |
| 614 | + |
| 615 | + const talkActive = useSignal(false); |
| 616 | + const sendOnTalk = useSignal(false); |
| 617 | + const talkStop = (e) => { |
| 618 | + if (e) e.preventDefault(); |
| 619 | + |
| 620 | + talkActive.value = false; |
| 621 | + talkRecognition?.stop(); |
| 622 | + } |
| 623 | + const talk = (e) => { |
| 624 | + e.preventDefault(); |
| 625 | + |
| 626 | + if (talkRecognition) |
| 627 | + talkRecognition.start(); |
| 628 | + else |
| 629 | + alert("Speech recognition is not supported by this browser."); |
| 630 | + } |
| 631 | + if(talkRecognition) { |
| 632 | + talkRecognition.onstart = () => { |
| 633 | + talkActive.value = true; |
| 634 | + } |
| 635 | + talkRecognition.onresult = (e) => { |
| 636 | + if (event.results.length > 0) { |
| 637 | + message.value = event.results[0][0].transcript; |
| 638 | + if (sendOnTalk.value) { |
| 639 | + submit(e); |
| 640 | + } |
| 641 | + } |
| 642 | + } |
| 643 | + talkRecognition.onspeechend = () => { |
| 644 | + talkStop(); |
| 645 | + } |
| 646 | + } |
| 647 | + |
| 648 | + const ttsVoices = useSignal(tts?.getVoices() || []); |
| 649 | + const ttsVoiceDefault = computed(() => ttsVoices.value.find(v => v.default)); |
| 650 | + if (tts) { |
| 651 | + tts.onvoiceschanged = () => { |
| 652 | + ttsVoices.value = tts.getVoices(); |
| 653 | + } |
| 654 | + } |
601 | 655 |
|
602 | 656 | const submit = (e) => {
|
603 | 657 | stop(e);
|
|
624 | 678 | value="${message}"
|
625 | 679 | />
|
626 | 680 | </div>
|
627 |
| - <div class="right"> |
628 |
| - <button type="submit" disabled=${generating.value}>Send</button> |
629 |
| - <button onclick=${uploadImage}>Upload Image</button> |
630 |
| - <button onclick=${stop} disabled=${!generating.value}>Stop</button> |
631 |
| - <button onclick=${reset}>Reset</button> |
| 681 | + <div class="message-controls"> |
| 682 | + <div> </div> |
| 683 | + <div> |
| 684 | + <div> |
| 685 | + <button type="submit" disabled=${generating.value || talkActive.value}>Send</button> |
| 686 | + <button disabled=${generating.value || talkActive.value} onclick=${uploadImage}>Upload Image</button> |
| 687 | + <button onclick=${stop} disabled=${!generating.value}>Stop</button> |
| 688 | + <button onclick=${reset}>Reset</button> |
| 689 | + </div> |
| 690 | + <div> |
| 691 | + <a href="#" style="cursor: help;" title="Help" onclick=${e => { |
| 692 | + e.preventDefault(); |
| 693 | + alert(`STT supported by your browser: ${SpeechRecognition ? 'Yes' : 'No'}\n` + |
| 694 | + `(TTS and speech recognition are not provided by llama.cpp)\n` + |
| 695 | + `Note: STT requires HTTPS to work.`); |
| 696 | + }}>[?]</a> |
| 697 | + <button disabled=${generating.value} onclick=${talkActive.value ? talkStop : talk}>${talkActive.value ? "Stop Talking" : "Talk"}</button> |
| 698 | + <div> |
| 699 | + <input type="checkbox" id="send-on-talk" name="send-on-talk" checked="${sendOnTalk}" onchange=${(e) => sendOnTalk.value = e.target.checked} /> |
| 700 | + <label for="send-on-talk" style="line-height: initial;">Send after talking</label> |
| 701 | + </div> |
| 702 | + </div> |
| 703 | + <div> |
| 704 | + <a href="#" style="cursor: help;" title="Help" onclick=${e => { |
| 705 | + e.preventDefault(); |
| 706 | + alert(`TTS supported by your browser: ${tts ? 'Yes' : 'No'}\n(TTS and speech recognition are not provided by llama.cpp)`); |
| 707 | + }}>[?]</a> |
| 708 | + <label for="tts-voices" style="line-height: initial;">Bot Voice:</label> |
| 709 | + <select id="tts-voices" name="tts-voices" onchange=${(e) => ttsVoice.value = e.target.value} style="max-width: 100px;"> |
| 710 | + <option value="" selected="${!ttsVoice.value}">None</option> |
| 711 | + ${[ |
| 712 | + ...(ttsVoiceDefault.value ? [ttsVoiceDefault.value] : []), |
| 713 | + ...ttsVoices.value.filter(v => !v.default), |
| 714 | + ].map( |
| 715 | + v => html`<option value="${v.name}" selected="${ttsVoice.value === v.name}">${v.name} (${v.lang}) ${v.default ? '(default)' : ''}</option>` |
| 716 | + )} |
| 717 | + </select> |
| 718 | + </div> |
| 719 | + </div> |
632 | 720 | </div>
|
633 | 721 | </form>
|
634 | 722 | `
|
|
659 | 747 | }
|
660 | 748 | }, [messages])
|
661 | 749 |
|
| 750 | + const ttsChatLineActiveIx = useSignal(undefined); |
| 751 | + const ttsChatLine = (e, ix, msg) => { |
| 752 | + if (e) e.preventDefault(); |
| 753 | + |
| 754 | + if (!tts || !ttsVoice.value || !('SpeechSynthesisUtterance' in window)) return; |
| 755 | + |
| 756 | + const ttsVoices = tts.getVoices(); |
| 757 | + const voice = ttsVoices.find(v => v.name === ttsVoice.value); |
| 758 | + if (!voice) return; |
| 759 | + |
| 760 | + if (ttsChatLineActiveIx.value !== undefined) { |
| 761 | + tts.cancel(); |
| 762 | + if (ttsChatLineActiveIx.value === ix) { |
| 763 | + ttsChatLineActiveIx.value = undefined; |
| 764 | + return; |
| 765 | + } |
| 766 | + } |
| 767 | + |
| 768 | + ttsChatLineActiveIx.value = ix; |
| 769 | + let ttsUtter = new SpeechSynthesisUtterance(msg); |
| 770 | + ttsUtter.voice = voice; |
| 771 | + ttsUtter.onend = e => { |
| 772 | + ttsChatLineActiveIx.value = undefined; |
| 773 | + }; |
| 774 | + tts.speak(ttsUtter); |
| 775 | + } |
| 776 | + |
662 | 777 | const isCompletionMode = session.value.type === 'completion'
|
| 778 | + |
| 779 | + // Try play the last bot message |
| 780 | + const lastCharChatLinesIxs = useSignal([]); |
| 781 | + const lastCharChatLinesIxsOld = useSignal([]); |
| 782 | + useEffect(() => { |
| 783 | + if ( |
| 784 | + !isCompletionMode |
| 785 | + && lastCharChatLinesIxs.value.length !== lastCharChatLinesIxsOld.value.length |
| 786 | + && !generating.value |
| 787 | + ) { |
| 788 | + const ix = lastCharChatLinesIxs.value[lastCharChatLinesIxs.value.length - 1]; |
| 789 | + if (ix !== undefined) { |
| 790 | + const msg = messages[ix]; |
| 791 | + ttsChatLine(null, ix, Array.isArray(msg) ? msg[1].map(m => m.content).join('') : msg); |
| 792 | + } |
| 793 | + |
| 794 | + lastCharChatLinesIxsOld.value = structuredClone(lastCharChatLinesIxs.value); |
| 795 | + } |
| 796 | + }, [generating.value]); |
| 797 | + |
663 | 798 | const chatLine = ([user, data], index) => {
|
664 | 799 | let message
|
665 |
| - const isArrayMessage = Array.isArray(data) |
| 800 | + const isArrayMessage = Array.isArray(data); |
| 801 | + const text = isArrayMessage ? |
| 802 | + data.map(msg => msg.content).join('') : |
| 803 | + data; |
666 | 804 | if (params.value.n_probs > 0 && isArrayMessage) {
|
667 | 805 | message = html`<${Probabilities} data=${data} />`
|
668 | 806 | } else {
|
669 |
| - const text = isArrayMessage ? |
670 |
| - data.map(msg => msg.content).join('') : |
671 |
| - data; |
672 | 807 | message = isCompletionMode ?
|
673 | 808 | text :
|
674 | 809 | html`<${Markdownish} text=${template(text)} />`
|
675 | 810 | }
|
| 811 | + |
| 812 | + const fromBot = user && user === '{{char}}'; |
| 813 | + if (fromBot && !lastCharChatLinesIxs.value.includes(index)) |
| 814 | + lastCharChatLinesIxs.value.push(index); |
| 815 | + |
676 | 816 | if (user) {
|
677 |
| - return html`<p key=${index}><strong>${template(user)}:</strong> ${message}</p>` |
| 817 | + return html` |
| 818 | + <div> |
| 819 | + <p key=${index}><strong>${template(user)}:</strong> ${message}</p> |
| 820 | + ${ |
| 821 | + fromBot && ttsVoice.value |
| 822 | + && html`<button disabled=${generating.value} onclick=${e => ttsChatLine(e, index, text)} aria-label=${ttsChatLineActiveIx.value === index ? 'Pause' : 'Play'}>${ ttsChatLineActiveIx.value === index ? '⏸️' : '▶️' }</div>` |
| 823 | + } |
| 824 | + </div> |
| 825 | + `; |
678 | 826 | } else {
|
679 | 827 | return isCompletionMode ?
|
680 | 828 | html`<span key=${index}>${message}</span>` :
|
681 |
| - html`<p key=${index}>${message}</p>` |
| 829 | + html`<div><p key=${index}>${message}</p></div>` |
682 | 830 | }
|
683 | 831 | };
|
684 | 832 |
|
|
0 commit comments