feat: integrate desktop runtime and root workspace updates

This commit is contained in:
Evandro Fonseca Junior committed 2026-03-27 03:17:16 -03:00
1 parent f5a4a9ee97
commit 168623c459
93 files changed
+19540 -1208

No files matched your search

-1
View File
@@ -1 +0,0 @@
.sidebar{width:260px;min-width:260px;background:var(--bg2);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}.sidebar-header{display:flex;align-items:center;padding:14px 14px 12px;border-bottom:1px solid var(--border);gap:8px}.sidebar-brand{display:flex;align-items:center;gap:8px;flex:1}.brand-icon{font-size:1.1rem}.brand-name{font-size:.95rem;font-weight:700;letter-spacing:-.01em}.icon-btn{background:none;border:none;color:var(--text2);cursor:pointer;font-size:1rem;padding:4px 6px;border-radius:var(--radius-sm);transition:background .15s,color .15s;line-height:1}.icon-btn:hover{background:var(--bg4);color:var(--text)}.settings-panel{padding:12px 14px;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:6px;background:var(--bg3)}.settings-label{font-size:.7rem;font-weight:600;color:var(--text3);text-transform:uppercase;letter-spacing:.05em}.settings-input{background:var(--bg4);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:.8rem;padding:6px 10px;outline:none;transition:border-color .15s;font-family:inherit}.settings-input:focus{border-color:var(--accent)}.model-section{padding:10px 14px;border-bottom:1px solid var(--border)}.model-row{display:flex;align-items:center;gap:8px}.status-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0;transition:background .3s}.status-dot.status-ok{background:var(--success);box-shadow:0 0 6px var(--success)}.status-dot.status-error{background:var(--danger)}.status-dot.status-loading{background:var(--warning);animation:pulse 1s infinite}@keyframes pulse{0%,to{opacity:1}50%{opacity:.4}}.model-select{flex:1;background:var(--bg4);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:.78rem;padding:5px 8px;outline:none;cursor:pointer;min-width:0;font-family:inherit}.model-select:focus{border-color:var(--accent)}.sidebar-actions{padding:10px 14px 6px}.new-chat-btn{width:100%;background:var(--accent);color:#fff;border:none;border-radius:var(--radius-sm);padding:8px 12px;font-size:.82rem;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:6px;transition:background .15s,transform .1s;font-family:inherit}.new-chat-btn:hover{background:var(--accent2)}.new-chat-btn:active{transform:scale(.98)}.chat-list{flex:1;overflow-y:auto;padding:6px 8px;display:flex;flex-direction:column;gap:1px}.chat-list-empty{text-align:center;color:var(--text3);font-size:.8rem;padding:20px 0}.chat-item{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:var(--radius-sm);cursor:pointer;font-size:.82rem;color:var(--text2);transition:background .1s,color .1s;position:relative}.chat-item:hover{background:var(--bg3);color:var(--text)}.chat-item.active{background:var(--bg4);color:var(--text)}.chat-item-icon{font-size:.85rem;flex-shrink:0;opacity:.6}.chat-item-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.chat-item-del{opacity:0;background:none;border:none;color:var(--danger);cursor:pointer;font-size:.75rem;padding:2px 5px;border-radius:4px;line-height:1;transition:opacity .15s,background .15s;flex-shrink:0}.chat-item:hover .chat-item-del{opacity:1}.chat-item-del:hover{background:#f0404026}.sidebar-footer{padding:10px 14px;border-top:1px solid var(--border)}.footer-version{font-size:.7rem;color:var(--text3)}.msg-edit-container{width:100%;max-width:780px}.msg-edit-textarea{width:100%;background:var(--bg3);border:1px solid var(--accent);border-radius:var(--radius);color:var(--text);font-size:.9rem;font-family:inherit;padding:10px 14px;resize:none;min-height:80px;outline:none;line-height:1.6}.msg-edit-actions{display:flex;gap:6px;margin-top:8px}.btn-primary{background:var(--accent);color:#fff;border:none;border-radius:var(--radius-sm);padding:6px 14px;font-size:.8rem;font-weight:600;cursor:pointer;font-family:inherit;transition:background .15s}.btn-primary:hover{background:var(--accent2)}.btn-secondary{background:var(--bg4);color:var(--text2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:6px 14px;font-size:.8rem;cursor:pointer;font-family:inherit;transition:background .15s}.btn-secondary:hover{background:var(--bg5);color:var(--text)}.btn-ghost{background:none;color:var(--text3);border:none;border-radius:var(--radius-sm);padding:6px 10px;font-size:.8rem;cursor:pointer;font-family:inherit;transition:color .15s}.btn-ghost:hover{color:var(--text2)}.msg-actions{display:flex;gap:4px;margin-top:5px;opacity:0;transition:opacity .15s}.msg-wrapper:hover .msg-actions{opacity:1}.action-btn{background:none;border:1px solid var(--border);color:var(--text2);border-radius:var(--radius-sm);padding:3px 9px;font-size:.72rem;cursor:pointer;transition:all .15s;font-family:inherit;white-space:nowrap}.action-btn:hover{background:var(--bg3);color:var(--text);border-color:var(--border2)}.action-btn.copied{color:var(--success);border-color:var(--success)}.action-btn.danger:hover{background:#f040401Line truncated
-108
View File
@@ -1,108 +0,0 @@
var tp=Object.defineProperty;var Yu=e=>{throw TypeError(e)};var np=(e,t,n)=>t in e?tp(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var G=(e,t,n)=>np(e,typeof t!="symbol"?t+"":t,n),rp=(e,t,n)=>t.has(e)||Yu("Cannot "+n);var Xu=(e,t,n)=>t.has(e)?Yu("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,n);var El=(e,t,n)=>(rp(e,t,"access private method"),n);(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const i of l)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(l){const i={};return l.integrity&&(i.integrity=l.integrity),l.referrerPolicy&&(i.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?i.credentials="include":l.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function r(l){if(l.ep)return;l.ep=!0;const i=n(l);fetch(l.href,i)}})();function lp(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var kc={exports:{}},Li={},wc={exports:{}},U={};/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var fl=Symbol.for("react.element"),ip=Symbol.for("react.portal"),op=Symbol.for("react.fragment"),sp=Symbol.for("react.strict_mode"),up=Symbol.for("react.profiler"),ap=Symbol.for("react.provider"),cp=Symbol.for("react.context"),fp=Symbol.for("react.forward_ref"),dp=Symbol.for("react.suspense"),pp=Symbol.for("react.memo"),hp=Symbol.for("react.lazy"),Ku=Symbol.iterator;function mp(e){return e===null||typeof e!="object"?null:(e=Ku&&e[Ku]||e["@@iterator"],typeof e=="function"?e:null)}var Sc={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Ec=Object.assign,xc={};function or(e,t,n){this.props=e,this.context=t,this.refs=xc,this.updater=n||Sc}or.prototype.isReactComponent={};or.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};or.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function _c(){}_c.prototype=or.prototype;function Ps(e,t,n){this.props=e,this.context=t,this.refs=xc,this.updater=n||Sc}var Os=Ps.prototype=new _c;Os.constructor=Ps;Ec(Os,or.prototype);Os.isPureReactComponent=!0;var Zu=Array.isArray,Tc=Object.prototype.hasOwnProperty,Is={current:null},Cc={key:!0,ref:!0,__self:!0,__source:!0};function Nc(e,t,n){var r,l={},i=null,o=null;if(t!=null)for(r in t.ref!==void 0&&(o=t.ref),t.key!==void 0&&(i=""+t.key),t)Tc.call(t,r)&&!Cc.hasOwnProperty(r)&&(l[r]=t[r]);var s=arguments.length-2;if(s===1)l.children=n;else if(1<s){for(var u=Array(s),a=0;a<s;a++)u[a]=arguments[a+2];l.children=u}if(e&&e.defaultProps)for(r in s=e.defaultProps,s)l[r]===void 0&&(l[r]=s[r]);return{$$typeof:fl,type:e,key:i,ref:o,props:l,_owner:Is.current}}function gp(e,t){return{$$typeof:fl,type:e.type,key:t,ref:e.ref,props:e.props,_owner:e._owner}}function zs(e){return typeof e=="object"&&e!==null&&e.$$typeof===fl}function yp(e){var t={"=":"=0",":":"=2"};return"$"+e.replace(/[=:]/g,function(n){return t[n]})}var qu=/\/+/g;function eo(e,t){return typeof e=="object"&&e!==null&&e.key!=null?yp(""+e.key):t.toString(36)}function Vl(e,t,n,r,l){var i=typeof e;(i==="undefined"||i==="boolean")&&(e=null);var o=!1;if(e===null)o=!0;else switch(i){case"string":case"number":o=!0;break;case"object":switch(e.$$typeof){case fl:case ip:o=!0}}if(o)return o=e,l=l(o),e=r===""?"."+eo(o,0):r,Zu(l)?(n="",e!=null&&(n=e.replace(qu,"$&/")+"/"),Vl(l,t,n,"",function(a){return a})):l!=null&&(zs(l)&&(l=gp(l,n+(!l.key||o&&o.key===l.key?"":(""+l.key).replace(qu,"$&/")+"/")+e)),t.push(l)),1;if(o=0,r=r===""?".":r+":",Zu(e))for(var s=0;s<e.length;s++){i=e[s];var u=r+eo(i,s);o+=Vl(i,t,n,u,l)}else if(u=mp(e),typeof u=="function")for(e=u.call(e),s=0;!(i=e.next()).done;)i=i.value,u=r+eo(i,s++),o+=Vl(i,t,n,u,l);else if(i==="object")throw t=String(e),Error("Objects are not valid as a React child (found: "+(t==="[object Object]"?"object with keys {"+Object.keys(e).join(", ")+"}":t)+"). If you meant to render a collection of children, use an array instead.");return o}function xl(e,t,n){if(e==null)return e;var r=[],l=0;return Vl(e,r,"","",function(i){return t.call(n,i,l++)}),r}function vp(e){if(e._status===-1){var t=e._result;t=t(),t.then(function(n){(e._status===0||e._status===-1)&&(e._status=1,e._result=n)},function(n){(e._status===0||e._status===-1)&&(e._status=2,e._result=n)}),e._status===-1&&(e._status=0,e._result=t)}if(e._status===1)return e._result.default;throw e._result}var Ae={current:null},Ql={transition:null},kp={ReactCurrentDispatcher:Ae,ReactCurrentBatchConfig:Ql,ReactCurrentOwner:Is};function Rc(){throw Error("act(...) is not supported in production builds of React.")}U.Children={map:xl,forEach:function(e,t,n){xl(e,function(){t.apply(this,arguments)},n)},count:function(e){var t=0;return xl(e,function(){t++}),t},toArray:function(e){return xl(e,function(t){return t})||[]},only:function(e){if(!zs(e))throw Error("React.Children.only expected to receive a single React element child.");return e}};U.Component=or;U.Fragment=op;U.Profiler=up;U.PureComponent=Ps;U.StrictMode=sp;U.Suspense=dp;U.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=kp;U.act=Rc;U.cloneElement=function(e,t,n){if(e==null)throw Error("React.cloneElement(...): The argument must be a React element, but you passed "+e+".");var r=Ec({},e.props),l=e.key,i=e.ref,o=e._owner;if(t!=null){if(t.ref!==void 0&&(i=t.ref,o=Is.current),t.key!==void 0&&(l=""+t.key),e.type&&e.type.defaultProps)var s=e.type.defaultProps;for(u in t)Tc.call(t,u)&&!Cc.hasOwnProperty(u)&&(r[u]=t[u]===void 0&&s!==void 0?s[u]:t[u])}var u=arguments.length-2;if(u===1)r.children=n;else if(1<u){s=Array(u);for(var a=0;a<u;a++)s[a]=arguments[a+2];r.children=s}return{$$typeof:fl,type:e.type,key:l,ref:i,props:r,_owner:o}};U.createContext=function(e){return e={$$typeof:cp,_currentValue:e,_currentValue2:e,_threadCount:0,ProviderLine truncated
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var Sp=M,Ep=Symbol.for("react.element"),xp=Symbol.for("react.fragment"),_p=Object.prototype.hasOwnProperty,Tp=Sp.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,Cp={key:!0,ref:!0,__self:!0,__source:!0};function Ac(e,t,n){var r,l={},i=null,o=null;n!==void 0&&(i=""+n),t.key!==void 0&&(i=""+t.key),t.ref!==void 0&&(o=t.ref);for(r in t)_p.call(t,r)&&!Cp.hasOwnProperty(r)&&(l[r]=t[r]);if(e&&e.defaultProps)for(r in t=e.defaultProps,t)l[r]===void 0&&(l[r]=t[r]);return{$$typeof:Ep,type:e,key:i,ref:o,props:l,_owner:Tp.current}}Li.Fragment=xp;Li.jsx=Ac;Li.jsxs=Ac;kc.exports=Li;var _=kc.exports,zo={},Lc={exports:{}},Ve={},Pc={exports:{}},Oc={};/**
* @license React
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/(function(e){function t(C,E){var I=C.length;C.push(E);e:for(;0<I;){var D=I-1>>>1,K=C[D];if(0<l(K,E))C[D]=E,C[I]=K,I=D;else break e}}function n(C){return C.length===0?null:C[0]}function r(C){if(C.length===0)return null;var E=C[0],I=C.pop();if(I!==E){C[0]=I;e:for(var D=0,K=C.length,J=K>>>1;D<J;){var je=2*(D+1)-1,V=C[je],ye=je+1,Xe=C[ye];if(0>l(V,I))ye<K&&0>l(Xe,V)?(C[D]=Xe,C[ye]=I,D=ye):(C[D]=V,C[je]=I,D=je);else if(ye<K&&0>l(Xe,I))C[D]=Xe,C[ye]=I,D=ye;else break e}}return E}function l(C,E){var I=C.sortIndex-E.sortIndex;return I!==0?I:C.id-E.id}if(typeof performance=="object"&&typeof performance.now=="function"){var i=performance;e.unstable_now=function(){return i.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var u=[],a=[],m=1,g=null,d=3,y=!1,w=!1,k=!1,O=typeof setTimeout=="function"?setTimeout:null,p=typeof clearTimeout=="function"?clearTimeout:null,c=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function h(C){for(var E=n(a);E!==null;){if(E.callback===null)r(a);else if(E.startTime<=C)r(a),E.sortIndex=E.expirationTime,t(u,E);else break;E=n(a)}}function v(C){if(k=!1,h(C),!w)if(n(u)!==null)w=!0,ln(T);else{var E=n(a);E!==null&&on(v,E.startTime-C)}}function T(C,E){w=!1,k&&(k=!1,p(L),L=-1),y=!0;var I=d;try{for(h(E),g=n(u);g!==null&&(!(g.expirationTime>E)||C&&!B());){var D=g.callback;if(typeof D=="function"){g.callback=null,d=g.priorityLevel;var K=D(g.expirationTime<=E);E=e.unstable_now(),typeof K=="function"?g.callback=K:g===n(u)&&r(u),h(E)}else r(u);g=n(u)}if(g!==null)var J=!0;else{var je=n(a);je!==null&&on(v,je.startTime-E),J=!1}return J}finally{g=null,d=I,y=!1}}var N=!1,A=null,L=-1,X=5,j=-1;function B(){return!(e.unstable_now()-j<X)}function Ge(){if(A!==null){var C=e.unstable_now();j=C;var E=!0;try{E=A(!0,C)}finally{E?Ye():(N=!1,A=null)}}else N=!1}var Ye;if(typeof c=="function")Ye=function(){c(Ge)};else if(typeof MessageChannel<"u"){var xt=new MessageChannel,ar=xt.port2;xt.port1.onmessage=Ge,Ye=function(){ar.postMessage(null)}}else Ye=function(){O(Ge,0)};function ln(C){A=C,N||(N=!0,Ye())}function on(C,E){L=O(function(){C(e.unstable_now())},E)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(C){C.callback=null},e.unstable_continueExecution=function(){w||y||(w=!0,ln(T))},e.unstable_forceFrameRate=function(C){0>C||125<C?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):X=0<C?Math.floor(1e3/C):5},e.unstable_getCurrentPriorityLevel=function(){return d},e.unstable_getFirstCallbackNode=function(){return n(u)},e.unstable_next=function(C){switch(d){case 1:case 2:case 3:var E=3;break;default:E=d}var I=d;d=E;try{return C()}finally{d=I}},e.unstable_pauseExecution=function(){},e.unstable_requestPaint=function(){},e.unstable_runWithPriority=function(C,E){switch(C){case 1:case 2:case 3:case 4:case 5:break;default:C=3}var I=d;d=C;try{return E()}finally{d=I}},e.unstable_scheduleCallback=function(C,E,I){var D=e.unstable_now();switch(typeof I=="object"&&I!==null?(I=I.delay,I=typeof I=="number"&&0<I?D+I:D):I=D,C){case 1:var K=-1;break;case 2:K=250;break;case 5:K=1073741823;break;case 4:K=1e4;break;default:K=5e3}return K=I+K,C={id:m++,callback:E,priorityLevel:C,startTime:I,expirationTime:K,sortIndex:-1},I>D?(C.sortIndex=I,t(a,C),n(u)===null&&C===n(a)&&(k?(p(L),L=-1):k=!0,on(v,I-D))):(C.sortIndex=K,t(u,C),w||y||(w=!0,ln(T))),C},e.unstable_shouldYield=B,e.unstable_wrapCallback=function(C){var E=d;return function(){var I=d;d=E;try{return C.apply(this,arguments)}finally{d=I}}}})(Oc);Pc.exports=Oc;var Np=Pc.exports;/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var Rp=M,We=Np;function S(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n<arguments.length;n++)t+="&args[]="+encodeURIComponent(arguments[n]);return"Minified React error #"+e+"; visit "+t+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}var Ic=new Set,Yr={};function _n(e,t){bn(e,t),bn(e+"Capture",t)}function bn(e,t){for(Yr[e]=t,e=0;e<t.length;e++)Ic.add(t[e])}var Ot=!(typeof window>"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Do=Object.prototype.hasOwnProperty,Ap=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,Ju={},bu={};function Lp(e){return Do.call(bu,e)?!0:Do.call(Ju,e)?!1:Ap.test(e)?bu[e]=!0:(Ju[e]=!0,!1)}function Pp(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Op(e,t,n,r){if(t===null||typeof t>"u"||Pp(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function Le(e,t,n,r,l,i,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i,this.removeEmptyString=o}var ge={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){ge[e]=new Le(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];ge[t]=new Le(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){ge[e]=new Le(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){ge[e]=new Le(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){ge[e]=new Le(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){ge[e]=new Le(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){ge[e]=new Le(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){ge[e]=new Le(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){ge[e]=new Le(e,5,!1,e.toLowerCase(),null,!1,!1)});var Ds=/[\-:]([a-z])/g;function Ms(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Ds,Ms);ge[t]=new Le(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Ds,Ms);ge[t]=new Le(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Ds,Ms);ge[t]=new Le(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){ge[e]=new Le(e,1,!1,e.toLowerCase(),null,!1,!1)});ge.xlinkHref=new Le("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){ge[e]=new Le(e,1,!1,e.toLowerCase(),null,!0,!0)});function js(e,t,n,r){var l=ge.hasOwnProperty(t)?ge[t]:null;(l!==null?l.type!==0:r||!(2<t.length)||t[0]!=="o"&&t[0]!==" Line truncated
`+to+e}var no=!1;function ro(e,t){if(!e||no)return"";no=!0;var n=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{if(t)if(t=function(){throw Error()},Object.defineProperty(t.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(t,[])}catch(a){var r=a}Reflect.construct(e,[],t)}else{try{t.call()}catch(a){r=a}e.call(t.prototype)}else{try{throw Error()}catch(a){r=a}e()}}catch(a){if(a&&r&&typeof a.stack=="string"){for(var l=a.stack.split(`
`),i=r.stack.split(`
`),o=l.length-1,s=i.length-1;1<=o&&0<=s&&l[o]!==i[s];)s--;for(;1<=o&&0<=s;o--,s--)if(l[o]!==i[s]){if(o!==1||s!==1)do if(o--,s--,0>s||l[o]!==i[s]){var u=`
`+l[o].replace(" at new "," at ");return e.displayName&&u.includes("<anonymous>")&&(u=u.replace("<anonymous>",e.displayName)),u}while(1<=o&&0<=s);break}}}finally{no=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?Or(e):""}function Ip(e){switch(e.tag){case 5:return Or(e.type);case 16:return Or("Lazy");case 13:return Or("Suspense");case 19:return Or("SuspenseList");case 0:case 2:case 15:return e=ro(e.type,!1),e;case 11:return e=ro(e.type.render,!1),e;case 1:return e=ro(e.type,!0),e;default:return""}}function Uo(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Mn:return"Fragment";case Dn:return"Portal";case Mo:return"Profiler";case Fs:return"StrictMode";case jo:return"Suspense";case Fo:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case Dc:return(e.displayName||"Context")+".Consumer";case zc:return(e._context.displayName||"Context")+".Provider";case Us:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case $s:return t=e.displayName||null,t!==null?t:Uo(e.type)||"Memo";case Ut:t=e._payload,e=e._init;try{return Uo(e(t))}catch{}}return null}function zp(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return Uo(t);case 8:return t===Fs?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function bt(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function jc(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Dp(e){var t=jc(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,i=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(o){r=""+o,i.call(this,o)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(o){r=""+o},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Tl(e){e._valueTracker||(e._valueTracker=Dp(e))}function Fc(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=jc(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function li(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function $o(e,t){var n=t.checked;return re({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function ta(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=bt(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Uc(e,t){t=t.checked,t!=null&&js(e,"checked",t,!1)}function Ho(e,t){Uc(e,t);var n=bt(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?Bo(e,t.type,n):t.hasOwnProperty("defaultValue")&&Bo(e,t.type,bt(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function na(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function Bo(e,t,n){(t!=="number"||li(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Ir=Array.isArray;function Yn(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l<n.length;l++)t["$"+n[l]]=!0;for(n=0;n<e.length;n++)l=t.hasOwnProperty("$"+e[n].value),e[n].selected!==l&&(e[n].selected=l),l&&r&&(e[n].defaultSelected=!0)}else{for(n=""+bt(n),t=null,l=0;l<e.length;l++){if(e[l].value===n){e[l].selected=!0,r&&(e[l].defaultSelected=!0);return}t!==null||e[l].disabled||(t=e[l])}t!==null&&(t.selected=!0)}}function Wo(e,t){if(t.dan Line truncated
`).replace(Qh,"")}function Il(e,t,n){if(t=Ta(t),Ta(e)!==t&&n)throw Error(S(425))}function di(){}var ns=null,rs=null;function ls(e,t){return e==="textarea"||e==="noscript"||typeof t.children=="string"||typeof t.children=="number"||typeof t.dangerouslySetInnerHTML=="object"&&t.dangerouslySetInnerHTML!==null&&t.dangerouslySetInnerHTML.__html!=null}var is=typeof setTimeout=="function"?setTimeout:void 0,Gh=typeof clearTimeout=="function"?clearTimeout:void 0,Ca=typeof Promise=="function"?Promise:void 0,Yh=typeof queueMicrotask=="function"?queueMicrotask:typeof Ca<"u"?function(e){return Ca.resolve(null).then(e).catch(Xh)}:is;function Xh(e){setTimeout(function(){throw e})}function yo(e,t){var n=t,r=0;do{var l=n.nextSibling;if(e.removeChild(n),l&&l.nodeType===8)if(n=l.data,n==="/$"){if(r===0){e.removeChild(l),Jr(t);return}r--}else n!=="$"&&n!=="$?"&&n!=="$!"||r++;n=l}while(n);Jr(t)}function Xt(e){for(;e!=null;e=e.nextSibling){var t=e.nodeType;if(t===1||t===3)break;if(t===8){if(t=e.data,t==="$"||t==="$!"||t==="$?")break;if(t==="/$")return null}}return e}function Na(e){e=e.previousSibling;for(var t=0;e;){if(e.nodeType===8){var n=e.data;if(n==="$"||n==="$!"||n==="$?"){if(t===0)return e;t--}else n==="/$"&&t++}e=e.previousSibling}return null}var ur=Math.random().toString(36).slice(2),vt="__reactFiber$"+ur,rl="__reactProps$"+ur,It="__reactContainer$"+ur,os="__reactEvents$"+ur,Kh="__reactListeners$"+ur,Zh="__reactHandles$"+ur;function dn(e){var t=e[vt];if(t)return t;for(var n=e.parentNode;n;){if(t=n[It]||n[vt]){if(n=t.alternate,t.child!==null||n!==null&&n.child!==null)for(e=Na(e);e!==null;){if(n=e[vt])return n;e=Na(e)}return t}e=n,n=e.parentNode}return null}function hl(e){return e=e[vt]||e[It],!e||e.tag!==5&&e.tag!==6&&e.tag!==13&&e.tag!==3?null:e}function $n(e){if(e.tag===5||e.tag===6)return e.stateNode;throw Error(S(33))}function Di(e){return e[rl]||null}var ss=[],Hn=-1;function nn(e){return{current:e}}function q(e){0>Hn||(e.current=ss[Hn],ss[Hn]=null,Hn--)}function Y(e,t){Hn++,ss[Hn]=e.current,e.current=t}var en={},xe=nn(en),ze=nn(!1),yn=en;function er(e,t){var n=e.type.contextTypes;if(!n)return en;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},i;for(i in n)l[i]=t[i];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function De(e){return e=e.childContextTypes,e!=null}function pi(){q(ze),q(xe)}function Ra(e,t,n){if(xe.current!==en)throw Error(S(168));Y(xe,t),Y(ze,n)}function Rf(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(S(108,zp(e)||"Unknown",l));return re({},n,r)}function hi(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||en,yn=xe.current,Y(xe,e),Y(ze,ze.current),!0}function Aa(e,t,n){var r=e.stateNode;if(!r)throw Error(S(169));n?(e=Rf(e,t,yn),r.__reactInternalMemoizedMergedChildContext=e,q(ze),q(xe),Y(xe,e)):q(ze),Y(ze,n)}var Rt=null,Mi=!1,vo=!1;function Af(e){Rt===null?Rt=[e]:Rt.push(e)}function qh(e){Mi=!0,Af(e)}function rn(){if(!vo&&Rt!==null){vo=!0;var e=0,t=W;try{var n=Rt;for(W=1;e<n.length;e++){var r=n[e];do r=r(!0);while(r!==null)}Rt=null,Mi=!1}catch(l){throw Rt!==null&&(Rt=Rt.slice(e+1)),bc(Bs,rn),l}finally{W=t,vo=!1}}return null}var Bn=[],Wn=0,mi=null,gi=0,Ze=[],qe=0,vn=null,At=1,Lt="";function cn(e,t){Bn[Wn++]=gi,Bn[Wn++]=mi,mi=e,gi=t}function Lf(e,t,n){Ze[qe++]=At,Ze[qe++]=Lt,Ze[qe++]=vn,vn=e;var r=At;e=Lt;var l=32-ct(r)-1;r&=~(1<<l),n+=1;var i=32-ct(t)+l;if(30<i){var o=l-l%5;i=(r&(1<<o)-1).toString(32),r>>=o,l-=o,At=1<<32-ct(t)+l|n<<l|r,Lt=i+e}else At=1<<i|n<<l|r,Lt=e}function qs(e){e.return!==null&&(cn(e,1),Lf(e,1,0))}function Js(e){for(;e===mi;)mi=Bn[--Wn],Bn[Wn]=null,gi=Bn[--Wn],Bn[Wn]=null;for(;e===vn;)vn=Ze[--qe],Ze[qe]=null,Lt=Ze[--qe],Ze[qe]=null,At=Ze[--qe],Ze[qe]=null}var Be=null,He=null,ee=!1,at=null;function Pf(e,t){var n=Je(5,null,null,0);n.elementType="DELETED",n.stateNode=t,n.return=e,t=e.deletions,t===null?(e.deletions=[n],e.flags|=16):t.push(n)}function La(e,t){switch(e.tag){case 5:var n=e.type;return t=t.nodeType!==1||n.toLowerCase()!==t.nodeName.toLowerCase()?null:t,t!==null?(e.stateNode=t,Be=e,He=Xt(t.firstChild),!0):!1;case 6:return t=e.pendingProps===""||t.nodeType!==3?null:t,t!==null?(e.stateNode=t,Be=e,He=null,!0):!1;case 13:return t=t.nodeType!==8?null:t,t!==null?(n=vn!==null?{id:At,overflow:Lt}:null,e.memoizedState={dehydrated:t,treeContext:n,retryLane:1073741824},n=Je(18,null,null,0),n.stateNode=t,n.return=e,e.child=n,Be=e,He=null,!0):!1;default:return!1}}function us(e){return(e.mode&1)!==0&&(e.flags&128)===0}function as(e){if(ee){var t=He;if(t){var n=t;if(!La(e,t)){if(us(e))throw Error(S(418));t=Xt(n.nextSibling);var r=Be;t&&La(e,t)?Pf(r,n):(e.flags=e.flags&-4097|2,ee=!1,Be=e)}}else{if(us(e))throw Error(S(418));e.flags=e.flags&-4097|2,ee=!1,Be=e}}}function Pa(e){for(e=e.return;e!==null Line truncated
Error generating stack: `+i.message+`
`+i.stack}return{value:e,source:t,stack:l,digest:null}}function xo(e,t,n){return{value:e,source:null,stack:n??null,digest:t??null}}function ps(e,t){try{console.error(t.value)}catch(n){setTimeout(function(){throw n})}}var om=typeof WeakMap=="function"?WeakMap:Map;function ld(e,t,n){n=Pt(-1,n),n.tag=3,n.payload={element:null};var r=t.value;return n.callback=function(){_i||(_i=!0,xs=r),ps(e,t)},n}function id(e,t,n){n=Pt(-1,n),n.tag=3;var r=e.type.getDerivedStateFromError;if(typeof r=="function"){var l=t.value;n.payload=function(){return r(l)},n.callback=function(){ps(e,t)}}var i=e.stateNode;return i!==null&&typeof i.componentDidCatch=="function"&&(n.callback=function(){ps(e,t),typeof r!="function"&&(Zt===null?Zt=new Set([this]):Zt.add(this));var o=t.stack;this.componentDidCatch(t.value,{componentStack:o!==null?o:""})}),n}function Ua(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new om;var l=new Set;r.set(t,l)}else l=r.get(t),l===void 0&&(l=new Set,r.set(t,l));l.has(n)||(l.add(n),e=wm.bind(null,e,t,n),t.then(e,e))}function $a(e){do{var t;if((t=e.tag===13)&&(t=e.memoizedState,t=t!==null?t.dehydrated!==null:!0),t)return e;e=e.return}while(e!==null);return null}function Ha(e,t,n,r,l){return e.mode&1?(e.flags|=65536,e.lanes=l,e):(e===t?e.flags|=65536:(e.flags|=128,n.flags|=131072,n.flags&=-52805,n.tag===1&&(n.alternate===null?n.tag=17:(t=Pt(-1,1),t.tag=2,Kt(n,t,1))),n.lanes|=1),e)}var sm=Mt.ReactCurrentOwner,Ie=!1;function Te(e,t,n,r){t.child=e===null?zf(t,null,n,r):nr(t,e.child,n,r)}function Ba(e,t,n,r,l){n=n.render;var i=t.ref;return qn(t,l),r=au(e,t,n,r,i,l),n=cu(),e!==null&&!Ie?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l,Dt(e,t,l)):(ee&&n&&qs(t),t.flags|=1,Te(e,t,r,l),t.child)}function Wa(e,t,n,r,l){if(e===null){var i=n.type;return typeof i=="function"&&!wu(i)&&i.defaultProps===void 0&&n.compare===null&&n.defaultProps===void 0?(t.tag=15,t.type=i,od(e,t,i,r,l)):(e=ti(n.type,null,r,t,t.mode,l),e.ref=t.ref,e.return=t,t.child=e)}if(i=e.child,!(e.lanes&l)){var o=i.memoizedProps;if(n=n.compare,n=n!==null?n:el,n(o,r)&&e.ref===t.ref)return Dt(e,t,l)}return t.flags|=1,e=Jt(i,r),e.ref=t.ref,e.return=t,t.child=e}function od(e,t,n,r,l){if(e!==null){var i=e.memoizedProps;if(el(i,r)&&e.ref===t.ref)if(Ie=!1,t.pendingProps=r=i,(e.lanes&l)!==0)e.flags&131072&&(Ie=!0);else return t.lanes=e.lanes,Dt(e,t,l)}return hs(e,t,n,r,l)}function sd(e,t,n){var r=t.pendingProps,l=r.children,i=e!==null?e.memoizedState:null;if(r.mode==="hidden")if(!(t.mode&1))t.memoizedState={baseLanes:0,cachePool:null,transitions:null},Y(Gn,Ue),Ue|=n;else{if(!(n&1073741824))return e=i!==null?i.baseLanes|n:n,t.lanes=t.childLanes=1073741824,t.memoizedState={baseLanes:e,cachePool:null,transitions:null},t.updateQueue=null,Y(Gn,Ue),Ue|=e,null;t.memoizedState={baseLanes:0,cachePool:null,transitions:null},r=i!==null?i.baseLanes:n,Y(Gn,Ue),Ue|=r}else i!==null?(r=i.baseLanes|n,t.memoizedState=null):r=n,Y(Gn,Ue),Ue|=r;return Te(e,t,l,n),t.child}function ud(e,t){var n=t.ref;(e===null&&n!==null||e!==null&&e.ref!==n)&&(t.flags|=512,t.flags|=2097152)}function hs(e,t,n,r,l){var i=De(n)?yn:xe.current;return i=er(t,i),qn(t,l),n=au(e,t,n,r,i,l),r=cu(),e!==null&&!Ie?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l,Dt(e,t,l)):(ee&&r&&qs(t),t.flags|=1,Te(e,t,n,l),t.child)}function Va(e,t,n,r,l){if(De(n)){var i=!0;hi(t)}else i=!1;if(qn(t,l),t.stateNode===null)Jl(e,t),rd(t,n,r),ds(t,n,r,l),r=!0;else if(e===null){var o=t.stateNode,s=t.memoizedProps;o.props=s;var u=o.context,a=n.contextType;typeof a=="object"&&a!==null?a=et(a):(a=De(n)?yn:xe.current,a=er(t,a));var m=n.getDerivedStateFromProps,g=typeof m=="function"||typeof o.getSnapshotBeforeUpdate=="function";g||typeof o.UNSAFE_componentWillReceiveProps!="function"&&typeof o.componentWillReceiveProps!="function"||(s!==r||u!==a)&&Fa(t,o,r,a),$t=!1;var d=t.memoizedState;o.state=d,ki(t,r,o,l),u=t.memoizedState,s!==r||d!==u||ze.current||$t?(typeof m=="function"&&(fs(t,n,m,r),u=t.memoizedState),(s=$t||ja(t,n,s,r,d,u,a))?(g||typeof o.UNSAFE_componentWillMount!="function"&&typeof o.componentWillMount!="function"||(typeof o.componentWillMount=="function"&&o.componentWillMount(),typeof o.UNSAFE_componentWillMount=="function"&&o.UNSAFE_componentWillMount()),typeof o.componentDidMount=="function"&&(t.flags|=4194308)):(typeof o.componentDidMount=="function"&&(t.flags|=4194308),t.memoizedProps=r,t.memoizedState=u),o.props=r,o.state=u,o.context=a,r=s):(typeof o.componentDidMount=="function"&&(t.flags|=4194308),r=!1)}else{o=t.stateNode,Mf(e,t),s=t.memoizedProps,a=t.type===t.elementType?s:st(t.type,s),o.props=a,g=t.pendingProps,d=o.context,u=n.contextType,typeof u=="object"&&u!==null?u=et(u):(u=De(n)?yn:xe.current,u=er(t,u));var y=n.getDerivedStateFromProps;(m=typeof y=="function"||typeof o.getSnapshotBeforeUpdate=="function")||typeof o.UNSAFE_componentWillReceiveProps!="function"&&typeof o.componentWillReceiveProps!="function"||(s!==g||d!==u)&&Fa(t,o,r,u),$t=!1,d=t.memoizedState,o.state=d,ki(t,r,o,l);var w=t.memoizedState Line truncated
`).map(l=>{const i=l.match(/^\s+/);if(i===null)return l;const[o]=i;return o.length>=r.length?l.slice(r.length):l}).join(`
`)}class Ri{constructor(t){G(this,"options");G(this,"rules");G(this,"lexer");this.options=t||Cn}space(t){const n=this.rules.block.newline.exec(t);if(n&&n[0].length>0)return{type:"space",raw:n[0]}}code(t){const n=this.rules.block.code.exec(t);if(n){const r=n[0].replace(/^ {1,4}/gm,"");return{type:"code",raw:n[0],codeBlockStyle:"indented",text:this.options.pedantic?r:$l(r,`
`)}}}fences(t){const n=this.rules.block.fences.exec(t);if(n){const r=n[0],l=$m(r,n[3]||"");return{type:"code",raw:r,lang:n[2]?n[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):n[2],text:l}}}heading(t){const n=this.rules.block.heading.exec(t);if(n){let r=n[2].trim();if(/#$/.test(r)){const l=$l(r,"#");(this.options.pedantic||!l||/ $/.test(l))&&(r=l.trim())}return{type:"heading",raw:n[0],depth:n[1].length,text:r,tokens:this.lexer.inline(r)}}}hr(t){const n=this.rules.block.hr.exec(t);if(n)return{type:"hr",raw:n[0]}}blockquote(t){const n=this.rules.block.blockquote.exec(t);if(n){let r=n[0].replace(/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,`
$1`);r=$l(r.replace(/^ *>[ \t]?/gm,""),`
`);const l=this.lexer.state.top;this.lexer.state.top=!0;const i=this.lexer.blockTokens(r);return this.lexer.state.top=l,{type:"blockquote",raw:n[0],tokens:i,text:r}}}list(t){let n=this.rules.block.list.exec(t);if(n){let r=n[1].trim();const l=r.length>1,i={type:"list",raw:"",ordered:l,start:l?+r.slice(0,-1):"",loose:!1,items:[]};r=l?`\\d{1,9}\\${r.slice(-1)}`:`\\${r}`,this.options.pedantic&&(r=l?r:"[*+-]");const o=new RegExp(`^( {0,3}${r})((?:[ ][^\\n]*)?(?:\\n|$))`);let s="",u="",a=!1;for(;t;){let m=!1;if(!(n=o.exec(t))||this.rules.block.hr.test(t))break;s=n[0],t=t.substring(s.length);let g=n[2].split(`
`,1)[0].replace(/^\t+/,p=>" ".repeat(3*p.length)),d=t.split(`
`,1)[0],y=0;this.options.pedantic?(y=2,u=g.trimStart()):(y=n[2].search(/[^ ]/),y=y>4?1:y,u=g.slice(y),y+=n[1].length);let w=!1;if(!g&&/^ *$/.test(d)&&(s+=d+`
`,t=t.substring(d.length+1),m=!0),!m){const p=new RegExp(`^ {0,${Math.min(3,y-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),c=new RegExp(`^ {0,${Math.min(3,y-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),h=new RegExp(`^ {0,${Math.min(3,y-1)}}(?:\`\`\`|~~~)`),v=new RegExp(`^ {0,${Math.min(3,y-1)}}#`);for(;t;){const T=t.split(`
`,1)[0];if(d=T,this.options.pedantic&&(d=d.replace(/^ {1,4}(?=( {4})*[^ ])/g," ")),h.test(d)||v.test(d)||p.test(d)||c.test(t))break;if(d.search(/[^ ]/)>=y||!d.trim())u+=`
`+d.slice(y);else{if(w||g.search(/[^ ]/)>=4||h.test(g)||v.test(g)||c.test(g))break;u+=`
`+d}!w&&!d.trim()&&(w=!0),s+=T+`
`,t=t.substring(T.length+1),g=d.slice(y)}}i.loose||(a?i.loose=!0:/\n *\n *$/.test(s)&&(a=!0));let k=null,O;this.options.gfm&&(k=/^\[[ xX]\] /.exec(u),k&&(O=k[0]!=="[ ] ",u=u.replace(/^\[[ xX]\] +/,""))),i.items.push({type:"list_item",raw:s,task:!!k,checked:O,loose:!1,text:u,tokens:[]}),i.raw+=s}i.items[i.items.length-1].raw=s.trimEnd(),i.items[i.items.length-1].text=u.trimEnd(),i.raw=i.raw.trimEnd();for(let m=0;m<i.items.length;m++)if(this.lexer.state.top=!1,i.items[m].tokens=this.lexer.blockTokens(i.items[m].text,[]),!i.loose){const g=i.items[m].tokens.filter(y=>y.type==="space"),d=g.length>0&&g.some(y=>/\n.*\n/.test(y.raw));i.loose=d}if(i.loose)for(let m=0;m<i.items.length;m++)i.items[m].loose=!0;return i}}html(t){const n=this.rules.block.html.exec(t);if(n)return{type:"html",block:!0,raw:n[0],pre:n[1]==="pre"||n[1]==="script"||n[1]==="style",text:n[0]}}def(t){const n=this.rules.block.def.exec(t);if(n){const r=n[1].toLowerCase().replace(/\s+/g," "),l=n[2]?n[2].replace(/^<(.*)>$/,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",i=n[3]?n[3].substring(1,n[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):n[3];return{type:"def",tag:r,raw:n[0],href:l,title:i}}}table(t){const n=this.rules.block.table.exec(t);if(!n||!/[:|]/.test(n[2]))return;const r=oc(n[1]),l=n[2].replace(/^\||\| *$/g,"").split("|"),i=n[3]&&n[3].trim()?n[3].replace(/\n[ \t]*$/,"").split(`
`):[],o={type:"table",raw:n[0],header:[],align:[],rows:[]};if(r.length===l.length){for(const s of l)/^ *-+: *$/.test(s)?o.align.push("right"):/^ *:-+: *$/.test(s)?o.align.push("center"):/^ *:-+ *$/.test(s)?o.align.push("left"):o.align.push(null);for(const s of r)o.header.push({text:s,tokens:this.lexer.inline(s)});for(const s of i)o.rows.push(oc(s,o.header.length).map(u=>({text:u,tokens:this.lexer.inline(u)})));return o}}lheading(t){const n=this.rules.block.lheading.exec(t);if(n)return{type:"heading",raw:n[0],depth:n[2].charAt(0)==="="?1:2,text:n[1],tokens:this.lexer.inline(n[1])}}paragraph(t){const n=this.rules.block.paragraph.exec(t);if(n){const r=n[1].charAt(n[1].length-1)===`
`?n[1].slice(0,-1):n[1];return{type:"paragraph",raw:n[0],text:r,tokens:this.lexer.inline(r)}}}text(t){const n=this.rules.block.text.exec(t);if(n)return{type:"text",raw:n[0],text:n[0],tokens:this.lexer.inline(n[0])}}escape(t){const n=this.rules.inline.escape.exec(t);if(n)return{type:"escape",raw:n[0],text:$e(n[1])}}tag(t){const n=this.rules.inline.tag.exec(t);if(n)return!this.lexer.state.inLink&&/^<a /i.test(n[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&/^<\/a>/i.test(n[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&/^<(pre|code|kbd|script)(\s|>)/i.test(n[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&/^<\/(pre|code|kbd|script)(\s|>)/i.test(n[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:n[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:n[0]}}link(t){const n=this.rules.inline.link.exec(t);if(n){const r=n[2].trim();if(!this.options.pedantic&&/^</.test(r)){if(!/>$/.test(r))return;const o=$l(r.slice(0,-1),"\\");if((r.length-o.length)%2===0)return}else{const o=Um(n[2],"()");if(o>-1){const u=(n[0].indexOf("!")===0?5:4)+n[1].length+o;n[2]=n[2].substring(0,o),n[0]=n[0].substring(0,u).trim(),n[3]=""}}let l=n[2],i="";if(this.options.pedantic){const o=/^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(l);o&&(l=o[1],i=o[3])}else i=n[3]?n[3].slice(1,-1):"";return l=l.trim(),/^</.test(l)&&(this.options.pedantic&&!/>$/.test(r)?l=l.slice(1):l=l.slice(1,-1)),sc(n,{href:l&&l.replace(this.rules.inline.anyPunctuation,"$1"),title:i&&i.replace(this.rules.inline.anyPunctuation,"$1")},n[0],this.lexer)}}reflink(t,n){let r;if((r=this.rules.inline.reflink.exec(t))||(r=this.rules.inline.nolink.exec(t))){const l=(r[2]||r[1]).replace(/\s+/g," "),i=n[l.toLowerCase()];if(!i){const o=r[0].charAt(0);return{type:"text",raw:o,text:o}}return sc(r,i,r[0],this.lexer)}}emStrong(t,n,r=""){let l=this.rules.inline.emStrongLDelim.exec(t);if(!l||l[3]&&r.match(/[\p{L}\p{N}]/u))return;if(!(l[1]||l[2]||"")||!r||this.rules.inline.punctuation.exec(r)){const o=[...l[0]].length-1;let s,u,a=o,m=0;const g=l[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(g.lastIndex=0,n=n.slice(-1*t.length+o);(l=g.exec(n))!=null;){if(s=l[1]||l[2]||l[3]||l[4]||l[5]||l[6],!s)continue;if(u=[...s].length,l[3]||l[4]){a+=u;continue}else if((l[5]||l[6])&&o%3&&!((o+u)%3)){m+=u;continue}if(a-=u,a>0)continue;u=Math.min(u,u+a+m);const d=[...l[0]][0].length,y=t.slice(0,o+l.index+d+u);if(Math.min(o,u)%2){const k=y.slice(1,-1);return{type:"em",raw:y,text:k,tokens:this.lexer.inlineTokens(k)}}const w=y.slice(2,-2);return{type:"strong",raw:y,text:w,tokens:this.lexer.inlineTokens(w)}}}}codespan(t){const n=this.rules.inline.code.exec(t);if(n){let r=n[2].replace(/\n/g," ");const l=/[^ ]/.test(r),i=/^ /.test(r)&&/ $/.test(r);return l&&i&&(r=r.substring(1,r.length-1)),r=$e(r,!0),{type:"codespan",raw:n[0],text:r}}}br(t){const n=this.rules.inline.br.exec(t);if(n)return{type:"br",raw:n[0]}}del(t){const n=this.rules.inline.del.exec(t);if(n)return{type:"del",raw:n[0],text:n[2],tokens:this.lexer.inlineTokens(n[2])}}autolink(t){const n=this.rules.inline.autolink.exec(t);if(n){let r,l;return n[2]==="@"?(r=$e(n[1]),l="mailto:"+r):(r=$e(n[1]),l=r),{type:"link",raw:n[0],text:r,href:l,tokens:[{type:"text",raw:r,text:r}]}}}url(t){var r;let n;if(n=this.rules.inline.url.exec(t)){let l,i;if(n[2]==="@")l=$e(n[0]),i="mailto:"+l;else{let o;do o=n[0],n[0]=((r=this.rules.inline._backpedal.exec(n[0]))==null?void 0:r[0])??"";while(o!==n[0]);l=$e(n[0]),n[1]==="www."?i="http://"+n[0]:i=n[0]}return{type:"link",raw:n[0],text:l,href:i,tokens:[{type:"text",raw:l,text:l}]}}}inlineText(t){const n=this.rules.inline.text.exec(t);if(n){let r;return this.lexer.state.inRawBlock?r=n[0]:r=$e(n[0]),{type:"text",raw:n[0],text:r}}}}const Hm=/^(?: *(?:\n|$))+/,Bm=/^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/,Wm=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,gl=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,Vm=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,Dd=/(?:[*+-]|\d{1,9}[.)])/,Md=Q(/^(?!bull |blockCode|fences|blockquote|heading|html)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html))+?)\n {0,3}(=+|-+) *(?:\n+|$)/).replace(/bull/g,Dd).replace(/blockCode/g,/ {4}/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).getRegex(),Cu=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,Qm=/^[^\n]+/,Nu=/(?!\s*\])(?:\\.|[^\[\]\\])+/,Gm=Q(/^ {0,3}\[(label)\]: *(?:\n *)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/).replace("label",Nu).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Ym=Q(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,Dd).getRegex(),Gi="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|diLine truncated
]`).replace("lheading",Md).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},Fd=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,Jm=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,Ud=/^( {2,}|\\)\n(?!\s*$)/,bm=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\<!\[`*_]|\b_|$)|[^ ](?= {2,}\n)))/,yl="\\p{P}\\p{S}",eg=Q(/^((?![*_])[\spunctuation])/,"u").replace(/punctuation/g,yl).getRegex(),tg=/\[[^[\]]*?\]\([^\(\)]*?\)|`[^`]*?`|<[^<>]*?>/g,ng=Q(/^(?:\*+(?:((?!\*)[punct])|[^\s*]))|^_+(?:((?!_)[punct])|([^\s_]))/,"u").replace(/punct/g,yl).getRegex(),rg=Q("^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)[punct](\\*+)(?=[\\s]|$)|[^punct\\s](\\*+)(?!\\*)(?=[punct\\s]|$)|(?!\\*)[punct\\s](\\*+)(?=[^punct\\s])|[\\s](\\*+)(?!\\*)(?=[punct])|(?!\\*)[punct](\\*+)(?!\\*)(?=[punct])|[^punct\\s](\\*+)(?=[^punct\\s])","gu").replace(/punct/g,yl).getRegex(),lg=Q("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)[punct](_+)(?=[\\s]|$)|[^punct\\s](_+)(?!_)(?=[punct\\s]|$)|(?!_)[punct\\s](_+)(?=[^punct\\s])|[\\s](_+)(?!_)(?=[punct])|(?!_)[punct](_+)(?!_)(?=[punct])","gu").replace(/punct/g,yl).getRegex(),ig=Q(/\\([punct])/,"gu").replace(/punct/g,yl).getRegex(),og=Q(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),sg=Q(Ru).replace("(?:-->|$)","-->").getRegex(),ug=Q("^comment|^</[a-zA-Z][\\w:-]*\\s*>|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^<![a-zA-Z]+\\s[\\s\\S]*?>|^<!\\[CDATA\\[[\\s\\S]*?\\]\\]>").replace("comment",sg).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),Ai=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,ag=Q(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/).replace("label",Ai).replace("href",/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),$d=Q(/^!?\[(label)\]\[(ref)\]/).replace("label",Ai).replace("ref",Nu).getRegex(),Hd=Q(/^!?\[(ref)\](?:\[\])?/).replace("ref",Nu).getRegex(),cg=Q("reflink|nolink(?!\\()","g").replace("reflink",$d).replace("nolink",Hd).getRegex(),Lu={_backpedal:Qr,anyPunctuation:ig,autolink:og,blockSkip:tg,br:Ud,code:Jm,del:Qr,emStrongLDelim:ng,emStrongRDelimAst:rg,emStrongRDelimUnd:lg,escape:Fd,link:ag,nolink:Hd,punctuation:eg,reflink:$d,reflinkSearch:cg,tag:ug,text:bm,url:Qr},fg={...Lu,link:Q(/^!?\[(label)\]\((.*?)\)/).replace("label",Ai).getRegex(),reflink:Q(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",Ai).getRegex()},Ns={...Lu,escape:Q(Fd).replace("])","~|])").getRegex(),url:Q(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])([\s\S]*?[^\s~])\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\<!\[`*~_]|\b_|https?:\/\/|ftp:\/\/|www\.|$)|[^ ](?= {2,}\n)|[^a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-](?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)))/},dg={...Ns,br:Q(Ud).replace("{2,}","*").getRegex(),text:Q(Ns.text).replace("\\b_","\\b_| {2,}\\n").replace(/\{2,\}/g,"*").getRegex()},Hl={normal:Au,gfm:Zm,pedantic:qm},Cr={normal:Lu,gfm:Ns,breaks:dg,pedantic:fg};class kt{constructor(t){G(this,"tokens");G(this,"options");G(this,"state");G(this,"tokenizer");G(this,"inlineQueue");this.tokens=[],this.tokens.links=Object.create(null),this.options=t||Cn,this.options.tokenizer=this.options.tokenizer||new Ri,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};const n={block:Hl.normal,inline:Cr.normal};this.options.pedantic?(n.block=Hl.pedantic,n.inline=Cr.pedantic):this.options.gfm&&(n.block=Hl.gfm,this.options.breaks?n.inline=Cr.breaks:n.inline=Cr.gfm),this.tokenizer.rules=n}static get rules(){return{block:Hl,inline:Cr}}static lex(t,n){return new kt(n).lex(t)}static lexInline(t,n){return new kt(n).inlineTokens(t)}lex(t){t=t.replace(/\r\n|\r/g,`
`),this.blockTokens(t,this.tokens);for(let n=0;n<this.inlineQueue.length;n++){const r=this.inlineQueue[n];this.inlineTokens(r.src,r.tokens)}return this.inlineQueue=[],this.tokens}blockTokens(t,n=[]){this.options.pedantic?t=t.replace(/\t/g," ").replace(/^ +$/gm,""):t=t.replace(/^( *)(\t+)/gm,(s,u,a)=>u+" ".repeat(a.length));let r,l,i,o;for(;t;)if(!(this.options.extensions&&this.options.extensions.block&&this.options.extensions.block.some(s=>(r=s.call({lexer:this},t,n))?(t=t.substring(r.raw.length),n.push(r),!0):!1))){if(r=this.tokenizer.space(t)){t=t.substring(r.raw.length),r.raw.length===1&&n.length>0?n[n.length-1].raw+=`
`:n.push(r);continue}if(r=this.tokenizer.code(t)){t=t.substring(r.raw.length),l=n[n.length-1],l&&(l.type==="paragraph"||l.type==="text")?(l.raw+=`
`+r.raw,l.text+=`
`+r.text,this.inlineQueue[this.inlineQueue.length-1].src=l.text):n.push(r);continue}if(r=this.tokenizer.fences(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.heading(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.hr(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.blockquote(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.list(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.html(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.def(t)){t=t.substring(r.raw.length),l=n[n.length-1],l&&(l.type==="paragraph"||l.type==="text")?(l.raw+=`
`+r.raw,l.text+=`
`+r.raw,this.inlineQueue[this.inlineQueue.length-1].src=l.text):this.tokens.links[r.tag]||(this.tokens.links[r.tag]={href:r.href,title:r.title});continue}if(r=this.tokenizer.table(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.lheading(t)){t=t.substring(r.raw.length),n.push(r);continue}if(i=t,this.options.extensions&&this.options.extensions.startBlock){let s=1/0;const u=t.slice(1);let a;this.options.extensions.startBlock.forEach(m=>{a=m.call({lexer:this},u),typeof a=="number"&&a>=0&&(s=Math.min(s,a))}),s<1/0&&s>=0&&(i=t.substring(0,s+1))}if(this.state.top&&(r=this.tokenizer.paragraph(i))){l=n[n.length-1],o&&l.type==="paragraph"?(l.raw+=`
`+r.raw,l.text+=`
`+r.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=l.text):n.push(r),o=i.length!==t.length,t=t.substring(r.raw.length);continue}if(r=this.tokenizer.text(t)){t=t.substring(r.raw.length),l=n[n.length-1],l&&l.type==="text"?(l.raw+=`
`+r.raw,l.text+=`
`+r.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=l.text):n.push(r);continue}if(t){const s="Infinite loop on byte: "+t.charCodeAt(0);if(this.options.silent){console.error(s);break}else throw new Error(s)}}return this.state.top=!0,n}inline(t,n=[]){return this.inlineQueue.push({src:t,tokens:n}),n}inlineTokens(t,n=[]){let r,l,i,o=t,s,u,a;if(this.tokens.links){const m=Object.keys(this.tokens.links);if(m.length>0)for(;(s=this.tokenizer.rules.inline.reflinkSearch.exec(o))!=null;)m.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(o=o.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+o.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(s=this.tokenizer.rules.inline.blockSkip.exec(o))!=null;)o=o.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+o.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;(s=this.tokenizer.rules.inline.anyPunctuation.exec(o))!=null;)o=o.slice(0,s.index)+"++"+o.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;t;)if(u||(a=""),u=!1,!(this.options.extensions&&this.options.extensions.inline&&this.options.extensions.inline.some(m=>(r=m.call({lexer:this},t,n))?(t=t.substring(r.raw.length),n.push(r),!0):!1))){if(r=this.tokenizer.escape(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.tag(t)){t=t.substring(r.raw.length),l=n[n.length-1],l&&r.type==="text"&&l.type==="text"?(l.raw+=r.raw,l.text+=r.text):n.push(r);continue}if(r=this.tokenizer.link(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.reflink(t,this.tokens.links)){t=t.substring(r.raw.length),l=n[n.length-1],l&&r.type==="text"&&l.type==="text"?(l.raw+=r.raw,l.text+=r.text):n.push(r);continue}if(r=this.tokenizer.emStrong(t,o,a)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.codespan(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.br(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.del(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.autolink(t)){t=t.substring(r.raw.length),n.push(r);continue}if(!this.state.inLink&&(r=this.tokenizer.url(t))){t=t.substring(r.raw.length),n.push(r);continue}if(i=t,this.options.extensions&&this.options.extensions.startInline){let m=1/0;const g=t.slice(1);let d;this.options.extensions.startInline.forEach(y=>{d=y.call({lexer:this},g),typeof d=="number"&&d>=0&&(m=Math.min(m,d))}),m<1/0&&m>=0&&(i=t.substring(0,m+1))}if(r=this.tokenizer.inlineText(i)){t=t.substring(r.raw.length),r.raw.slice(-1)!=="_"&&(a=r.raw.slice(-1)),u=!0,l=n[n.length-1],l&&l.type==="text"?(l.raw+=r.raw,l.text+=r.text):n.push(r);continue}if(t){const m="Infinite loop on byte: "+t.charCodeAt(0);if(this.options.silent){console.error(m);break}else throw new Error(m)}}return n}}class cl{constructor(t){G(this,"options");this.options=t||Cn}code(t,n,r){var i;const l=(i=(n||"").match(/^\S*/))==null?void 0:i[0];return t=t.replace(/\n$/,"")+`
`,l?'<pre><code class="language-'+$e(l)+'">'+(r?t:$e(t,!0))+`</code></pre>
`:"<pre><code>"+(r?t:$e(t,!0))+`</code></pre>
`}blockquote(t){return`<blockquote>
${t}</blockquote>
`}html(t,n){return t}heading(t,n,r){return`<h${n}>${t}</h${n}>
`}hr(){return`<hr>
`}list(t,n,r){const l=n?"ol":"ul",i=n&&r!==1?' start="'+r+'"':"";return"<"+l+i+`>
`+t+"</"+l+`>
`}listitem(t,n,r){return`<li>${t}</li>
`}checkbox(t){return"<input "+(t?'checked="" ':"")+'disabled="" type="checkbox">'}paragraph(t){return`<p>${t}</p>
`}table(t,n){return n&&(n=`<tbody>${n}</tbody>`),`<table>
<thead>
`+t+`</thead>
`+n+`</table>
`}tablerow(t){return`<tr>
${t}</tr>
`}tablecell(t,n){const r=n.header?"th":"td";return(n.align?`<${r} align="${n.align}">`:`<${r}>`)+t+`</${r}>
`}strong(t){return`<strong>${t}</strong>`}em(t){return`<em>${t}</em>`}codespan(t){return`<code>${t}</code>`}br(){return"<br>"}del(t){return`<del>${t}</del>`}link(t,n,r){const l=ic(t);if(l===null)return r;t=l;let i='<a href="'+t+'"';return n&&(i+=' title="'+n+'"'),i+=">"+r+"</a>",i}image(t,n,r){const l=ic(t);if(l===null)return r;t=l;let i=`<img src="${t}" alt="${r}"`;return n&&(i+=` title="${n}"`),i+=">",i}text(t){return t}}class Pu{strong(t){return t}em(t){return t}codespan(t){return t}del(t){return t}html(t){return t}text(t){return t}link(t,n,r){return""+r}image(t,n,r){return""+r}br(){return""}}class wt{constructor(t){G(this,"options");G(this,"renderer");G(this,"textRenderer");this.options=t||Cn,this.options.renderer=this.options.renderer||new cl,this.renderer=this.options.renderer,this.renderer.options=this.options,this.textRenderer=new Pu}static parse(t,n){return new wt(n).parse(t)}static parseInline(t,n){return new wt(n).parseInline(t)}parse(t,n=!0){let r="";for(let l=0;l<t.length;l++){const i=t[l];if(this.options.extensions&&this.options.extensions.renderers&&this.options.extensions.renderers[i.type]){const o=i,s=this.options.extensions.renderers[o.type].call({parser:this},o);if(s!==!1||!["space","hr","heading","code","table","blockquote","list","html","paragraph","text"].includes(o.type)){r+=s||"";continue}}switch(i.type){case"space":continue;case"hr":{r+=this.renderer.hr();continue}case"heading":{const o=i;r+=this.renderer.heading(this.parseInline(o.tokens),o.depth,jm(this.parseInline(o.tokens,this.textRenderer)));continue}case"code":{const o=i;r+=this.renderer.code(o.text,o.lang,!!o.escaped);continue}case"table":{const o=i;let s="",u="";for(let m=0;m<o.header.length;m++)u+=this.renderer.tablecell(this.parseInline(o.header[m].tokens),{header:!0,align:o.align[m]});s+=this.renderer.tablerow(u);let a="";for(let m=0;m<o.rows.length;m++){const g=o.rows[m];u="";for(let d=0;d<g.length;d++)u+=this.renderer.tablecell(this.parseInline(g[d].tokens),{header:!1,align:o.align[d]});a+=this.renderer.tablerow(u)}r+=this.renderer.table(s,a);continue}case"blockquote":{const o=i,s=this.parse(o.tokens);r+=this.renderer.blockquote(s);continue}case"list":{const o=i,s=o.ordered,u=o.start,a=o.loose;let m="";for(let g=0;g<o.items.length;g++){const d=o.items[g],y=d.checked,w=d.task;let k="";if(d.task){const O=this.renderer.checkbox(!!y);a?d.tokens.length>0&&d.tokens[0].type==="paragraph"?(d.tokens[0].text=O+" "+d.tokens[0].text,d.tokens[0].tokens&&d.tokens[0].tokens.length>0&&d.tokens[0].tokens[0].type==="text"&&(d.tokens[0].tokens[0].text=O+" "+d.tokens[0].tokens[0].text)):d.tokens.unshift({type:"text",text:O+" "}):k+=O+" "}k+=this.parse(d.tokens,a),m+=this.renderer.listitem(k,w,!!y)}r+=this.renderer.list(m,s,u);continue}case"html":{const o=i;r+=this.renderer.html(o.text,o.block);continue}case"paragraph":{const o=i;r+=this.renderer.paragraph(this.parseInline(o.tokens));continue}case"text":{let o=i,s=o.tokens?this.parseInline(o.tokens):o.text;for(;l+1<t.length&&t[l+1].type==="text";)o=t[++l],s+=`
`+(o.tokens?this.parseInline(o.tokens):o.text);r+=n?this.renderer.paragraph(s):s;continue}default:{const o='Token with "'+i.type+'" type was not found.';if(this.options.silent)return console.error(o),"";throw new Error(o)}}}return r}parseInline(t,n){n=n||this.renderer;let r="";for(let l=0;l<t.length;l++){const i=t[l];if(this.options.extensions&&this.options.extensions.renderers&&this.options.extensions.renderers[i.type]){const o=this.options.extensions.renderers[i.type].call({parser:this},i);if(o!==!1||!["escape","html","link","image","strong","em","codespan","br","del","text"].includes(i.type)){r+=o||"";continue}}switch(i.type){case"escape":{const o=i;r+=n.text(o.text);break}case"html":{const o=i;r+=n.html(o.text);break}case"link":{const o=i;r+=n.link(o.href,o.title,this.parseInline(o.tokens,n));break}case"image":{const o=i;r+=n.image(o.href,o.title,o.text);break}case"strong":{const o=i;r+=n.strong(this.parseInline(o.tokens,n));break}case"em":{const o=i;r+=n.em(this.parseInline(o.tokens,n));break}case"codespan":{const o=i;r+=n.codespan(o.text);break}case"br":{r+=n.br();break}case"del":{const o=i;r+=n.del(this.parseInline(o.tokens,n));break}case"text":{const o=i;r+=n.text(o.text);break}default:{const o='Token with "'+i.type+'" type was not found.';if(this.options.silent)return console.error(o),"";throw new Error(o)}}}return r}}class Gr{constructor(t){G(this,"options");this.options=t||Cn}preprocess(t){return t}postprocess(t){return t}processAllTokens(t){return t}}G(Gr,"passThroughHooks",new Set(["preprocess","postprocess","processAllTokens"]));var xn,Rs,Bd;class pg{constructor(...t){Xu(this,xn);G(this,"defaults",Tu());G(this,"options",this.setOptions);G(this,"parse",El(this,xn,Rs).call(this,kt.lex,wt.parse));G(this,"parseInline",El(this,xn,Rs).call(this,kt.lexInline,wt.parseInline));G(this,"Parser",wt);G(this,"Renderer",cl);G(this,"TextRenderer",Pu);G(this,"Lexer",kt);G(this,"Tokenizer",Ri);G(this,"Hooks",Gr);this.use(...t)}walkTokens(t,n){var l,i;let r=[];for(const o of t)switch(r=r.concat(n.call(this,o)),o.type){case"table":{const s=o;for(const u of s.header)r=r.concat(this.walkTokens(u.tokens,n));for(const u of s.rows)for(const a of u)r=r.concat(this.walkTokens(a.tokens,n));break}case"list":{const s=o;r=r.concat(this.walkTokens(s.items,n));break}default:{const s=o;(i=(l=this.defaults.extensions)==null?void 0:l.childTokens)!=null&&i[s.type]?this.defaults.extensions.childTokens[s.type].forEach(u=>{const a=s[u].flat(1/0);r=r.concat(this.walkTokens(a,n))}):s.tokens&&(r=r.concat(this.walkTokens(s.tokens,n)))}}return r}use(...t){const n=this.defaults.extensions||{renderers:{},childTokens:{}};return t.forEach(r=>{const l={...r};if(l.async=this.defaults.async||l.async||!1,r.extensions&&(r.extensions.forEach(i=>{if(!i.name)throw new Error("extension name required");if("renderer"in i){const o=n.renderers[i.name];o?n.renderers[i.name]=function(...s){let u=i.renderer.apply(this,s);return u===!1&&(u=o.apply(this,s)),u}:n.renderers[i.name]=i.renderer}if("tokenizer"in i){if(!i.level||i.level!=="block"&&i.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");const o=n[i.level];o?o.unshift(i.tokenizer):n[i.level]=[i.tokenizer],i.start&&(i.level==="block"?n.startBlock?n.startBlock.push(i.start):n.startBlock=[i.start]:i.level==="inline"&&(n.startInline?n.startInline.push(i.start):n.startInline=[i.start]))}"childTokens"in i&&i.childTokens&&(n.childTokens[i.name]=i.childTokens)}),l.extensions=n),r.renderer){const i=this.defaults.renderer||new cl(this.defaults);for(const o in r.renderer){if(!(o in i))throw new Error(`renderer '${o}' does not exist`);if(o==="options")continue;const s=o,u=r.renderer[s],a=i[s];i[s]=(...m)=>{let g=u.apply(i,m);return g===!1&&(g=a.apply(i,m)),g||""}}l.renderer=i}if(r.tokenizer){const i=this.defaults.tokenizer||new Ri(this.defaults);for(const o in r.tokenizer){if(!(o in i))throw new Error(`tokenizer '${o}' does not exist`);if(["options","rules","lexer"].includes(o))continue;const s=o,u=r.tokenizer[s],a=i[s];i[s]=(...m)=>{let g=u.apply(i,m);return g===!1&&(g=a.apply(i,m)),g}}l.tokenizer=i}if(r.hooks){const i=this.defaults.hooks||new Gr;for(const o in r.hooks){if(!(o in i))throw new Error(`hook '${o}' does not exist`);if(o==="options")continue;const s=o,u=r.hooks[s],a=i[s];Gr.passThroughHooks.has(o)?i[s]=m=>{if(this.defaults.async)return Promise.resolve(u.call(i,m)).then(d=>a.call(i,d));const g=u.call(i,m);return a.call(i,g)}:i[s]=(...m)=>{let g=u.apply(i,m);return g===!1&&(g=a.apply(i,m)),g}}l.hooks=i}if(r.walkTokens){const i=this.defaults.walkTokens,o=r.walkTokens;l.walkTokens=function(s){let u=[];return u.push(o.call(this,s)),i&&(u=u.concat(i.call(this,s))),u}}this.defaults={...this.defaults,...l}}),this}setOptions(t){return this.defaults={...this.defaults,...t},this}lexer(t,n){return kt.lex(t,n??this.defaults)}parser(t,n){return wt.parse(t,n??this.defaults)}}xn=new WeakSet,Rs=function(t,n){return(r,l)=>{const i={...l},o={...this.defaults,...i};this.defaults.async===!0&&i Line truncated
Please report this to https://github.com/markedjs/marked.`,t){const l="<p>An error occurred:</p><pre>"+$e(r.message+"",!0)+"</pre>";return n?Promise.resolve(l):l}if(n)return Promise.reject(r);throw r}};const En=new pg;function H(e,t){return En.parse(e,t)}H.options=H.setOptions=function(e){return En.setOptions(e),H.defaults=En.defaults,Od(H.defaults),H};H.getDefaults=Tu;H.defaults=Cn;H.use=function(...e){return En.use(...e),H.defaults=En.defaults,Od(H.defaults),H};H.walkTokens=function(e,t){return En.walkTokens(e,t)};H.parseInline=En.parseInline;H.Parser=wt;H.parser=wt.parse;H.Renderer=cl;H.TextRenderer=Pu;H.Lexer=kt;H.lexer=kt.lex;H.Tokenizer=Ri;H.Hooks=Gr;H.parse=H;H.options;H.setOptions;H.use;H.walkTokens;H.parseInline;wt.parse;kt.lex;/*! @license DOMPurify 3.3.3 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.3.3/LICENSE */const{entries:Wd,setPrototypeOf:ac,isFrozen:hg,getPrototypeOf:mg,getOwnPropertyDescriptor:gg}=Object;let{freeze:Ne,seal:nt,create:ni}=Object,{apply:As,construct:Ls}=typeof Reflect<"u"&&Reflect;Ne||(Ne=function(t){return t});nt||(nt=function(t){return t});As||(As=function(t,n){for(var r=arguments.length,l=new Array(r>2?r-2:0),i=2;i<r;i++)l[i-2]=arguments[i];return t.apply(n,l)});Ls||(Ls=function(t){for(var n=arguments.length,r=new Array(n>1?n-1:0),l=1;l<n;l++)r[l-1]=arguments[l];return new t(...r)});const Bl=Re(Array.prototype.forEach),yg=Re(Array.prototype.lastIndexOf),cc=Re(Array.prototype.pop),Nr=Re(Array.prototype.push),vg=Re(Array.prototype.splice),ri=Re(String.prototype.toLowerCase),No=Re(String.prototype.toString),Ro=Re(String.prototype.match),Rr=Re(String.prototype.replace),kg=Re(String.prototype.indexOf),wg=Re(String.prototype.trim),Fe=Re(Object.prototype.hasOwnProperty),_e=Re(RegExp.prototype.test),Ar=Sg(TypeError);function Re(e){return function(t){t instanceof RegExp&&(t.lastIndex=0);for(var n=arguments.length,r=new Array(n>1?n-1:0),l=1;l<n;l++)r[l-1]=arguments[l];return As(e,t,r)}}function Sg(e){return function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];return Ls(e,n)}}function F(e,t){let n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:ri;ac&&ac(e,null);let r=t.length;for(;r--;){let l=t[r];if(typeof l=="string"){const i=n(l);i!==l&&(hg(t)||(t[r]=i),l=i)}e[l]=!0}return e}function Eg(e){for(let t=0;t<e.length;t++)Fe(e,t)||(e[t]=null);return e}function gt(e){const t=ni(null);for(const[n,r]of Wd(e))Fe(e,n)&&(Array.isArray(r)?t[n]=Eg(r):r&&typeof r=="object"&&r.constructor===Object?t[n]=gt(r):t[n]=r);return t}function Lr(e,t){for(;e!==null;){const r=gg(e,t);if(r){if(r.get)return Re(r.get);if(typeof r.value=="function")return Re(r.value)}e=mg(e)}function n(){return null}return n}const fc=Ne(["a","abbr","acronym","address","area","article","aside","audio","b","bdi","bdo","big","blink","blockquote","body","br","button","canvas","caption","center","cite","code","col","colgroup","content","data","datalist","dd","decorator","del","details","dfn","dialog","dir","div","dl","dt","element","em","fieldset","figcaption","figure","font","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","img","input","ins","kbd","label","legend","li","main","map","mark","marquee","menu","menuitem","meter","nav","nobr","ol","optgroup","option","output","p","picture","pre","progress","q","rp","rt","ruby","s","samp","search","section","select","shadow","slot","small","source","spacer","span","strike","strong","style","sub","summary","sup","table","tbody","td","template","textarea","tfoot","th","thead","time","tr","track","tt","u","ul","var","video","wbr"]),Ao=Ne(["svg","a","altglyph","altglyphdef","altglyphitem","animatecolor","animatemotion","animatetransform","circle","clippath","defs","desc","ellipse","enterkeyhint","exportparts","filter","font","g","glyph","glyphref","hkern","image","inputmode","line","lineargradient","marker","mask","metadata","mpath","part","path","pattern","polygon","polyline","radialgradient","rect","stop","style","switch","symbol","text","textpath","title","tref","tspan","view","vkern"]),Lo=Ne(["feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feDistantLight","feDropShadow","feFlood","feFuncA","feFuncB","feFuncG","feFuncR","feGaussianBlur","feImage","feMerge","feMergeNode","feMorphology","feOffset","fePointLight","feSpecularLighting","feSpotLight","feTile","feTurbulence"]),xg=Ne(["animate","color-profile","cursor","discard","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","foreignobject","hatch","hatchpath","mesh","meshgradient","meshpatch","meshrow","missing-glyph","script","set","solidcolor","unknown","use"]),Po=Ne(["math","menclose","merror","mfenced","mfrac","mglyph","mi","mlabeledtr","mmultiscripts","mn","mo","mover","mpadded","mphantom","mroot","mrow","ms","mspace","msqrt","mstyle","msub","msup","msubsup","mta Line truncated
`+Pe),lt&&Bl([Ge,Ye,xt],Ct=>{Pe=Rr(Pe,Ct," ")}),v&&un?v.createHTML(Pe):Pe},t.setConfig=function(){let z=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};qi(z),dr=!0},t.clearConfig=function(){On=null,dr=!1},t.isValidAttribute=function(z,f,x){On||qi({});const P=ue(z),b=ue(f);return Bu(P,b,x)},t.addHook=function(z,f){typeof f=="function"&&Nr(B[z],f)},t.removeHook=function(z,f){if(f!==void 0){const x=yg(B[z],f);return x===-1?void 0:vg(B[z],x,1)[0]}return cc(B[z])},t.removeHooks=function(z){B[z]=[]},t.removeAllHooks=function(){B=gc()},t}var Dg=Gd();let Io=!1;async function Mg(){if(Io||window.hljs){Io=!0;return}const e=["javascript","typescript","python","rust","bash","json","sql","css","xml","go","cpp","c"],t="https://cdn.jsdelivr.net/npm/highlight.js@11/lib";await yc(`${t}/core.min.js`),await Promise.all(e.map(n=>yc(`${t}/languages/${n}.min.js`))),Io=!0}function yc(e){return new Promise((t,n)=>{if(document.querySelector(`script[src="${e}"]`)){t();return}const r=document.createElement("script");r.src=e,r.onload=()=>t(),r.onerror=n,document.head.appendChild(r)})}const Yd=new cl;Yd.code=function(e,t){var i;const n=t&&((i=window.hljs)!=null&&i.getLanguage(t))?t:"plaintext";let r=e;try{window.hljs&&(r=n!=="plaintext"?window.hljs.highlight(e,{language:n}).value:window.hljs.highlightAuto(e).value)}catch{}const l=e.replace(/&/g,"&amp;").replace(/'/g,"&#39;");return`<div class="code-block">
<div class="code-header">
<span class="code-lang">${n}</span>
<button class="copy-code-btn" data-code='${l}'>Copy</button>
</div>
<pre><code class="hljs language-${n}">${r}</code></pre>
</div>`};H.setOptions({breaks:!0,gfm:!0});H.use({renderer:Yd});function vc(e){const t=H.parse(e);return Dg.sanitize(t,{ADD_TAGS:["div","span","button","pre","code"],ADD_ATTR:["class","data-code"],FORCE_BODY:!1})}function jg({message:e,onDelete:t,onEditAndResend:n,onEditAssistant:r,renderMarkdown:l}){const[i,o]=M.useState(!1),[s,u]=M.useState(e.content),[a,m]=M.useState(!1),g=M.useRef(null),d=M.useRef(null);M.useEffect(()=>{if(!d.current)return;d.current.querySelectorAll(".copy-code-btn").forEach(h=>{h.onclick=()=>{const v=h.getAttribute("data-code")||"";navigator.clipboard.writeText(v).then(()=>{h.textContent="Copied!",h.style.color="var(--success)",setTimeout(()=>{h.textContent="Copy",h.style.color=""},2e3)})}})});const y=()=>{const c=g.current;c&&(c.style.height="auto",c.style.height=Math.min(c.scrollHeight,300)+"px")},w=()=>{u(e.content),o(!0),setTimeout(()=>{var c;(c=g.current)==null||c.focus(),y()},0)},k=()=>o(!1),O=c=>{const h=s.trim();h&&(o(!1),c&&n?n(e,h):r&&r(e.id,h))},p=()=>{navigator.clipboard.writeText(e.content).then(()=>{m(!0),setTimeout(()=>m(!1),2e3)})};return _.jsx("div",{className:`msg-wrapper ${e.role}`,children:i?_.jsxs("div",{className:"msg-edit-container",children:[_.jsx("textarea",{ref:g,className:"msg-edit-textarea",value:s,onChange:c=>{u(c.target.value),y()},onKeyDown:c=>{c.key==="Escape"&&k(),c.key==="Enter"&&c.ctrlKey&&O(!1)}}),_.jsxs("div",{className:"msg-edit-actions",children:[n&&_.jsx("button",{className:"btn-primary",onClick:()=>O(!0),children:"Save & Resend"}),_.jsx("button",{className:"btn-secondary",onClick:()=>O(!1),children:"Save only"}),_.jsx("button",{className:"btn-ghost",onClick:k,children:"Cancel"})]})]}):_.jsxs(_.Fragment,{children:[e.role==="assistant"?_.jsx("div",{ref:d,className:"msg-bubble",dangerouslySetInnerHTML:{__html:l(e.content)}}):_.jsx("div",{className:"msg-bubble",children:e.content}),_.jsxs("div",{className:"msg-actions",children:[_.jsx("button",{className:`action-btn ${a?"copied":""}`,onClick:p,children:a?"✓ Copied":"⎘ Copy"}),_.jsxs("button",{className:"action-btn",onClick:w,children:["✎ Edit",n?" & Resend":""]}),_.jsx("button",{className:"action-btn danger",onClick:()=>t(e.id),children:"✕ Delete"})]})]})})}const Fg=20;function Ug({db:e,chatId:t,server:n,apiKey:r,model:l,onChatCreated:i,onChatUpdated:o}){const[s,u]=M.useState([]),[a,m]=M.useState(""),[g,d]=M.useState(!1),[y,w]=M.useState(null),[k,O]=M.useState(null),[p,c]=M.useState(!0),[h,v]=M.useState(!0),T=M.useRef(null),N=M.useRef(null),A=M.useRef(null);M.useEffect(()=>{Mg()},[]);const L=M.useCallback(()=>{if(!t){u([]);return}const E=e.query("SELECT * FROM messages WHERE chat_id = ? ORDER BY created_at ASC",[t]);u(E)},[e,t]);M.useEffect(()=>{L()},[L]),M.useEffect(()=>{var E;(E=T.current)==null||E.scrollIntoView({behavior:"smooth"})},[s,y]);const X=()=>{const E=N.current;E&&(E.style.height="auto",E.style.height=Math.min(E.scrollHeight,200)+"px")},j=M.useCallback(()=>{if(t)return t;e.run("INSERT INTO chats (title) VALUES ('New Chat')");const I=e.get("SELECT last_insert_rowid() as id").id;return i(I),I},[t,e,i]),B=M.useCallback((E,I)=>{const D=I.slice(0,50)+(I.length>50?"…":"");e.run("UPDATE chats SET title = ?, updated_at = strftime('%s','now') WHERE id = ?",[D,E]),o()},[e,o]),Ge=M.useCallback(E=>{const I=h?Fg:1;return e.query("SELECT role, content FROM messages WHERE chat_id = ? ORDER BY created_at DESC LIMIT ?",[E,I]).reverse()},[e,h]),Ye=M.useCallback(async(E,I)=>{var V,ye,Xe,rt,cr,Nn,fr;if(!E.trim()||g)return;if(!l){alert("No model selected");return}d(!0),w(null);const D=I??j();e.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)",[D,E]);const K=e.get("SELECT COUNT(*) as c FROM messages WHERE chat_id = ?",[D]);(K==null?void 0:K.c)===1&&B(D,E),L();const J=Ge(D),je={"Content-Type":"application/json"};r&&(je.Authorization=`Bearer ${r}`),A.current=new AbortController;try{const Ke=await fetch(`${n}/v1/chat/completions`,{method:"POST",headers:je,signal:A.current.signal,body:JSON.stringify({model:l,messages:J,stream:p,stream_options:p?{include_usage:!0}:void 0,max_tokens:2048})});if(!Ke.ok){const ve=await Ke.json().catch(()=>({}));throw new Error(((V=ve==null?void 0:ve.error)==null?void 0:V.message)||`HTTP ${Ke.status}`)}let lt="",it=null;if(p){const ve=Ke.body.getReader(),dr=new TextDecoder;let sn="";for(;;){const{done:jt,value:Rn}=await ve.read();if(jt)break;sn+=dr.decode(Rn,{stream:!0});const un=sn.split(`
`);sn=un.pop();for(const pr of un){if(!pr.startsWith("data: "))continue;const hr=pr.slice(6).trim();if(hr!=="[DONE]")try{const mr=JSON.parse(hr),An=(rt=(Xe=(ye=mr.choices)==null?void 0:ye[0])==null?void 0:Xe.delta)==null?void 0:rt.content;An&&(lt+=An,w(lt)),mr.usage&&(it=mr.usage)}catch{}}}}else{const ve=await Ke.json();lt=((fr=(Nn=(cr=ve.choices)==null?void 0:cr[0])==null?void 0:Nn.message)==null?void 0:fr.content)||"",it=ve.usage}e.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)",[D,lt]),e.run("UPDATE chats SET updated_at = strftime('%s','now') WHERE id = ?",[D]),it!=null&&it.tokens_per_second&&O(it.tokens_per_second),o()}catch(Ke){Ke instanceof Error&&Ke.name!=="AbortError"&&e.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)",[D,`⚠️ Error: ${Ke.message}`])}finally{d(!1),w(null),L(),A.current=null}},[g,l,n,r,p,e,j,B,Ge,L,o]),xt=()=>{const E=a.trim();E&&(m(""),N.current&&(N.current.style.height="auto"),Ye(E))},ar=()=>{var E;(E=A.current)==null||E.abort()},ln=E=>{e.run("DELETE FROM messages WHERE id = ?",[E]),L()},on=(E,I)=>{e.run("UPDATE messages SET content = ? WHERE id = ?",[I,E.id]),e.run("DELETE FROM messages WHERE chat_id = ? AND id > ?",[E.chat_id,E.id]),L(),Ye(I,E.chat_id)},C=(E,I)=>{e.run("UPDATE messages SET content = ? WHERE id = ?",[I,E]),L()};return _.jsxs("div",{className:"chat-view",children:[_.jsxs("div",{className:"chat-topbar",children:[_.jsx("div",{className:"chat-topbar-left",children:k!==null&&_.jsxs("span",{className:"tps-badge",children:[k.toFixed(1)," tok/s"]})}),_.jsxs("div",{className:"chat-topbar-right",children:[_.jsxs("label",{className:"toggle-label",children:[_.jsx("input",{type:"checkbox",checked:p,onChange:E=>c(E.target.checked)}),"Streaming"]}),_.jsxs("label",{className:"toggle-label",children:[_.jsx("input",{type:"checkbox",checked:h,onChange:E=>v(E.target.checked)}),"Memory"]})]})]}),_.jsxs("div",{className:"messages-area",children:[!t&&s.length===0&&_.jsxs("div",{className:"empty-state",children:[_.jsx("div",{className:"empty-icon",children:"⚡"}),_.jsx("h2",{children:"ForgeLM Chat"}),_.jsx("p",{children:"Start a conversation. Supports full Markdown, code highlighting, and persistent history."})]}),s.map(E=>_.jsx(jg,{message:E,onDelete:ln,onEditAndResend:E.role==="user"?on:void 0,onEditAssistant:E.role==="assistant"?C:void 0,renderMarkdown:vc},E.id)),g&&y!==null&&_.jsx("div",{className:"msg-wrapper assistant",children:_.jsx("div",{className:"msg-bubble",dangerouslySetInnerHTML:{__html:vc(y)}})}),g&&y===null&&_.jsx("div",{className:"msg-wrapper assistant",children:_.jsxs("div",{className:"thinking",children:[_.jsx("span",{}),_.jsx("span",{}),_.jsx("span",{})]})}),_.jsx("div",{ref:T})]}),_.jsxs("div",{className:"input-area",children:[_.jsxs("div",{className:"input-row",children:[_.jsx("textarea",{ref:N,className:"input-textarea",value:a,onChange:E=>{m(E.target.value),X()},onKeyDown:E=>{E.key==="Enter"&&!E.shiftKey&&(E.preventDefault(),xt())},placeholder:"Message… (Enter to send, Shift+Enter for new line)",rows:1,disabled:g}),g?_.jsx("button",{className:"stop-btn",onClick:ar,children:"■ Stop"}):_.jsx("button",{className:"send-btn",onClick:xt,disabled:!a.trim(),children:"Send ↑"})]}),_.jsx("div",{className:"input-meta",children:_.jsxs("span",{className:"char-count",children:[a.length," chars"]})})]})]})}class $g{constructor(t){G(this,"inner");this.inner=t}run(t,n=[]){this.inner.run(t,n),this.persist()}query(t,n=[]){const r=this.inner.prepare(t);r.bind(n);const l=[];for(;r.step();)l.push(r.getAsObject());return r.free(),l}get(t,n=[]){return this.query(t,n)[0]??null}persist(){try{const t=this.inner.export(),n=btoa(String.fromCharCode(...t));localStorage.setItem("forgelm_db_v2",n)}catch{}}}async function Hg(){window.initSqlJs||await new Promise((l,i)=>{const o=document.createElement("script");o.src="https://cdn.jsdelivr.net/npm/[email protected]/dist/sql-wasm.js",o.onload=()=>l(),o.onerror=i,document.head.appendChild(o)});const e=await window.initSqlJs({locateFile:l=>`https://cdn.jsdelivr.net/npm/[email protected]/dist/${l}`}),t=localStorage.getItem("forgelm_db_v2");let n;if(t){const l=Uint8Array.from(atob(t),i=>i.charCodeAt(0));n=new e.Database(l)}else n=new e.Database;const r=new $g(n);return r.run(`
CREATE TABLE IF NOT EXISTS chats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT 'New Chat',
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)
`),r.run(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)
`),r.run("CREATE INDEX IF NOT EXISTS idx_msg_chat ON messages(chat_id, created_at)"),r}function Bg(){const[e,t]=M.useState(null),[n,r]=M.useState(!1);return M.useEffect(()=>{Hg().then(l=>{t(l),r(!0)})},[]),{db:e,ready:n}}function Wg(e,t){const[n,r]=M.useState([]),[l,i]=M.useState(""),[o,s]=M.useState("loading"),u=M.useCallback(async()=>{s("loading");try{const a={};t&&(a.Authorization=`Bearer ${t}`);const d=(await(await fetch(`${e}/v1/models`,{headers:a})).json()).data||[];r(d),d.length>0&&!l&&i(d[0].id),s("ok")}catch{s("error")}},[e,t,l]);return M.useEffect(()=>{u();const a=setInterval(u,3e4);return()=>clearInterval(a)},[u]),{models:n,selectedModel:l,setSelectedModel:i,status:o,refresh:u}}function Vg(){const{db:e,ready:t}=Bg(),[n,r]=M.useState(null),[l,i]=M.useState([]),[o,s]=M.useState(()=>localStorage.getItem("forgelm_server")||"http://127.0.0.1:8080"),[u,a]=M.useState(()=>localStorage.getItem("forgelm_apikey")||""),{models:m,selectedModel:g,setSelectedModel:d,status:y,refresh:w}=Wg(o,u);M.useEffect(()=>{localStorage.setItem("forgelm_server",o)},[o]),M.useEffect(()=>{localStorage.setItem("forgelm_apikey",u)},[u]);const k=M.useCallback(()=>{if(!e)return;const c=e.query("SELECT * FROM chats ORDER BY updated_at DESC");i(c)},[e]);M.useEffect(()=>{k()},[k]);const O=M.useCallback(()=>{if(!e)return;e.run("INSERT INTO chats (title) VALUES ('New Chat')");const c=e.get("SELECT last_insert_rowid() as id");c&&(r(c.id),k())},[e,k]),p=M.useCallback(c=>{e&&(e.run("DELETE FROM chats WHERE id = ?",[c]),n===c&&r(null),k())},[e,n,k]);return t?_.jsxs("div",{className:"app-layout",children:[_.jsx(Om,{chats:l,currentChatId:n,onSelectChat:r,onNewChat:O,onDeleteChat:p,server:o,onServerChange:s,apiKey:u,onApiKeyChange:a,models:m,selectedModel:g,onModelChange:d,status:y,onRefreshModels:w}),_.jsx(Ug,{db:e,chatId:n,server:o,apiKey:u,model:g,onChatCreated:c=>{r(c),k()},onChatUpdated:k})]}):_.jsxs("div",{className:"app-loading",children:[_.jsx("div",{className:"loading-spinner"}),_.jsx("span",{children:"Initializing…"})]})}zo.createRoot(document.getElementById("root")).render(_.jsx(wp.StrictMode,{children:_.jsx(Vg,{})}));
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ForgeLM Chat</title>
<script type="module" crossorigin src="/assets/index-DyFHx2Ao.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ci29W9gt.css">
<script type="module" crossorigin src="/assets/index-FraKRbHA.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DGWirp_d.css">
</head>
<body>
<div id="root"></div>
+6 -1
View File
@@ -9,8 +9,13 @@ case `uname` in
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/baseline-browser-mapping/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/baseline-browser-mapping/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../baseline-browser-mapping/dist/cli.cjs" "$@"
else
else
exec node "$basedir/../baseline-browser-mapping/dist/cli.cjs" "$@"
fi
+10 -15
View File
@@ -1,17 +1,12 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
@SETLOCAL
@IF NOT DEFINED NODE_PATH (
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\baseline-browser-mapping\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\baseline-browser-mapping\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules;%NODE_PATH%"
)
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\baseline-browser-mapping\dist\cli.cjs" %*
) ELSE (
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\baseline-browser-mapping\dist\cli.cjs" %*
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\baseline-browser-mapping\dist\cli.cjs" %*
+13
View File
@@ -2,11 +2,23 @@
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
$pathsep=":"
$env_node_path=$env:NODE_PATH
$new_node_path="D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\baseline-browser-mapping\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
$pathsep=";"
} else {
$new_node_path="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/baseline-browser-mapping/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
}
if ([string]::IsNullOrEmpty($env_node_path)) {
$env:NODE_PATH=$new_node_path
} else {
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
@@ -25,4 +37,5 @@ if (Test-Path "$basedir/node$exe") {
}
$ret=$LASTEXITCODE
}
$env:NODE_PATH=$env_node_path
exit $ret
+6 -1
View File
@@ -9,8 +9,13 @@ case `uname` in
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/browserslist/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/browserslist/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../browserslist/cli.js" "$@"
else
else
exec node "$basedir/../browserslist/cli.js" "$@"
fi
+10 -15
View File
@@ -1,17 +1,12 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
@SETLOCAL
@IF NOT DEFINED NODE_PATH (
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\browserslist\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\browserslist\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules;%NODE_PATH%"
)
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\browserslist\cli.js" %*
) ELSE (
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\browserslist\cli.js" %*
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\browserslist\cli.js" %*
+13
View File
@@ -2,11 +2,23 @@
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
$pathsep=":"
$env_node_path=$env:NODE_PATH
$new_node_path="D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\browserslist\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
$pathsep=";"
} else {
$new_node_path="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/browserslist/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
}
if ([string]::IsNullOrEmpty($env_node_path)) {
$env:NODE_PATH=$new_node_path
} else {
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
@@ -25,4 +37,5 @@ if (Test-Path "$basedir/node$exe") {
}
$ret=$LASTEXITCODE
}
$env:NODE_PATH=$env_node_path
exit $ret
+6 -1
View File
@@ -9,8 +9,13 @@ case `uname` in
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/esbuild/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/esbuild/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../esbuild/bin/esbuild" "$@"
else
else
exec node "$basedir/../esbuild/bin/esbuild" "$@"
fi
+10 -15
View File
@@ -1,17 +1,12 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
@SETLOCAL
@IF NOT DEFINED NODE_PATH (
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\esbuild\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\esbuild\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules;%NODE_PATH%"
)
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\esbuild\bin\esbuild" %*
) ELSE (
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\esbuild\bin\esbuild" %*
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\esbuild\bin\esbuild" %*
+13
View File
@@ -2,11 +2,23 @@
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
$pathsep=":"
$env_node_path=$env:NODE_PATH
$new_node_path="D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\esbuild\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
$pathsep=";"
} else {
$new_node_path="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/esbuild/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
}
if ([string]::IsNullOrEmpty($env_node_path)) {
$env:NODE_PATH=$new_node_path
} else {
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
@@ -25,4 +37,5 @@ if (Test-Path "$basedir/node$exe") {
}
$ret=$LASTEXITCODE
}
$env:NODE_PATH=$env_node_path
exit $ret
+6 -1
View File
@@ -9,8 +9,13 @@ case `uname` in
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/jsesc/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/jsesc/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../jsesc/bin/jsesc" "$@"
else
else
exec node "$basedir/../jsesc/bin/jsesc" "$@"
fi
+10 -15
View File
@@ -1,17 +1,12 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
@SETLOCAL
@IF NOT DEFINED NODE_PATH (
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\jsesc\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\jsesc\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules;%NODE_PATH%"
)
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\jsesc\bin\jsesc" %*
) ELSE (
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\jsesc\bin\jsesc" %*
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\jsesc\bin\jsesc" %*
+13
View File
@@ -2,11 +2,23 @@
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
$pathsep=":"
$env_node_path=$env:NODE_PATH
$new_node_path="D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\jsesc\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
$pathsep=";"
} else {
$new_node_path="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/jsesc/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
}
if ([string]::IsNullOrEmpty($env_node_path)) {
$env:NODE_PATH=$new_node_path
} else {
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
@@ -25,4 +37,5 @@ if (Test-Path "$basedir/node$exe") {
}
$ret=$LASTEXITCODE
}
$env:NODE_PATH=$env_node_path
exit $ret
+6 -1
View File
@@ -9,8 +9,13 @@ case `uname` in
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/json5/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/json5/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../json5/lib/cli.js" "$@"
else
else
exec node "$basedir/../json5/lib/cli.js" "$@"
fi
+10 -15
View File
@@ -1,17 +1,12 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
@SETLOCAL
@IF NOT DEFINED NODE_PATH (
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\json5\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\json5\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules;%NODE_PATH%"
)
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\json5\lib\cli.js" %*
) ELSE (
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\json5\lib\cli.js" %*
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\json5\lib\cli.js" %*
+13
View File
@@ -2,11 +2,23 @@
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
$pathsep=":"
$env_node_path=$env:NODE_PATH
$new_node_path="D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\json5\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
$pathsep=";"
} else {
$new_node_path="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/json5/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
}
if ([string]::IsNullOrEmpty($env_node_path)) {
$env:NODE_PATH=$new_node_path
} else {
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
@@ -25,4 +37,5 @@ if (Test-Path "$basedir/node$exe") {
}
$ret=$LASTEXITCODE
}
$env:NODE_PATH=$env_node_path
exit $ret
+6 -1
View File
@@ -9,8 +9,13 @@ case `uname` in
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/loose-envify/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/loose-envify/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../loose-envify/cli.js" "$@"
else
else
exec node "$basedir/../loose-envify/cli.js" "$@"
fi
+10 -15
View File
@@ -1,17 +1,12 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
@SETLOCAL
@IF NOT DEFINED NODE_PATH (
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\loose-envify\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\loose-envify\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules;%NODE_PATH%"
)
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\loose-envify\cli.js" %*
) ELSE (
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\loose-envify\cli.js" %*
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\loose-envify\cli.js" %*
+13
View File
@@ -2,11 +2,23 @@
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
$pathsep=":"
$env_node_path=$env:NODE_PATH
$new_node_path="D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\loose-envify\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
$pathsep=";"
} else {
$new_node_path="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/loose-envify/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
}
if ([string]::IsNullOrEmpty($env_node_path)) {
$env:NODE_PATH=$new_node_path
} else {
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
@@ -25,4 +37,5 @@ if (Test-Path "$basedir/node$exe") {
}
$ret=$LASTEXITCODE
}
$env:NODE_PATH=$env_node_path
exit $ret
+6 -1
View File
@@ -9,8 +9,13 @@ case `uname` in
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/nanoid/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/nanoid/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../nanoid/bin/nanoid.cjs" "$@"
else
else
exec node "$basedir/../nanoid/bin/nanoid.cjs" "$@"
fi
+10 -15
View File
@@ -1,17 +1,12 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
@SETLOCAL
@IF NOT DEFINED NODE_PATH (
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\nanoid\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\nanoid\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules;%NODE_PATH%"
)
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\nanoid\bin\nanoid.cjs" %*
) ELSE (
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\nanoid\bin\nanoid.cjs" %*
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\nanoid\bin\nanoid.cjs" %*
+13
View File
@@ -2,11 +2,23 @@
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
$pathsep=":"
$env_node_path=$env:NODE_PATH
$new_node_path="D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\nanoid\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
$pathsep=";"
} else {
$new_node_path="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/nanoid/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
}
if ([string]::IsNullOrEmpty($env_node_path)) {
$env:NODE_PATH=$new_node_path
} else {
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
@@ -25,4 +37,5 @@ if (Test-Path "$basedir/node$exe") {
}
$ret=$LASTEXITCODE
}
$env:NODE_PATH=$env_node_path
exit $ret
+6 -1
View File
@@ -9,8 +9,13 @@ case `uname` in
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/@babel/parser/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/@babel/parser/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../@babel/parser/bin/babel-parser.js" "$@"
else
else
exec node "$basedir/../@babel/parser/bin/babel-parser.js" "$@"
fi
+10 -15
View File
@@ -1,17 +1,12 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
@SETLOCAL
@IF NOT DEFINED NODE_PATH (
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\@babel\parser\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\@babel\parser\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules;%NODE_PATH%"
)
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\@babel\parser\bin\babel-parser.js" %*
) ELSE (
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\@babel\parser\bin\babel-parser.js" %*
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\@babel\parser\bin\babel-parser.js" %*
+13
View File
@@ -2,11 +2,23 @@
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
$pathsep=":"
$env_node_path=$env:NODE_PATH
$new_node_path="D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\@babel\parser\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
$pathsep=";"
} else {
$new_node_path="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/@babel/parser/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
}
if ([string]::IsNullOrEmpty($env_node_path)) {
$env:NODE_PATH=$new_node_path
} else {
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
@@ -25,4 +37,5 @@ if (Test-Path "$basedir/node$exe") {
}
$ret=$LASTEXITCODE
}
$env:NODE_PATH=$env_node_path
exit $ret
+6 -1
View File
@@ -9,8 +9,13 @@ case `uname` in
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/rollup/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/rollup/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../rollup/dist/bin/rollup" "$@"
else
else
exec node "$basedir/../rollup/dist/bin/rollup" "$@"
fi
+10 -15
View File
@@ -1,17 +1,12 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
@SETLOCAL
@IF NOT DEFINED NODE_PATH (
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\rollup\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\rollup\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules;%NODE_PATH%"
)
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\rollup\dist\bin\rollup" %*
) ELSE (
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\rollup\dist\bin\rollup" %*
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\rollup\dist\bin\rollup" %*
+13
View File
@@ -2,11 +2,23 @@
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
$pathsep=":"
$env_node_path=$env:NODE_PATH
$new_node_path="D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\rollup\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
$pathsep=";"
} else {
$new_node_path="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/rollup/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
}
if ([string]::IsNullOrEmpty($env_node_path)) {
$env:NODE_PATH=$new_node_path
} else {
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
@@ -25,4 +37,5 @@ if (Test-Path "$basedir/node$exe") {
}
$ret=$LASTEXITCODE
}
$env:NODE_PATH=$env_node_path
exit $ret
+6 -1
View File
@@ -9,8 +9,13 @@ case `uname` in
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/semver/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/semver/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../semver/bin/semver.js" "$@"
else
else
exec node "$basedir/../semver/bin/semver.js" "$@"
fi
+10 -15
View File
@@ -1,17 +1,12 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
@SETLOCAL
@IF NOT DEFINED NODE_PATH (
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\semver\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\semver\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules;%NODE_PATH%"
)
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\semver\bin\semver.js" %*
) ELSE (
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\semver\bin\semver.js" %*
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\semver\bin\semver.js" %*
+13
View File
@@ -2,11 +2,23 @@
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
$pathsep=":"
$env_node_path=$env:NODE_PATH
$new_node_path="D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\semver\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
$pathsep=";"
} else {
$new_node_path="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/semver/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
}
if ([string]::IsNullOrEmpty($env_node_path)) {
$env:NODE_PATH=$new_node_path
} else {
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
@@ -25,4 +37,5 @@ if (Test-Path "$basedir/node$exe") {
}
$ret=$LASTEXITCODE
}
$env:NODE_PATH=$env_node_path
exit $ret
+6 -1
View File
@@ -9,8 +9,13 @@ case `uname` in
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/update-browserslist-db/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/update-browserslist-db/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../update-browserslist-db/cli.js" "$@"
else
else
exec node "$basedir/../update-browserslist-db/cli.js" "$@"
fi
+10 -15
View File
@@ -1,17 +1,12 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
@SETLOCAL
@IF NOT DEFINED NODE_PATH (
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\update-browserslist-db\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
@SET "NODE_PATH=D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\update-browserslist-db\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules;%NODE_PATH%"
)
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\update-browserslist-db\cli.js" %*
) ELSE (
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\update-browserslist-db\cli.js" %*
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\update-browserslist-db\cli.js" %*
+13
View File
@@ -2,11 +2,23 @@
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
$pathsep=":"
$env_node_path=$env:NODE_PATH
$new_node_path="D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\update-browserslist-db\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules;D:\Documents\Github\ai-backend-studio\tauri-app\node_modules\.pnpm\node_modules"
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
$pathsep=";"
} else {
$new_node_path="/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/update-browserslist-db/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules:/mnt/d/Documents/Github/ai-backend-studio/tauri-app/node_modules/.pnpm/node_modules"
}
if ([string]::IsNullOrEmpty($env_node_path)) {
$env:NODE_PATH=$new_node_path
} else {
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
@@ -25,4 +37,5 @@ if (Test-Path "$basedir/node$exe") {
}
$ret=$LASTEXITCODE
}
$env:NODE_PATH=$env_node_path
exit $ret
+1157
View File
File diff suppressed because it is too large. Load diff
+1678 -1
View File
File diff suppressed because it is too large. Load diff
+5 -2
View File
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"test": "vitest run",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
@@ -12,9 +13,9 @@
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-shell": "^2",
"marked": "^12.0.0",
"dompurify": "^3.0.0",
"highlight.js": "^11.0.0",
"marked": "^12.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
@@ -23,8 +24,10 @@
"@types/dompurify": "^3.0.0",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.1",
"jsdom": "^29.0.1",
"typescript": "^5.5.3",
"vite": "^5.4.8",
"@vitejs/plugin-react": "^4.3.1"
"vitest": "^4.1.2"
}
}
+1272
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
+5166
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command Line truncated
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for ForgeLM Chat","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","dialog:allow-open"]}}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+66 -122
View File
@@ -1,10 +1,14 @@
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::sync::Mutex;
use std::path::Path;
use std::process::Command;
use tauri::{AppHandle, Manager, State};
use tauri_plugin_dialog::DialogExt;
pub struct ServerState(pub Mutex<Option<Child>>);
mod runtime;
use runtime::config::{load_runtime_config, save_runtime_config};
use runtime::process::find_localllm_binary;
use runtime::types::{RuntimeConfig, RuntimeSnapshot};
use runtime::{bootstrap_runtime, get_runtime_snapshot, save_runtime_config_command, start_runtime, stop_runtime, RuntimeState};
// ─── GPU ──────────────────────────────────────────────────────────────────────
@@ -47,107 +51,73 @@ fn query_gpu() -> Result<GpuInfo, String> {
// ─── Server lifecycle ─────────────────────────────────────────────────────────
/// Locate the `localllm` binary by searching several well-known locations:
/// 1. Workspace `target/release/` relative to the Tauri app's source tree
/// 2. Next to the running executable (for installed/bundled builds)
/// 3. System PATH
fn find_localllm_binary() -> Option<PathBuf> {
let exe_name = if cfg!(target_os = "windows") { "localllm.exe" } else { "localllm" };
#[tauri::command]
fn bootstrap_runtime_command(
app: AppHandle,
state: State<RuntimeState>,
) -> Result<RuntimeSnapshot, String> {
bootstrap_runtime(app, state)
}
// 1. Workspace target/release — works when running `npm run tauri dev` from tauri-app/
// The Tauri dev binary lives at tauri-app/src-tauri/target/debug/forgelm-chat.exe
// so we walk up to find the workspace root and look for target/release/localllm.exe
if let Ok(exe) = std::env::current_exe() {
// Walk up from the current exe looking for target/release/localllm.exe
let mut dir = exe.parent();
while let Some(d) = dir {
let candidate = d.join("target").join("release").join(exe_name);
if candidate.exists() {
return Some(candidate);
}
// Also check sibling: if we're inside target/debug, go up two levels
let candidate2 = d.join(exe_name);
if candidate2.exists() {
return Some(candidate2);
}
dir = d.parent();
}
}
#[tauri::command]
fn get_runtime_snapshot_command(state: State<RuntimeState>) -> Result<RuntimeSnapshot, String> {
get_runtime_snapshot(state)
}
// 2. Relative to CWD (useful when launched from repo root)
let cwd_candidate = PathBuf::from("target").join("release").join(exe_name);
if cwd_candidate.exists() {
return Some(cwd_candidate);
}
#[tauri::command]
fn save_runtime_config_command_handler(
app: AppHandle,
state: State<RuntimeState>,
config: RuntimeConfig,
) -> Result<RuntimeSnapshot, String> {
save_runtime_config_command(app, state, config)
}
// 3. System PATH
if let Ok(output) = Command::new(if cfg!(target_os = "windows") { "where" } else { "which" })
.arg("localllm")
.output()
{
if output.status.success() {
let path_str = String::from_utf8_lossy(&output.stdout);
let first_line = path_str.lines().next().unwrap_or("").trim();
if !first_line.is_empty() {
return Some(PathBuf::from(first_line));
}
}
}
#[tauri::command]
fn start_runtime_command(
app: AppHandle,
state: State<RuntimeState>,
) -> Result<RuntimeSnapshot, String> {
start_runtime(app, state)
}
None
#[tauri::command]
fn stop_runtime_command(state: State<RuntimeState>) -> Result<RuntimeSnapshot, String> {
stop_runtime(state)
}
#[tauri::command]
fn start_server(
state: State<ServerState>,
app: AppHandle,
state: State<RuntimeState>,
model_path: String,
addr: String,
gpu_layers: i32,
ctx_size: u32,
) -> Result<String, String> {
let mut guard = state.0.lock().map_err(|e| e.to_string())?;
if guard.is_some() {
return Err("Server is already running".into());
}
let binary_path = find_localllm_binary()
.ok_or_else(|| {
"localllm binary not found. Build it with: cargo build --release -p inference-server\n\
Then ensure the binary is in one of: target/release/, PATH, or next to this app."
.to_string()
})?;
let child = Command::new(&binary_path)
.env("LOCALLLM_MODEL", &model_path)
.env("LOCALLLM_ADDR", &addr)
.env("LOCALLLM_GPU_LAYERS", gpu_layers.to_string())
.env("LOCALLLM_CTX", ctx_size.to_string())
.spawn()
.map_err(|e| format!("Failed to start server ({}): {e}", binary_path.display()))?;
let pid = child.id();
*guard = Some(child);
Ok(format!("Server started (pid={pid}) on {addr}"))
let mut config = load_runtime_config(&app).unwrap_or_default();
config.connection_mode = runtime::types::ConnectionMode::Local;
config.model_path = Some(model_path);
config.addr = addr;
config.gpu_layers = gpu_layers;
config.ctx_size = ctx_size;
save_runtime_config(&app, &config)?;
let snapshot = start_runtime(app, state)?;
Ok(format!("Local API {}", snapshot.resolved_url))
}
#[tauri::command]
fn stop_server(state: State<ServerState>) -> Result<String, String> {
let mut guard = state.0.lock().map_err(|e| e.to_string())?;
match guard.as_mut() {
None => Err("Server is not running".into()),
Some(child) => {
child.kill().map_err(|e| format!("Failed to kill server: {e}"))?;
let _ = child.wait();
*guard = None;
Ok("Server stopped".into())
}
}
fn stop_server(state: State<RuntimeState>) -> Result<String, String> {
let snapshot = stop_runtime(state)?;
Ok(format!("Local API stopped ({})", snapshot.resolved_url))
}
#[tauri::command]
fn server_status(state: State<ServerState>) -> String {
let guard = state.0.lock().unwrap();
if guard.is_some() { "running".into() } else { "stopped".into() }
fn server_status(state: State<RuntimeState>) -> String {
match get_runtime_snapshot(state) {
Ok(snapshot) if snapshot.process_running => "running".into(),
_ => "stopped".into(),
}
}
// ─── Model files ──────────────────────────────────────────────────────────────
@@ -288,46 +258,15 @@ fn import_model(src_path: String, dest_dir: String) -> Result<ModelFile, String>
// ─── Persistent config ────────────────────────────────────────────────────────
/// All user-configurable settings, persisted to app data dir as config.json.
#[derive(serde::Serialize, serde::Deserialize, Clone, Default)]
pub struct AppConfig {
pub models_dir: Option<String>,
pub model_path: Option<String>,
pub addr: Option<String>,
pub gpu_layers: Option<i32>,
pub ctx_size: Option<u32>,
pub api_key: Option<String>,
pub server_url: Option<String>,
/// Extra model paths added via file picker (not necessarily in models_dir)
pub extra_model_paths: Option<Vec<String>>,
}
fn config_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
let data_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Cannot resolve app data dir: {e}"))?;
std::fs::create_dir_all(&data_dir)
.map_err(|e| format!("Cannot create app data dir: {e}"))?;
Ok(data_dir.join("config.json"))
}
#[tauri::command]
fn save_config(app: AppHandle, config: AppConfig) -> Result<(), String> {
let path = config_path(&app)?;
let json = serde_json::to_string_pretty(&config)
.map_err(|e| format!("Serialization error: {e}"))?;
std::fs::write(&path, json).map_err(|e| format!("Write error: {e}"))?;
fn save_config(app: AppHandle, config: RuntimeConfig) -> Result<(), String> {
save_runtime_config(&app, &config)?;
Ok(())
}
#[tauri::command]
fn load_config(app: AppHandle) -> Result<AppConfig, String> {
let path = config_path(&app)?;
if !path.exists() {
return Ok(AppConfig::default());
}
let json = std::fs::read_to_string(&path).map_err(|e| format!("Read error: {e}"))?;
serde_json::from_str(&json).map_err(|e| format!("Parse error: {e}"))
fn load_config(app: AppHandle) -> Result<RuntimeConfig, String> {
load_runtime_config(&app)
}
/// Returns the resolved path to the localllm binary (useful for debugging).
@@ -357,13 +296,18 @@ pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.manage(ServerState(Mutex::new(None)))
.manage(RuntimeState::new())
.invoke_handler(tauri::generate_handler![
query_gpu,
scan_models,
pick_models_dir,
pick_model_files,
import_model,
bootstrap_runtime_command,
get_runtime_snapshot_command,
save_runtime_config_command_handler,
start_runtime_command,
stop_runtime_command,
start_server,
stop_server,
server_status,
+32
View File
@@ -0,0 +1,32 @@
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
use super::types::RuntimeConfig;
pub fn config_path(app: &AppHandle) -> Result<PathBuf, String> {
let data_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Cannot resolve app data dir: {e}"))?;
std::fs::create_dir_all(&data_dir).map_err(|e| format!("Cannot create app data dir: {e}"))?;
Ok(data_dir.join("config.json"))
}
pub fn load_runtime_config(app: &AppHandle) -> Result<RuntimeConfig, String> {
let path = config_path(app)?;
if !path.exists() {
return Ok(RuntimeConfig::default());
}
let json = std::fs::read_to_string(&path).map_err(|e| format!("Read error: {e}"))?;
serde_json::from_str(&json).map_err(|e| format!("Parse error: {e}"))
}
pub fn save_runtime_config(app: &AppHandle, config: &RuntimeConfig) -> Result<(), String> {
let path = config_path(app)?;
let json =
serde_json::to_string_pretty(config).map_err(|e| format!("Serialization error: {e}"))?;
std::fs::write(&path, json).map_err(|e| format!("Write error: {e}"))?;
Ok(())
}
+120
View File
@@ -0,0 +1,120 @@
use std::io::{Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::thread;
use std::time::{Duration, Instant};
fn resolve_socket_addr(addr: &str) -> Option<std::net::SocketAddr> {
match addr.to_socket_addrs() {
Ok(mut addrs) => addrs.next(),
Err(_) => None,
}
}
fn health_response_is_ready(response: &str) -> bool {
let mut sections = response.splitn(2, "\r\n\r\n");
let headers = sections.next().unwrap_or_default();
let body = sections.next().unwrap_or_default();
if !headers.starts_with("HTTP/1.1 200") && !headers.starts_with("HTTP/1.0 200") {
return false;
}
let Ok(json) = serde_json::from_str::<serde_json::Value>(body) else {
return false;
};
json.get("status")
.and_then(|value| value.as_str())
.map(|status| status == "ok")
.unwrap_or(false)
}
pub fn wait_for_health(addr: &str, timeout: Duration) -> bool {
let Some(socket_addr) = resolve_socket_addr(addr) else {
return false;
};
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
match TcpStream::connect_timeout(&socket_addr, Duration::from_millis(250)) {
Ok(mut stream) => {
let _ = stream.set_read_timeout(Some(Duration::from_millis(250)));
let _ = stream.set_write_timeout(Some(Duration::from_millis(250)));
let request =
format!("GET /health HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
if stream.write_all(request.as_bytes()).is_ok() {
let mut response = String::new();
if stream.read_to_string(&mut response).is_ok()
&& health_response_is_ready(&response)
{
return true;
}
}
}
Err(_) => {}
}
thread::sleep(Duration::from_millis(250));
}
false
}
#[cfg(test)]
mod tests {
use super::wait_for_health;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
use std::time::Duration;
fn spawn_http_server(response: &'static str) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
let addr = listener.local_addr().expect("local addr");
thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buffer = [0_u8; 1024];
let _ = stream.read(&mut buffer);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
});
addr.to_string()
}
#[test]
fn wait_for_health_accepts_healthy_http_response() {
let addr = spawn_http_server(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 58\r\n\r\n{\"status\":\"ok\",\"model_loaded\":true,\"model_id\":\"demo\"}",
);
assert!(wait_for_health(&addr, Duration::from_secs(1)));
}
#[test]
fn wait_for_health_rejects_non_healthy_payloads() {
let addr = spawn_http_server(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 21\r\n\r\n{\"status\":\"booting\"}",
);
assert!(!wait_for_health(&addr, Duration::from_secs(1)));
}
#[test]
fn wait_for_health_rejects_plain_open_ports() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind bare port");
let addr = listener.local_addr().expect("local addr").to_string();
thread::spawn(move || {
if let Ok((_stream, _)) = listener.accept() {
thread::sleep(Duration::from_millis(250));
}
});
assert!(!wait_for_health(&addr, Duration::from_millis(500)));
}
}
+254
View File
@@ -0,0 +1,254 @@
pub mod config;
pub mod health;
pub mod process;
pub mod types;
use std::process::Child;
use std::sync::Mutex;
use std::time::Duration;
use tauri::{AppHandle, State};
use self::config::{load_runtime_config, save_runtime_config};
use self::health::wait_for_health;
use self::process::{find_localllm_binary, spawn_localllm};
use self::types::{
ConnectionMode, RuntimeConfig, RuntimeErrorInfo, RuntimeSnapshot, RuntimeStatus, StartPolicy,
};
pub struct RuntimeInner {
pub child: Option<Child>,
pub status: RuntimeStatus,
pub last_error: Option<RuntimeErrorInfo>,
pub config: RuntimeConfig,
}
pub struct RuntimeState(pub Mutex<RuntimeInner>);
impl RuntimeState {
pub fn new() -> Self {
Self(Mutex::new(RuntimeInner {
child: None,
status: RuntimeStatus::Stopped,
last_error: None,
config: RuntimeConfig::default(),
}))
}
}
fn runtime_error(code: &str, message: impl Into<String>) -> RuntimeErrorInfo {
RuntimeErrorInfo {
code: code.to_string(),
message: message.into(),
}
}
fn resolve_runtime_url(config: &RuntimeConfig) -> String {
match config.connection_mode {
ConnectionMode::Local => format!("http://{}", config.addr.trim()),
ConnectionMode::Remote => config.server_url.trim().trim_end_matches('/').to_string(),
}
}
fn requires_runtime_restart(current: &RuntimeConfig, next: &RuntimeConfig) -> bool {
current.connection_mode != next.connection_mode
|| current.model_path != next.model_path
|| current.addr != next.addr
|| current.gpu_layers != next.gpu_layers
|| current.ctx_size != next.ctx_size
}
fn refresh_process_state(inner: &mut RuntimeInner) {
let Some(child) = inner.child.as_mut() else {
if matches!(
inner.status,
RuntimeStatus::Starting | RuntimeStatus::Ready | RuntimeStatus::Degraded
) {
inner.status = RuntimeStatus::Stopped;
}
return;
};
match child.try_wait() {
Ok(Some(status)) => {
inner.child = None;
inner.status = RuntimeStatus::Error;
inner.last_error = Some(runtime_error(
"unexpected_exit",
format!("Local API exited unexpectedly with status {status}"),
));
}
Ok(None) => {
if inner.status == RuntimeStatus::Stopped {
inner.status = RuntimeStatus::Ready;
}
}
Err(error) => {
inner.child = None;
inner.status = RuntimeStatus::Error;
inner.last_error = Some(runtime_error("process_probe_failed", error.to_string()));
}
}
}
fn build_snapshot(inner: &RuntimeInner) -> RuntimeSnapshot {
RuntimeSnapshot {
status: inner.status,
ready: inner.status == RuntimeStatus::Ready,
process_running: inner.child.is_some(),
resolved_url: resolve_runtime_url(&inner.config),
binary_path: find_localllm_binary().map(|path| path.to_string_lossy().to_string()),
last_error: inner.last_error.clone(),
config: inner.config.clone(),
}
}
pub fn get_runtime_snapshot(state: State<'_, RuntimeState>) -> Result<RuntimeSnapshot, String> {
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
refresh_process_state(&mut inner);
Ok(build_snapshot(&inner))
}
pub fn save_runtime_config_command(
app: AppHandle,
state: State<'_, RuntimeState>,
config: RuntimeConfig,
) -> Result<RuntimeSnapshot, String> {
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
refresh_process_state(&mut inner);
if inner.child.is_some() && requires_runtime_restart(&inner.config, &config) {
inner.last_error = Some(runtime_error(
"restart_required",
"Stop the local API before changing connection mode, model, address, or runtime sizing.",
));
return Ok(build_snapshot(&inner));
}
save_runtime_config(&app, &config)?;
inner.config = config;
inner.last_error = None;
Ok(build_snapshot(&inner))
}
fn start_runtime_inner(
app: &AppHandle,
state: &State<'_, RuntimeState>,
override_config: Option<RuntimeConfig>,
) -> Result<RuntimeSnapshot, String> {
let config = {
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
refresh_process_state(&mut inner);
if inner.child.is_some() {
return Ok(build_snapshot(&inner));
}
if let Some(config) = override_config {
save_runtime_config(app, &config)?;
inner.config = config;
}
if inner.config.connection_mode != ConnectionMode::Local {
inner.last_error = Some(runtime_error(
"remote_mode",
"Switch to Local mode before starting the built-in API",
));
inner.status = RuntimeStatus::Error;
return Ok(build_snapshot(&inner));
}
inner.status = RuntimeStatus::Starting;
inner.last_error = None;
inner.config.clone()
};
let child = match spawn_localllm(&config) {
Ok(child) => child,
Err(message) => {
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
inner.status = RuntimeStatus::Error;
inner.last_error = Some(runtime_error("spawn_failed", message));
return Ok(build_snapshot(&inner));
}
};
{
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
inner.child = Some(child);
inner.status = RuntimeStatus::Starting;
}
if wait_for_health(&config.addr, Duration::from_secs(15)) {
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
inner.status = RuntimeStatus::Ready;
inner.last_error = None;
Ok(build_snapshot(&inner))
} else {
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
if let Some(child) = inner.child.as_mut() {
let _ = child.kill();
let _ = child.wait();
}
inner.child = None;
inner.status = RuntimeStatus::Error;
inner.last_error = Some(runtime_error(
"health_timeout",
format!(
"Local API did not pass /health readiness on {} in time",
config.addr
),
));
Ok(build_snapshot(&inner))
}
}
pub fn bootstrap_runtime(
app: AppHandle,
state: State<'_, RuntimeState>,
) -> Result<RuntimeSnapshot, String> {
let config = load_runtime_config(&app).unwrap_or_default();
let should_autostart = config.connection_mode == ConnectionMode::Local
&& config.start_policy == StartPolicy::OnAppLaunch
&& config
.model_path
.as_deref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
{
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
inner.config = config.clone();
refresh_process_state(&mut inner);
}
if should_autostart {
start_runtime_inner(&app, &state, Some(config))
} else {
get_runtime_snapshot(state)
}
}
pub fn start_runtime(
app: AppHandle,
state: State<'_, RuntimeState>,
) -> Result<RuntimeSnapshot, String> {
start_runtime_inner(&app, &state, None)
}
pub fn stop_runtime(state: State<'_, RuntimeState>) -> Result<RuntimeSnapshot, String> {
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
inner.status = RuntimeStatus::Stopping;
if let Some(child) = inner.child.as_mut() {
child
.kill()
.map_err(|e| format!("Failed to kill server: {e}"))?;
let _ = child.wait();
}
inner.child = None;
inner.status = RuntimeStatus::Stopped;
inner.last_error = None;
Ok(build_snapshot(&inner))
}
@@ -0,0 +1,75 @@
use std::path::PathBuf;
use std::process::{Child, Command};
use super::types::RuntimeConfig;
pub fn find_localllm_binary() -> Option<PathBuf> {
let exe_name = if cfg!(target_os = "windows") {
"localllm.exe"
} else {
"localllm"
};
if let Ok(exe) = std::env::current_exe() {
let mut dir = exe.parent();
while let Some(d) = dir {
let candidate = d.join("target").join("release").join(exe_name);
if candidate.exists() {
return Some(candidate);
}
let sibling_candidate = d.join(exe_name);
if sibling_candidate.exists() {
return Some(sibling_candidate);
}
dir = d.parent();
}
}
let cwd_candidate = PathBuf::from("target").join("release").join(exe_name);
if cwd_candidate.exists() {
return Some(cwd_candidate);
}
if let Ok(output) = Command::new(if cfg!(target_os = "windows") {
"where"
} else {
"which"
})
.arg("localllm")
.output()
{
if output.status.success() {
let path_str = String::from_utf8_lossy(&output.stdout);
let first_line = path_str.lines().next().unwrap_or("").trim();
if !first_line.is_empty() {
return Some(PathBuf::from(first_line));
}
}
}
None
}
pub fn spawn_localllm(config: &RuntimeConfig) -> Result<Child, String> {
let model_path = config
.model_path
.as_deref()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "Select a local model before starting the API".to_string())?;
let binary_path = find_localllm_binary().ok_or_else(|| {
"localllm binary not found. Build it with: cargo build --release -p inference-server\n\
Then ensure the binary is in target/release/, PATH, or next to this app."
.to_string()
})?;
Command::new(&binary_path)
.env("LOCALLLM_MODEL", model_path)
.env("LOCALLLM_ADDR", &config.addr)
.env("LOCALLLM_GPU_LAYERS", config.gpu_layers.to_string())
.env("LOCALLLM_CTX", config.ctx_size.to_string())
.spawn()
.map_err(|e| format!("Failed to start server ({}): {e}", binary_path.display()))
}
+84
View File
@@ -0,0 +1,84 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ConnectionMode {
#[default]
Local,
Remote,
}
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum StartPolicy {
#[default]
Manual,
OnAppLaunch,
}
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeStatus {
#[default]
Stopped,
Starting,
Ready,
Degraded,
Stopping,
Error,
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(default)]
pub struct RuntimeConfig {
pub connection_mode: ConnectionMode,
pub start_policy: StartPolicy,
pub server_url: String,
pub api_key: String,
pub model_path: Option<String>,
pub models_dir: Option<String>,
pub addr: String,
pub gpu_layers: i32,
pub ctx_size: u32,
pub extra_model_paths: Vec<String>,
}
impl RuntimeConfig {
pub fn default_addr() -> String {
"127.0.0.1:8080".to_string()
}
}
impl Default for RuntimeConfig {
fn default() -> Self {
Self {
connection_mode: ConnectionMode::Local,
start_policy: StartPolicy::Manual,
server_url: String::new(),
api_key: String::new(),
model_path: None,
models_dir: None,
addr: Self::default_addr(),
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: Vec::new(),
}
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct RuntimeErrorInfo {
pub code: String,
pub message: String,
}
#[derive(Serialize, Clone)]
pub struct RuntimeSnapshot {
pub status: RuntimeStatus,
pub ready: bool,
pub process_running: bool,
pub resolved_url: String,
pub binary_path: Option<String>,
pub last_error: Option<RuntimeErrorInfo>,
pub config: RuntimeConfig,
}
+12 -18
View File
@@ -3,7 +3,7 @@ import Sidebar from "./components/Sidebar";
import ChatView from "./components/ChatView";
import { useDB } from "./hooks/useDB";
import { useModels } from "./hooks/useModels";
import { useServerProcess } from "./hooks/useServerProcess";
import { useRuntimeManager } from "./features/runtime/useRuntimeManager";
import type { Chat } from "./lib/types";
import "./App.css";
@@ -11,22 +11,20 @@ export default function App() {
const { db, ready } = useDB();
const [currentChatId, setCurrentChatId] = useState<number | null>(null);
const [chats, setChats] = useState<Chat[]>([]);
const [server, setServer] = useState(() => localStorage.getItem("forgelm_server") || "http://127.0.0.1:8080");
const [apiKey, setApiKey] = useState(() => localStorage.getItem("forgelm_apikey") || "");
const { models, selectedModel, setSelectedModel, status, refresh: refreshModels } = useModels(server, apiKey);
const serverProcess = useServerProcess();
// Persist server/key
useEffect(() => { localStorage.setItem("forgelm_server", server); }, [server]);
useEffect(() => { localStorage.setItem("forgelm_apikey", apiKey); }, [apiKey]);
const runtime = useRuntimeManager();
const { models, selectedModel, setSelectedModel, status, refresh: refreshModels } = useModels(
runtime.connection.serverUrl,
runtime.connection.apiKey,
Boolean(runtime.connection.serverUrl),
);
// Auto-refresh models when server starts
useEffect(() => {
if (serverProcess.status === "running") {
if (runtime.snapshot.status === "ready") {
setTimeout(refreshModels, 2000);
}
}, [serverProcess.status, refreshModels]);
}, [runtime.snapshot.status, refreshModels]);
const loadChats = useCallback(() => {
if (!db) return;
@@ -70,22 +68,18 @@ export default function App() {
onSelectChat={setCurrentChatId}
onNewChat={newChat}
onDeleteChat={deleteChat}
server={server}
onServerChange={setServer}
apiKey={apiKey}
onApiKeyChange={setApiKey}
models={models}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
status={status}
onRefreshModels={refreshModels}
serverProcess={serverProcess}
runtime={runtime}
/>
<ChatView
db={db!}
chatId={currentChatId}
server={server}
apiKey={apiKey}
server={runtime.connection.serverUrl}
apiKey={runtime.connection.apiKey}
model={selectedModel}
onChatCreated={(id) => { setCurrentChatId(id); loadChats(); }}
onChatUpdated={loadChats}
+34
View File
@@ -20,6 +20,22 @@
.chat-topbar-left { display: flex; align-items: center; gap: 10px; }
.chat-topbar-right { display: flex; align-items: center; gap: 14px; }
.chat-token-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 0.72rem;
color: var(--text2);
font-variant-numeric: tabular-nums;
}
.chat-token-summary span {
background: var(--bg4);
border: 1px solid var(--border);
padding: 3px 10px;
border-radius: 20px;
}
.tps-badge {
font-size: 0.72rem;
background: var(--bg4);
@@ -205,6 +221,10 @@
40% { transform: translateY(-5px); opacity: 1; }
}
.live-reasoning-panel {
margin-bottom: 8px;
}
/* Input area */
.input-area {
padding: 12px 20px 14px;
@@ -279,3 +299,17 @@
}
.char-count { font-size: 0.7rem; color: var(--text3); margin-left: auto; }
@media (max-width: 860px) {
.chat-topbar {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.chat-topbar-right {
width: 100%;
justify-content: space-between;
flex-wrap: wrap;
}
}
+109 -26
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { DB } from "../lib/db";
import type { Message, Usage } from "../lib/types";
import type { ChatTokenTotals, Message, Usage } from "../lib/types";
import { buildChatTitle, parseAssistantResponse } from "../lib/chat";
import { renderMarkdown, ensureHljs } from "../lib/markdown";
import MessageBubble from "./MessageBubble";
import "./ChatView.css";
@@ -21,7 +22,10 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [generating, setGenerating] = useState(false);
const [streamContent, setStreamContent] = useState<string | null>(null);
const [streamContent, setStreamContent] = useState("");
const [streamReasoning, setStreamReasoning] = useState("");
const [streamThinkingOpen, setStreamThinkingOpen] = useState(false);
const [showLiveReasoning, setShowLiveReasoning] = useState(false);
const [tps, setTps] = useState<number | null>(null);
const [useStream, setUseStream] = useState(true);
const [useMemory, setUseMemory] = useState(true);
@@ -29,13 +33,20 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
const textareaRef = useRef<HTMLTextAreaElement>(null);
const abortRef = useRef<AbortController | null>(null);
const chatTotals = messages.reduce<ChatTokenTotals>((totals, message) => {
totals.prompt_tokens += message.prompt_tokens ?? 0;
totals.completion_tokens += message.completion_tokens ?? 0;
totals.total_tokens += message.total_tokens ?? 0;
return totals;
}, { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
useEffect(() => { ensureHljs(); }, []);
const loadMessages = useCallback(() => {
if (!chatId) { setMessages([]); return; }
const loadMessages = useCallback((targetChatId: number | null = chatId) => {
if (!targetChatId) { setMessages([]); return; }
const rows = db.query<Message>(
"SELECT * FROM messages WHERE chat_id = ? ORDER BY created_at ASC",
[chatId]
[targetChatId]
);
setMessages(rows);
}, [db, chatId]);
@@ -53,18 +64,21 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
el.style.height = Math.min(el.scrollHeight, 200) + "px";
};
const getOrCreateChat = useCallback((): number => {
const getOrCreateChat = useCallback((firstMessage?: string): number => {
if (chatId) return chatId;
db.run("INSERT INTO chats (title) VALUES ('New Chat')");
db.run("INSERT INTO chats (title) VALUES (?)", [buildChatTitle(firstMessage || "")]);
const row = db.get<{ id: number }>("SELECT last_insert_rowid() as id");
const newId = row!.id;
onChatCreated(newId);
return newId;
}, [chatId, db, onChatCreated]);
const updateChatTitle = useCallback((cid: number, firstMsg: string) => {
const title = firstMsg.slice(0, 50) + (firstMsg.length > 50 ? "…" : "");
db.run("UPDATE chats SET title = ?, updated_at = strftime('%s','now') WHERE id = ?", [title, cid]);
const ensureChatTitle = useCallback((cid: number, firstMsg: string) => {
const title = buildChatTitle(firstMsg);
db.run(
"UPDATE chats SET title = CASE WHEN title = 'New Chat' OR title = '' THEN ? ELSE title END, updated_at = strftime('%s','now') WHERE id = ?",
[title, cid],
);
onChatUpdated();
}, [db, onChatUpdated]);
@@ -79,18 +93,23 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
const send = useCallback(async (text: string, fromChatId?: number) => {
if (!text.trim() || generating) return;
if (!model) { alert("No model selected"); return; }
if (!server.trim()) { alert("Configure a local or remote API before sending messages"); return; }
setGenerating(true);
setStreamContent(null);
setStreamContent("");
setStreamReasoning("");
setStreamThinkingOpen(false);
setShowLiveReasoning(false);
const cid = fromChatId ?? getOrCreateChat();
const cid = fromChatId ?? getOrCreateChat(text);
// Save user message
db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)", [cid, text]);
const count = db.get<{ c: number }>("SELECT COUNT(*) as c FROM messages WHERE chat_id = ?", [cid]);
if (count?.c === 1) updateChatTitle(cid, text);
if (count?.c === 1) ensureChatTitle(cid, text);
loadMessages();
loadMessages(cid);
onChatUpdated();
const apiMessages = buildApiMessages(cid);
const headers: Record<string, string> = { "Content-Type": "application/json" };
@@ -139,7 +158,14 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
try {
const chunk = JSON.parse(data);
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) { fullContent += delta; setStreamContent(fullContent); }
if (delta) {
fullContent += delta;
const parsed = parseAssistantResponse(fullContent);
setStreamContent(parsed.content);
setStreamReasoning(parsed.reasoning);
setStreamThinkingOpen(parsed.thinkingOpen);
if (parsed.reasoning) setShowLiveReasoning(true);
}
if (chunk.usage) usage = chunk.usage;
} catch {}
}
@@ -148,10 +174,30 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
const data = await res.json();
fullContent = data.choices?.[0]?.message?.content || "";
usage = data.usage;
const parsed = parseAssistantResponse(fullContent);
setStreamContent(parsed.content);
setStreamReasoning(parsed.reasoning);
setStreamThinkingOpen(parsed.thinkingOpen);
if (parsed.reasoning) setShowLiveReasoning(true);
}
// Save assistant message
db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)", [cid, fullContent]);
const parsed = parseAssistantResponse(fullContent);
const finalContent = parsed.content.trim() || fullContent.trim();
db.run(
`INSERT INTO messages (
chat_id, role, content, reasoning_content, prompt_tokens, completion_tokens, total_tokens, tokens_per_second
) VALUES (?, 'assistant', ?, ?, ?, ?, ?, ?)`,
[
cid,
finalContent,
parsed.content.trim() ? parsed.reasoning : null,
usage?.prompt_tokens ?? 0,
usage?.completion_tokens ?? 0,
usage?.total_tokens ?? 0,
usage?.tokens_per_second ?? null,
],
);
db.run("UPDATE chats SET updated_at = strftime('%s','now') WHERE id = ?", [cid]);
if (usage?.tokens_per_second) setTps(usage.tokens_per_second);
onChatUpdated();
@@ -162,14 +208,18 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
cid,
`⚠️ Error: ${err.message}`,
]);
onChatUpdated();
}
} finally {
setGenerating(false);
setStreamContent(null);
loadMessages();
setStreamContent("");
setStreamReasoning("");
setStreamThinkingOpen(false);
setShowLiveReasoning(false);
loadMessages(cid);
abortRef.current = null;
}
}, [generating, model, server, apiKey, useStream, db, getOrCreateChat, updateChatTitle, buildApiMessages, loadMessages, onChatUpdated]);
}, [generating, model, server, apiKey, useStream, db, getOrCreateChat, ensureChatTitle, buildApiMessages, loadMessages, onChatUpdated]);
const handleSend = () => {
const text = input.trim();
@@ -203,6 +253,13 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
{/* Top bar */}
<div className="chat-topbar">
<div className="chat-topbar-left">
{chatId !== null && chatTotals.total_tokens > 0 && (
<div className="chat-token-summary">
<span>Sent {chatTotals.prompt_tokens}</span>
<span>Generated {chatTotals.completion_tokens}</span>
<span>Total {chatTotals.total_tokens}</span>
</div>
)}
{tps !== null && (
<span className="tps-badge">{tps.toFixed(1)} tok/s</span>
)}
@@ -241,17 +298,43 @@ export default function ChatView({ db, chatId, server, apiKey, model, onChatCrea
))}
{/* Live streaming bubble */}
{generating && streamContent !== null && (
{generating && (streamReasoning || streamContent || streamThinkingOpen) && (
<div className="msg-wrapper assistant">
<div
className="msg-bubble"
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamContent) }}
/>
{(streamReasoning || streamThinkingOpen) && (
<div className="reasoning-panel live-reasoning-panel">
<button
className="reasoning-toggle"
onClick={() => setShowLiveReasoning((value) => !value)}
type="button"
>
<span>{showLiveReasoning ? "▼" : "▶"}</span>
<span>{streamThinkingOpen ? "Thinking..." : "Thinking"}</span>
</button>
{showLiveReasoning && streamReasoning && (
<div
className="reasoning-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamReasoning) }}
/>
)}
</div>
)}
{streamContent && (
<div
className="msg-bubble"
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamContent) }}
/>
)}
{!streamContent && streamThinkingOpen && (
<div className="thinking">
<span /><span /><span />
</div>
)}
</div>
)}
{/* Thinking indicator */}
{generating && streamContent === null && (
{generating && !streamReasoning && !streamContent && !streamThinkingOpen && (
<div className="msg-wrapper assistant">
<div className="thinking">
<span /><span /><span />
@@ -66,6 +66,58 @@
.btn-ghost:hover { color: var(--text2); }
/* Message actions */
.reasoning-panel {
width: 100%;
margin-bottom: 8px;
}
.reasoning-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
background: rgba(124, 158, 248, 0.08);
color: var(--text2);
border: 1px solid var(--border);
border-radius: 999px;
padding: 6px 12px;
font-size: 0.75rem;
cursor: pointer;
font-family: inherit;
}
.reasoning-toggle:hover {
background: rgba(124, 158, 248, 0.12);
color: var(--text);
}
.reasoning-content {
margin-top: 8px;
padding: 12px 14px;
background: rgba(124, 158, 248, 0.06);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text2);
font-size: 0.84rem;
line-height: 1.6;
}
.message-metrics {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
color: var(--text3);
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
}
.message-metrics span {
background: var(--bg3);
border: 1px solid var(--border);
border-radius: 999px;
padding: 3px 8px;
}
.msg-actions {
display: flex;
gap: 4px;
+54 -1
View File
@@ -14,8 +14,10 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(message.content);
const [copied, setCopied] = useState(false);
const [showReasoning, setShowReasoning] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const bubbleRef = useRef<HTMLDivElement>(null);
const reasoningRef = useRef<HTMLDivElement>(null);
// Wire up copy-code buttons inside rendered markdown
useEffect(() => {
@@ -33,6 +35,21 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
});
});
useEffect(() => {
if (!reasoningRef.current) return;
const buttons = reasoningRef.current.querySelectorAll<HTMLButtonElement>(".copy-code-btn");
buttons.forEach((btn) => {
btn.onclick = () => {
const code = btn.getAttribute("data-code") || "";
navigator.clipboard.writeText(code).then(() => {
btn.textContent = "Copied!";
btn.style.color = "var(--success)";
setTimeout(() => { btn.textContent = "Copy"; btn.style.color = ""; }, 2000);
});
};
});
});
const autoResize = () => {
const el = textareaRef.current;
if (!el) return;
@@ -66,6 +83,13 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
});
};
const hasReasoning = Boolean(message.reasoning_content?.trim());
const hasUsage = message.role === "assistant" && (
(message.prompt_tokens ?? 0) > 0 ||
(message.completion_tokens ?? 0) > 0 ||
(message.total_tokens ?? 0) > 0
);
return (
<div className={`msg-wrapper ${message.role}`}>
{editing ? (
@@ -94,6 +118,26 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
</div>
) : (
<>
{hasReasoning && (
<div className="reasoning-panel">
<button
className="reasoning-toggle"
onClick={() => setShowReasoning((value) => !value)}
type="button"
>
<span>{showReasoning ? "▼" : "▶"}</span>
<span>Thinking</span>
</button>
{showReasoning && (
<div
ref={reasoningRef}
className="reasoning-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.reasoning_content || "") }}
/>
)}
</div>
)}
{message.role === "assistant" ? (
<div
ref={bubbleRef}
@@ -101,7 +145,16 @@ export default function MessageBubble({ message, onDelete, onEditAndResend, onEd
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
/>
) : (
<div className="msg-bubble">{message.content}</div>
<div className="msg-bubble">{message.content}</div>
)}
{hasUsage && (
<div className="message-metrics">
<span>Sent {message.prompt_tokens ?? 0}</span>
<span>Generated {message.completion_tokens ?? 0}</span>
<span>Total {message.total_tokens ?? 0}</span>
{message.tokens_per_second ? <span>{message.tokens_per_second.toFixed(1)} tok/s</span> : null}
</div>
)}
<div className="msg-actions">
+8 -6
View File
@@ -1,7 +1,9 @@
.sidebar {
width: 280px;
min-width: 280px;
background: var(--bg2);
background:
radial-gradient(circle at top left, rgba(72, 166, 255, 0.12), transparent 26%),
var(--bg2);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
@@ -29,7 +31,7 @@
.brand-icon {
width: 28px;
height: 28px;
background: linear-gradient(135deg, var(--accent), #7c3aed);
background: linear-gradient(135deg, #2b88e6, #49c0ff);
border-radius: 8px;
display: flex;
align-items: center;
@@ -43,7 +45,7 @@
font-size: 15px;
font-weight: 800;
letter-spacing: -0.02em;
background: linear-gradient(135deg, #e8e8ed, #9ca3af);
background: linear-gradient(135deg, #f5fbff, #89b5d9);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@@ -196,7 +198,7 @@
.new-chat-btn {
width: 100%;
background: linear-gradient(135deg, var(--accent), #7c3aed);
background: linear-gradient(135deg, #2b88e6, #49c0ff);
color: #fff;
border: none;
border-radius: var(--radius-sm);
@@ -211,12 +213,12 @@
transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s;
font-family: inherit;
letter-spacing: 0.01em;
box-shadow: 0 2px 10px rgba(91,106,240,0.25);
box-shadow: 0 2px 10px rgba(72,166,255,0.25);
}
.new-chat-btn:hover {
opacity: 0.9;
box-shadow: 0 4px 16px rgba(91,106,240,0.4);
box-shadow: 0 4px 16px rgba(72,166,255,0.4);
}
.new-chat-btn:active {
+36 -47
View File
@@ -1,7 +1,8 @@
import { useState } from "react";
import type { Chat, ModelInfo, ServerStatus } from "../lib/types";
import type { ProcessStatus, ServerConfig } from "../hooks/useServerProcess";
import ServerPanel from "./ServerPanel";
import RuntimeSettingsDrawer from "../features/runtime/components/RuntimeSettingsDrawer";
import RuntimeStatusCard from "../features/runtime/components/RuntimeStatusCard";
import type { RuntimeConfig, RuntimeSnapshot } from "../features/runtime/types";
import "./Sidebar.css";
interface Props {
@@ -10,33 +11,31 @@ interface Props {
onSelectChat: (id: number) => void;
onNewChat: () => void;
onDeleteChat: (id: number) => void;
server: string;
onServerChange: (v: string) => void;
apiKey: string;
onApiKeyChange: (v: string) => void;
models: ModelInfo[];
selectedModel: string;
onModelChange: (v: string) => void;
status: ServerStatus;
onRefreshModels: () => void;
serverProcess: {
status: ProcessStatus;
message: string;
start: (cfg: ServerConfig) => void;
stop: () => void;
runtime: {
snapshot: RuntimeSnapshot;
loading: boolean;
busy: boolean;
saveConfig: (patch: Partial<RuntimeConfig>) => Promise<RuntimeSnapshot>;
startLocal: () => Promise<unknown>;
stopLocal: () => Promise<unknown>;
};
}
export default function Sidebar({
chats, currentChatId, onSelectChat, onNewChat, onDeleteChat,
server, onServerChange, apiKey, onApiKeyChange,
models, selectedModel, onModelChange, status, onRefreshModels,
serverProcess,
runtime,
}: Props) {
const [showSettings, setShowSettings] = useState(false);
const [showRuntimeSettings, setShowRuntimeSettings] = useState(false);
return (
<aside className="sidebar">
<>
<aside className="sidebar">
{/* ── Header ── */}
<div className="sidebar-header">
<div className="sidebar-brand">
@@ -45,42 +44,20 @@ export default function Sidebar({
</div>
<button
className="icon-btn"
onClick={() => setShowSettings(!showSettings)}
title="Connection settings"
onClick={() => setShowRuntimeSettings(true)}
title="Runtime settings"
>
⚙
</button>
</div>
{/* ── Connection settings ── */}
{showSettings && (
<div className="settings-panel">
<label className="settings-label">Server URL</label>
<input
className="settings-input"
type="text"
value={server}
onChange={(e) => onServerChange(e.target.value)}
onBlur={onRefreshModels}
placeholder="http://127.0.0.1:8080"
/>
<label className="settings-label">API Key (optional)</label>
<input
className="settings-input"
type="password"
value={apiKey}
onChange={(e) => onApiKeyChange(e.target.value)}
placeholder="sk-…"
/>
</div>
)}
{/* ── Local server control ── */}
<ServerPanel
status={serverProcess.status}
message={serverProcess.message}
onStart={serverProcess.start}
onStop={serverProcess.stop}
<RuntimeStatusCard
snapshot={runtime.snapshot}
loading={runtime.loading}
busy={runtime.busy}
onOpenSettings={() => setShowRuntimeSettings(true)}
onStart={() => { void runtime.startLocal(); }}
onStop={() => { void runtime.stopLocal(); }}
/>
{/* ── Active model selector ── */}
@@ -144,6 +121,18 @@ export default function Sidebar({
<div className="sidebar-footer">
<span className="footer-version">ForgeLM v0.1.0</span>
</div>
</aside>
</aside>
<RuntimeSettingsDrawer
open={showRuntimeSettings}
snapshot={runtime.snapshot}
busy={runtime.busy}
onClose={() => setShowRuntimeSettings(false)}
onSave={runtime.saveConfig}
onStart={() => runtime.startLocal()}
onStop={() => runtime.stopLocal()}
/>
</>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { invoke } from "@tauri-apps/api/core";
import { createDefaultRuntimeSnapshot } from "./runtime-utils";
import type {
RuntimeConfig,
RuntimeGpuInfo,
RuntimeModelFile,
RuntimePickDirResult,
RuntimeSnapshot,
} from "./types";
const STORAGE_KEY = "forgelm_runtime_snapshot_v1";
function canUseLocalStorage(): boolean {
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
}
function readFallbackSnapshot(): RuntimeSnapshot {
if (!canUseLocalStorage()) {
return createDefaultRuntimeSnapshot();
}
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
return createDefaultRuntimeSnapshot();
}
try {
return { ...createDefaultRuntimeSnapshot(), ...JSON.parse(raw) } as RuntimeSnapshot;
} catch {
return createDefaultRuntimeSnapshot();
}
}
function writeFallbackSnapshot(snapshot: RuntimeSnapshot): RuntimeSnapshot {
if (canUseLocalStorage()) {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
}
return snapshot;
}
export async function bootstrapRuntime(): Promise<RuntimeSnapshot> {
try {
return await invoke<RuntimeSnapshot>("bootstrap_runtime_command");
} catch {
return readFallbackSnapshot();
}
}
export async function getRuntimeSnapshot(): Promise<RuntimeSnapshot> {
try {
return await invoke<RuntimeSnapshot>("get_runtime_snapshot_command");
} catch {
return readFallbackSnapshot();
}
}
export async function saveRuntimeConfig(config: RuntimeConfig): Promise<RuntimeSnapshot> {
try {
return await invoke<RuntimeSnapshot>("save_runtime_config_command_handler", { config });
} catch {
const snapshot = readFallbackSnapshot();
return writeFallbackSnapshot({ ...snapshot, config });
}
}
export async function startRuntime(): Promise<RuntimeSnapshot> {
try {
return await invoke<RuntimeSnapshot>("start_runtime_command");
} catch {
const snapshot = readFallbackSnapshot();
return writeFallbackSnapshot({
...snapshot,
status: "error",
last_error: { code: "tauri_unavailable", message: "Local runtime control requires the Tauri desktop app." },
});
}
}
export async function stopRuntime(): Promise<RuntimeSnapshot> {
try {
return await invoke<RuntimeSnapshot>("stop_runtime_command");
} catch {
const snapshot = readFallbackSnapshot();
return writeFallbackSnapshot({ ...snapshot, status: "stopped", process_running: false, ready: false });
}
}
export async function queryRuntimeGpu(): Promise<RuntimeGpuInfo | null> {
try {
return await invoke<RuntimeGpuInfo>("query_gpu");
} catch {
return null;
}
}
export async function scanRuntimeModels(dir: string): Promise<RuntimeModelFile[]> {
return invoke<RuntimeModelFile[]>("scan_models", { dir });
}
export async function pickRuntimeModelsDir(): Promise<RuntimePickDirResult | null> {
try {
return await invoke<RuntimePickDirResult>("pick_models_dir");
} catch (error) {
if (String(error) === "cancelled") {
return null;
}
throw error;
}
}
export async function pickRuntimeModelFiles(): Promise<RuntimeModelFile[]> {
try {
return await invoke<RuntimeModelFile[]>("pick_model_files");
} catch (error) {
if (String(error) === "cancelled") {
return [];
}
throw error;
}
}
@@ -0,0 +1,355 @@
.runtime-drawer-shell {
position: fixed;
inset: 0;
z-index: 40;
display: flex;
justify-content: flex-end;
}
.runtime-drawer-backdrop {
flex: 1;
border: 0;
background: rgba(5, 6, 10, 0.58);
backdrop-filter: blur(6px);
}
.runtime-drawer {
width: min(560px, 100%);
height: 100%;
display: flex;
flex-direction: column;
background:
radial-gradient(circle at top right, rgba(72, 166, 255, 0.14), transparent 24%),
linear-gradient(180deg, #10161f, #0b1017 72%);
border-left: 1px solid rgba(255, 255, 255, 0.07);
box-shadow: -28px 0 70px rgba(0, 0, 0, 0.48);
}
.runtime-drawer-header,
.runtime-drawer-footer {
padding: 20px 22px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.runtime-drawer-footer {
border-bottom: 0;
border-top: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
gap: 10px;
}
.runtime-drawer-kicker {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #78bdf4;
margin-bottom: 6px;
}
.runtime-drawer-header h2 {
font-size: 28px;
line-height: 1.1;
letter-spacing: -0.03em;
}
.runtime-drawer-copy {
margin-top: 10px;
max-width: 34ch;
color: var(--text2);
line-height: 1.55;
}
.runtime-drawer-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.runtime-drawer-close {
width: 36px;
height: 36px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
font-size: 26px;
line-height: 1;
cursor: pointer;
}
.runtime-drawer-body {
flex: 1;
overflow-y: auto;
padding: 20px 22px 28px;
}
.runtime-drawer-section {
margin-bottom: 18px;
}
.runtime-section-card,
.runtime-overview-card {
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 18px;
background: rgba(255, 255, 255, 0.035);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.runtime-section-card {
padding: 16px;
}
.runtime-overview-card {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
padding: 18px;
margin-bottom: 18px;
}
.runtime-overview-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
margin-bottom: 8px;
}
.runtime-overview-value {
color: var(--text);
font-size: 14px;
line-height: 1.45;
}
.runtime-overview-mono,
.runtime-static-value-mono {
font-family: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
font-size: 13px;
}
.runtime-drawer-section-title,
.runtime-field-label {
display: block;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
margin-bottom: 8px;
}
.runtime-segmented {
display: flex;
gap: 8px;
}
.runtime-segmented-btn,
.runtime-footer-btn {
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: var(--text);
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: border-color 0.12s ease, background 0.12s ease, transform 0.12s ease;
}
.runtime-segmented-btn {
flex: 1;
padding: 11px 12px;
}
.runtime-segmented-btn.active {
border-color: rgba(72, 166, 255, 0.55);
background: rgba(72, 166, 255, 0.16);
}
.runtime-field-input,
.runtime-static-value {
width: 100%;
min-height: 44px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: var(--text);
padding: 11px 13px;
font-size: 14px;
font-family: inherit;
}
.runtime-static-value {
display: flex;
align-items: center;
}
.runtime-field-select {
appearance: none;
}
.runtime-static-value-path {
word-break: break-all;
}
.runtime-grid-two {
display: grid;
gap: 12px;
}
.runtime-field-row {
display: flex;
justify-content: space-between;
gap: 14px;
align-items: flex-end;
margin-bottom: 14px;
}
.runtime-field-row-top {
align-items: flex-start;
}
.runtime-field-support {
color: var(--text2);
font-size: 13px;
line-height: 1.5;
}
.runtime-inline-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.runtime-inline-btn,
.runtime-preset,
.runtime-chip {
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
font-family: inherit;
cursor: pointer;
}
.runtime-inline-btn {
min-height: 40px;
padding: 0 12px;
font-size: 13px;
font-weight: 700;
}
.runtime-inline-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.runtime-chip-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.runtime-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
font-size: 12px;
}
.runtime-chip span {
color: var(--text3);
}
.runtime-preset-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
}
.runtime-preset {
padding: 8px 12px;
font-size: 12px;
font-weight: 700;
}
.runtime-preset.active {
border-color: rgba(72, 166, 255, 0.55);
background: rgba(72, 166, 255, 0.16);
}
.runtime-checkbox-row {
display: flex;
gap: 10px;
align-items: flex-start;
color: var(--text2);
font-size: 14px;
line-height: 1.5;
}
.runtime-error-banner {
border-radius: 12px;
border: 1px solid rgba(240, 64, 64, 0.18);
background: rgba(240, 64, 64, 0.08);
color: #ffb7b7;
padding: 12px 14px;
line-height: 1.5;
}
.runtime-warning-banner {
border-radius: 12px;
border: 1px solid rgba(243, 211, 139, 0.18);
background: rgba(243, 211, 139, 0.08);
color: #f3d38b;
padding: 12px 14px;
line-height: 1.5;
}
.runtime-footer-btn {
flex: 1;
padding: 12px 14px;
}
.runtime-footer-btn:active:not(:disabled),
.runtime-segmented-btn:active:not(:disabled) {
transform: scale(0.98);
}
.runtime-footer-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.runtime-footer-btn-secondary:hover:not(:disabled) {
border-color: var(--accent);
}
.runtime-footer-btn-primary {
color: #fff;
border-color: rgba(72, 166, 255, 0.55);
background: linear-gradient(135deg, #2c8ce8, #49c0ff);
}
@media (max-width: 720px) {
.runtime-drawer {
width: 100%;
}
.runtime-overview-card,
.runtime-grid-two,
.runtime-field-row {
grid-template-columns: 1fr;
flex-direction: column;
align-items: stretch;
}
.runtime-drawer-footer {
flex-direction: column;
}
}
@@ -0,0 +1,480 @@
import { useEffect, useMemo, useState } from "react";
import {
pickRuntimeModelFiles,
pickRuntimeModelsDir,
queryRuntimeGpu,
scanRuntimeModels,
} from "../api";
import type {
RuntimeConfig,
RuntimeGpuInfo,
RuntimeModelFile,
RuntimeSnapshot,
} from "../types";
import "./RuntimeSettingsDrawer.css";
const CTX_PRESETS = [2048, 4096, 8192, 16384, 32768, 65536];
function pathName(path: string | null | undefined): string {
if (!path) return "";
const parts = path.split(/[\\/]/).filter(Boolean);
return parts[parts.length - 1] || path;
}
function toModelFile(path: string): RuntimeModelFile {
return { name: pathName(path) || path, path };
}
function dedupeModels(models: RuntimeModelFile[]): RuntimeModelFile[] {
const seen = new Set<string>();
return models.filter((model) => {
if (seen.has(model.path)) return false;
seen.add(model.path);
return true;
});
}
function chooseModel(current: string, scanned: RuntimeModelFile[], extra: RuntimeModelFile[]): string {
const nextChoices = [...scanned, ...extra].map((model) => model.path);
if (current && (nextChoices.length === 0 || nextChoices.includes(current))) {
return current;
}
return scanned[0]?.path || extra[0]?.path || "";
}
interface Props {
open: boolean;
snapshot: RuntimeSnapshot;
busy: boolean;
onClose: () => void;
onSave: (patch: Partial<RuntimeConfig>) => Promise<RuntimeSnapshot>;
onStart: () => Promise<unknown>;
onStop: () => Promise<unknown>;
}
export default function RuntimeSettingsDrawer({
open,
snapshot,
busy,
onClose,
onSave,
onStart,
onStop,
}: Props) {
const [mode, setMode] = useState(snapshot.config.connection_mode);
const [startPolicy, setStartPolicy] = useState(snapshot.config.start_policy);
const [serverUrl, setServerUrl] = useState(snapshot.config.server_url);
const [apiKey, setApiKey] = useState(snapshot.config.api_key);
const [modelsDir, setModelsDir] = useState(snapshot.config.models_dir || "");
const [selectedModel, setSelectedModel] = useState(snapshot.config.model_path || "");
const [addr, setAddr] = useState(snapshot.config.addr);
const [gpuLayers, setGpuLayers] = useState(snapshot.config.gpu_layers);
const [ctxSize, setCtxSize] = useState(snapshot.config.ctx_size);
const [scannedModels, setScannedModels] = useState<RuntimeModelFile[]>([]);
const [extraModels, setExtraModels] = useState<RuntimeModelFile[]>([]);
const [gpuInfo, setGpuInfo] = useState<RuntimeGpuInfo | null>(null);
const [gpuError, setGpuError] = useState("");
const [localError, setLocalError] = useState("");
const [scanning, setScanning] = useState(false);
useEffect(() => {
if (!open) return;
const extra = snapshot.config.extra_model_paths.map(toModelFile);
setMode(snapshot.config.connection_mode);
setStartPolicy(snapshot.config.start_policy);
setServerUrl(snapshot.config.server_url);
setApiKey(snapshot.config.api_key);
setModelsDir(snapshot.config.models_dir || "");
setSelectedModel(snapshot.config.model_path || "");
setAddr(snapshot.config.addr);
setGpuLayers(snapshot.config.gpu_layers);
setCtxSize(snapshot.config.ctx_size);
setExtraModels(extra);
setScannedModels([]);
setLocalError("");
setGpuError("");
if (snapshot.config.models_dir?.trim()) {
setScanning(true);
scanRuntimeModels(snapshot.config.models_dir)
.then((models) => {
setScannedModels(models);
setSelectedModel((current) => chooseModel(current || snapshot.config.model_path || "", models, extra));
})
.catch((error) => setLocalError(String(error)))
.finally(() => setScanning(false));
}
queryRuntimeGpu()
.then((info) => {
setGpuInfo(info);
if (info && snapshot.config.gpu_layers === 0) {
setGpuLayers(info.recommended_layers);
}
})
.catch((error) => setGpuError(String(error)));
}, [open, snapshot]);
const allModels = useMemo(() => {
const merged = dedupeModels([...scannedModels, ...extraModels]);
if (selectedModel && !merged.some((model) => model.path === selectedModel)) {
return [toModelFile(selectedModel), ...merged];
}
return merged;
}, [extraModels, scannedModels, selectedModel]);
const savedLocalModel = snapshot.config.model_path?.trim() || "";
const hasPendingModeChange = mode !== snapshot.config.connection_mode;
const maxLayers = gpuInfo?.recommended_layers ?? 200;
const startReason =
mode !== "local"
? "Save mode before starting the local runtime."
: !snapshot.binary_path
? "Build or bundle the local API binary before starting."
: !selectedModel.trim()
? "Choose a model before starting the local runtime."
: "";
if (!open) return null;
const buildPatch = (): Partial<RuntimeConfig> => ({
connection_mode: mode,
start_policy: startPolicy,
server_url: serverUrl.trim(),
api_key: apiKey.trim(),
models_dir: mode === "local" ? (modelsDir.trim() || null) : snapshot.config.models_dir,
model_path: mode === "local" ? (selectedModel.trim() || null) : snapshot.config.model_path,
addr: mode === "local" ? addr.trim() || snapshot.config.addr : snapshot.config.addr,
gpu_layers: mode === "local" ? Math.max(0, Math.min(gpuLayers, maxLayers)) : snapshot.config.gpu_layers,
ctx_size: mode === "local" ? Math.max(512, ctxSize || 4096) : snapshot.config.ctx_size,
extra_model_paths: mode === "local" ? extraModels.map((model) => model.path) : snapshot.config.extra_model_paths,
});
const handleSave = async () => {
setLocalError("");
try {
await onSave(buildPatch());
} catch (error) {
setLocalError(String(error));
}
};
const handleSaveAndStart = async () => {
setLocalError("");
if (snapshot.process_running) {
try {
await onStop();
} catch (error) {
setLocalError(String(error));
}
return;
}
try {
const next = await onSave(buildPatch());
if (next.config.connection_mode !== "local") return;
if (!next.config.model_path?.trim()) return;
if (next.last_error?.code === "restart_required") return;
await onStart();
} catch (error) {
setLocalError(String(error));
}
};
const handleRescan = async (dir = modelsDir) => {
if (!dir.trim()) return;
setScanning(true);
setLocalError("");
try {
const models = await scanRuntimeModels(dir.trim());
setScannedModels(models);
setSelectedModel((current) => chooseModel(current, models, extraModels));
} catch (error) {
setLocalError(String(error));
setScannedModels([]);
} finally {
setScanning(false);
}
};
const handlePickDir = async () => {
setLocalError("");
try {
const result = await pickRuntimeModelsDir();
if (!result) return;
setModelsDir(result.dir);
setScannedModels(result.models);
setSelectedModel((current) => chooseModel(current, result.models, extraModels));
} catch (error) {
setLocalError(String(error));
}
};
const handlePickFiles = async () => {
setLocalError("");
try {
const picked = await pickRuntimeModelFiles();
if (picked.length === 0) return;
setExtraModels((current) => {
const next = dedupeModels([...current, ...picked]);
if (!selectedModel) {
setSelectedModel(next[0]?.path || "");
}
return next;
});
} catch (error) {
setLocalError(String(error));
}
};
const handleRemoveExtra = (path: string) => {
setExtraModels((current) => {
const next = current.filter((model) => model.path !== path);
setSelectedModel((selected) => (selected === path ? chooseModel("", scannedModels, next) : selected));
return next;
});
};
return (
<div className="runtime-drawer-shell" role="dialog" aria-modal="true" aria-label="Runtime settings">
<button className="runtime-drawer-backdrop" onClick={onClose} aria-label="Close runtime settings" />
<aside className="runtime-drawer">
<div className="runtime-drawer-header">
<div>
<div className="runtime-drawer-kicker">Runtime workspace</div>
<h2>One control plane for startup, models, and API routing</h2>
<p className="runtime-drawer-copy">
Save the config once, then start the managed API from the same surface.
</p>
</div>
<button className="runtime-drawer-close" onClick={onClose} type="button" aria-label="Close runtime settings">
×
</button>
</div>
<div className="runtime-drawer-body">
<section className="runtime-overview-card">
<div>
<div className="runtime-overview-label">Current status</div>
<div className="runtime-overview-value">{snapshot.status === "ready" ? "Local API ready" : snapshot.config.connection_mode === "remote" ? "Remote API mode" : "Local API setup"}</div>
</div>
<div>
<div className="runtime-overview-label">Endpoint</div>
<div className="runtime-overview-value runtime-overview-mono">{mode === "remote" ? (serverUrl.trim() || "Not configured") : `http://${addr.trim() || snapshot.config.addr}`}</div>
</div>
<div>
<div className="runtime-overview-label">Saved model</div>
<div className="runtime-overview-value">{savedLocalModel ? pathName(savedLocalModel) : "No model saved"}</div>
</div>
</section>
<section className="runtime-drawer-section">
<div className="runtime-drawer-section-title">Connection mode</div>
<div className="runtime-segmented" role="tablist" aria-label="Connection mode">
<button className={`runtime-segmented-btn${mode === "local" ? " active" : ""}`} onClick={() => setMode("local")} type="button">
Local API
</button>
<button className={`runtime-segmented-btn${mode === "remote" ? " active" : ""}`} onClick={() => setMode("remote")} type="button">
Remote API
</button>
</div>
</section>
{mode === "remote" ? (
<section className="runtime-drawer-section runtime-section-card">
<label className="runtime-field-label" htmlFor="runtime-remote-url">Remote server URL</label>
<input
id="runtime-remote-url"
className="runtime-field-input"
value={serverUrl}
onChange={(event) => setServerUrl(event.target.value)}
placeholder="https://api.example.com"
type="text"
/>
<label className="runtime-field-label" htmlFor="runtime-api-key">API key</label>
<input
id="runtime-api-key"
className="runtime-field-input"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder="sk-..."
type="password"
/>
</section>
) : (
<>
<section className="runtime-drawer-section runtime-section-card runtime-grid-two">
<div>
<div className="runtime-field-label">Managed endpoint</div>
<div className="runtime-static-value runtime-static-value-mono">http://{addr.trim() || snapshot.config.addr}</div>
</div>
<div>
<div className="runtime-field-label">Binary discovery</div>
<div className="runtime-static-value runtime-static-value-path">{snapshot.binary_path || "Binary not found yet"}</div>
</div>
</section>
<section className="runtime-drawer-section runtime-section-card">
<div className="runtime-field-row">
<div>
<div className="runtime-field-label">Models folder</div>
<div className="runtime-static-value runtime-static-value-path">{modelsDir || "Choose a folder or add individual files"}</div>
</div>
<div className="runtime-inline-actions">
<button className="runtime-inline-btn" onClick={handlePickDir} type="button">Browse</button>
<button className="runtime-inline-btn" onClick={() => { void handleRescan(); }} type="button" disabled={!modelsDir.trim() || scanning}>
{scanning ? "Scanning..." : "Rescan"}
</button>
<button className="runtime-inline-btn" onClick={handlePickFiles} type="button">Add files</button>
</div>
</div>
<label className="runtime-field-label" htmlFor="runtime-model-select">Selected model</label>
<select
id="runtime-model-select"
className="runtime-field-input runtime-field-select"
value={selectedModel}
onChange={(event) => setSelectedModel(event.target.value)}
>
<option value="">Choose a model</option>
{allModels.map((model) => (
<option key={model.path} value={model.path}>{model.name}</option>
))}
</select>
{extraModels.length > 0 && (
<div className="runtime-chip-list">
{extraModels.map((model) => (
<button
key={model.path}
className="runtime-chip"
onClick={() => handleRemoveExtra(model.path)}
type="button"
title={`Remove ${model.name}`}
>
{model.name}
<span>×</span>
</button>
))}
</div>
)}
</section>
<section className="runtime-drawer-section runtime-section-card runtime-grid-two">
<div>
<label className="runtime-field-label" htmlFor="runtime-addr">Listen address</label>
<input
id="runtime-addr"
className="runtime-field-input"
value={addr}
onChange={(event) => setAddr(event.target.value)}
placeholder="127.0.0.1:8080"
type="text"
/>
</div>
<div>
<label className="runtime-field-label" htmlFor="runtime-context">Context size</label>
<input
id="runtime-context"
className="runtime-field-input"
min={512}
step={512}
value={ctxSize}
onChange={(event) => setCtxSize(Math.max(512, parseInt(event.target.value, 10) || 4096))}
type="number"
/>
</div>
</section>
<section className="runtime-drawer-section runtime-section-card">
<div className="runtime-field-row runtime-field-row-top">
<div>
<div className="runtime-field-label">GPU layers</div>
<div className="runtime-field-support">
{gpuInfo ? `${gpuInfo.name} · recommended ${gpuInfo.recommended_layers}` : gpuError || "GPU detection unavailable"}
</div>
</div>
{gpuInfo && (
<button className="runtime-inline-btn" onClick={() => setGpuLayers(gpuInfo.recommended_layers)} type="button">
Use recommended
</button>
)}
</div>
<input
className="runtime-field-input"
min={0}
max={maxLayers}
value={gpuLayers}
onChange={(event) => setGpuLayers(Math.max(0, Math.min(maxLayers, parseInt(event.target.value, 10) || 0)))}
type="number"
/>
<div className="runtime-preset-row">
{CTX_PRESETS.map((preset) => (
<button
key={preset}
className={`runtime-preset${ctxSize === preset ? " active" : ""}`}
onClick={() => setCtxSize(preset)}
type="button"
>
{preset >= 1024 ? `${preset / 1024}k` : preset}
</button>
))}
</div>
</section>
<section className="runtime-drawer-section runtime-section-card">
<label className="runtime-checkbox-row">
<input
checked={startPolicy === "on_app_launch"}
onChange={(event) => setStartPolicy(event.target.checked ? "on_app_launch" : "manual")}
type="checkbox"
/>
<span>Start the local API automatically when the desktop app opens</span>
</label>
</section>
</>
)}
{(localError || snapshot.last_error) && (
<section className="runtime-drawer-section">
<div className="runtime-error-banner">{localError || snapshot.last_error?.message}</div>
</section>
)}
{!snapshot.process_running && mode === "local" && startReason && (
<section className="runtime-drawer-section">
<div className="runtime-warning-banner">{startReason}</div>
</section>
)}
</div>
<div className="runtime-drawer-footer">
<button className="runtime-footer-btn runtime-footer-btn-secondary" onClick={() => { void handleSave(); }} type="button" disabled={busy}>
{busy ? "Saving..." : "Save settings"}
</button>
{mode === "local" && (
<button
className="runtime-footer-btn runtime-footer-btn-primary"
onClick={() => { void handleSaveAndStart(); }}
type="button"
disabled={busy || hasPendingModeChange || (!snapshot.process_running && Boolean(startReason))}
>
{hasPendingModeChange
? "Save mode before applying"
: snapshot.process_running
? "Stop local API"
: "Save and start local API"}
</button>
)}
</div>
</aside>
</div>
);
}
@@ -0,0 +1,159 @@
.runtime-card {
margin: 10px 12px 8px;
padding: 15px;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.08);
background:
radial-gradient(circle at top right, rgba(72, 166, 255, 0.18), transparent 40%),
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.01));
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.28);
}
.runtime-card-success {
border-color: rgba(48, 209, 88, 0.24);
}
.runtime-card-warning {
border-color: rgba(255, 214, 10, 0.24);
}
.runtime-card-danger {
border-color: rgba(240, 64, 64, 0.24);
}
.runtime-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.runtime-card-kicker {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
margin-bottom: 4px;
}
.runtime-card-title {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
}
.runtime-card-mode {
flex-shrink: 0;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 5px 8px;
border-radius: 999px;
border: 1px solid var(--border2);
color: var(--text2);
background: rgba(255, 255, 255, 0.04);
}
.runtime-card-mode-local {
color: #71c5ff;
border-color: rgba(113, 197, 255, 0.28);
}
.runtime-card-mode-remote {
color: #92e5c7;
border-color: rgba(146, 229, 199, 0.24);
}
.runtime-card-copy {
margin-top: 10px;
color: var(--text2);
font-size: 12px;
line-height: 1.5;
}
.runtime-card-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.runtime-card-meta span,
.runtime-card-note {
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: var(--text2);
font-size: 11px;
line-height: 1.3;
padding: 7px 10px;
}
.runtime-card-note {
display: block;
margin-top: 10px;
border-radius: 12px;
color: #f3d38b;
border-color: rgba(243, 211, 139, 0.16);
background: rgba(243, 211, 139, 0.08);
}
.runtime-card-error {
margin-top: 10px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(240, 64, 64, 0.2);
background: rgba(240, 64, 64, 0.08);
color: #ffb4b4;
font-size: 11px;
line-height: 1.5;
}
.runtime-card-actions {
display: flex;
gap: 8px;
margin-top: 14px;
}
.runtime-card-btn {
flex: 1;
border-radius: 10px;
border: 1px solid var(--border2);
padding: 9px 12px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
font-family: inherit;
transition: transform 0.12s ease, border-color 0.12s ease, background 0.12s ease;
}
.runtime-card-btn:active:not(:disabled) {
transform: scale(0.98);
}
.runtime-card-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.runtime-card-btn-secondary {
color: var(--text);
background: rgba(255, 255, 255, 0.04);
}
.runtime-card-btn-secondary:hover:not(:disabled) {
border-color: var(--accent);
}
.runtime-card-btn-primary {
color: #fff;
border-color: rgba(72, 166, 255, 0.6);
background: linear-gradient(135deg, #2b88e6, #46c2ff);
}
.runtime-card-btn-primary:hover:not(:disabled) {
background: linear-gradient(135deg, #3193f5, #55cbff);
}
@@ -0,0 +1,72 @@
import { getRuntimeStartGuard, getRuntimeStateLabel, getRuntimeStatusTone } from "../runtime-utils";
import type { RuntimeSnapshot } from "../types";
import "./RuntimeStatusCard.css";
function summarizePath(path: string | null): string {
if (!path) return "No model selected";
const segments = path.split(/[\\/]/).filter(Boolean);
return segments[segments.length - 1] || path;
}
interface Props {
snapshot: RuntimeSnapshot;
loading: boolean;
busy: boolean;
onOpenSettings: () => void;
onStart: () => void;
onStop: () => void;
}
export default function RuntimeStatusCard({ snapshot, loading, busy, onOpenSettings, onStart, onStop }: Props) {
const tone = getRuntimeStatusTone(snapshot);
const isLocal = snapshot.config.connection_mode === "local";
const startGuard = getRuntimeStartGuard(snapshot);
const needsSetup = isLocal && !snapshot.process_running && !startGuard.canStart;
const primaryAction = needsSetup ? onOpenSettings : isLocal && !snapshot.process_running ? onStart : onStop;
const primaryLabel = needsSetup ? "Set up" : isLocal && !snapshot.process_running ? "Start" : "Stop";
const primaryDisabled = busy || (isLocal && snapshot.process_running && snapshot.status === "stopping");
return (
<section className={`runtime-card runtime-card-${tone}`} aria-label="Runtime manager summary">
<div className="runtime-card-header">
<div>
<div className="runtime-card-kicker">Runtime Manager</div>
<div className="runtime-card-title">{loading ? "Loading runtime" : getRuntimeStateLabel(snapshot)}</div>
</div>
<span className={`runtime-card-mode runtime-card-mode-${snapshot.config.connection_mode}`}>
{snapshot.config.connection_mode === "local" ? "Local" : "Remote"}
</span>
</div>
<p className="runtime-card-copy">
{snapshot.config.connection_mode === "local"
? `Managed endpoint ${snapshot.resolved_url}`
: snapshot.config.server_url || "Configure a remote API endpoint"}
</p>
<div className="runtime-card-meta" aria-label="Runtime details">
<span>{isLocal ? summarizePath(snapshot.config.model_path) : "External provider"}</span>
<span>{snapshot.config.start_policy === "on_app_launch" ? "Auto-start" : "Manual start"}</span>
</div>
{needsSetup && <div className="runtime-card-note">{startGuard.reason}</div>}
{snapshot.last_error && (
<div className="runtime-card-error" role="status">
{snapshot.last_error.message}
</div>
)}
<div className="runtime-card-actions">
<button className="runtime-card-btn runtime-card-btn-secondary" onClick={onOpenSettings} type="button">
Manage
</button>
{snapshot.config.connection_mode === "local" && (
<button className="runtime-card-btn runtime-card-btn-primary" onClick={primaryAction} type="button" disabled={primaryDisabled}>
{busy ? "Working..." : primaryLabel}
</button>
)}
</div>
</section>
);
}
@@ -0,0 +1,379 @@
/* @vitest-environment jsdom */
import { useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { act } from "react-dom/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
bootstrapRuntime,
getRuntimeSnapshot,
pickRuntimeModelsDir,
saveRuntimeConfig,
startRuntime,
stopRuntime,
} from "../api";
import RuntimeSettingsDrawer from "./RuntimeSettingsDrawer";
import RuntimeStatusCard from "./RuntimeStatusCard";
import { useRuntimeManager } from "../useRuntimeManager";
import { createDefaultRuntimeSnapshot } from "../runtime-utils";
import type { RuntimeConfig, RuntimeSnapshot } from "../types";
type SnapshotOverrides = Partial<Omit<RuntimeSnapshot, "config">> & {
config?: Partial<RuntimeSnapshot["config"]>;
};
vi.mock("../api", () => ({
bootstrapRuntime: vi.fn(async () => createDefaultRuntimeSnapshot()),
getRuntimeSnapshot: vi.fn(async () => createDefaultRuntimeSnapshot()),
pickRuntimeModelFiles: vi.fn(async () => []),
pickRuntimeModelsDir: vi.fn(async () => null),
queryRuntimeGpu: vi.fn(async () => null),
saveRuntimeConfig: vi.fn(async (config) => ({ ...createDefaultRuntimeSnapshot(), config })),
scanRuntimeModels: vi.fn(async () => []),
startRuntime: vi.fn(async () => ({ ...createDefaultRuntimeSnapshot(), status: "ready", ready: true, process_running: true })),
stopRuntime: vi.fn(async () => createDefaultRuntimeSnapshot()),
}));
function makeSnapshot(overrides: SnapshotOverrides = {}): RuntimeSnapshot {
return {
...createDefaultRuntimeSnapshot(),
...overrides,
config: {
...createDefaultRuntimeSnapshot().config,
...overrides.config,
},
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
function RuntimeManagerHarness({ startOpen = false }: { startOpen?: boolean }) {
const runtime = useRuntimeManager();
const [drawerOpen, setDrawerOpen] = useState(startOpen);
return (
<>
<RuntimeStatusCard
snapshot={runtime.snapshot}
loading={runtime.loading}
busy={runtime.busy}
onOpenSettings={() => setDrawerOpen(true)}
onStart={() => {
void runtime.startLocal();
}}
onStop={() => {
void runtime.stopLocal();
}}
/>
<RuntimeSettingsDrawer
open={drawerOpen}
snapshot={runtime.snapshot}
busy={runtime.busy}
onClose={() => setDrawerOpen(false)}
onSave={runtime.saveConfig}
onStart={runtime.startLocal}
onStop={runtime.stopLocal}
/>
</>
);
}
describe("Runtime app surface", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.clearAllMocks();
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
});
it("routes incomplete local runtime setup to the settings drawer instead of trying to start", async () => {
const onOpenSettings = vi.fn();
const onStart = vi.fn();
await act(async () => {
root.render(
<RuntimeStatusCard
snapshot={makeSnapshot({
binary_path: "D:/bin/localllm.exe",
config: { model_path: null, connection_mode: "local" },
})}
loading={false}
busy={false}
onOpenSettings={onOpenSettings}
onStart={onStart}
onStop={vi.fn()}
/>,
);
});
const button = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Set up"));
expect(button).toBeTruthy();
await act(async () => {
button?.click();
});
expect(onOpenSettings).toHaveBeenCalledTimes(1);
expect(onStart).not.toHaveBeenCalled();
});
it("saves local runtime settings before starting from the unified drawer", async () => {
const onSave = vi.fn(async (patch) => makeSnapshot({
binary_path: "D:/bin/localllm.exe",
config: {
...patch,
connection_mode: "local",
model_path: "D:/models/demo.gguf",
},
}));
const onStart = vi.fn(async () => makeSnapshot({ status: "starting" }));
await act(async () => {
root.render(
<RuntimeSettingsDrawer
open
snapshot={makeSnapshot({
binary_path: "D:/bin/localllm.exe",
config: {
connection_mode: "local",
model_path: "D:/models/demo.gguf",
models_dir: "D:/models",
},
})}
busy={false}
onClose={vi.fn()}
onSave={onSave}
onStart={onStart}
onStop={vi.fn()}
/>,
);
});
const action = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Save and start local API"));
expect(action).toBeTruthy();
expect((action as HTMLButtonElement | undefined)?.disabled).toBe(false);
await act(async () => {
action?.click();
await Promise.resolve();
await Promise.resolve();
});
expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave.mock.calls[0]?.[0]).toMatchObject({
connection_mode: "local",
model_path: "D:/models/demo.gguf",
models_dir: "D:/models",
});
expect(onStart).toHaveBeenCalledTimes(1);
});
it("stops the local runtime from the same drawer action when the process is already running", async () => {
const onSave = vi.fn();
const onStop = vi.fn(async () => makeSnapshot({ status: "stopping", process_running: false }));
await act(async () => {
root.render(
<RuntimeSettingsDrawer
open
snapshot={makeSnapshot({
status: "ready",
process_running: true,
ready: true,
binary_path: "D:/bin/localllm.exe",
config: {
connection_mode: "local",
model_path: "D:/models/demo.gguf",
},
})}
busy={false}
onClose={vi.fn()}
onSave={onSave}
onStart={vi.fn()}
onStop={onStop}
/>,
);
});
const action = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Stop local API"));
expect(action).toBeTruthy();
await act(async () => {
action?.click();
await Promise.resolve();
});
expect(onStop).toHaveBeenCalledTimes(1);
expect(onSave).not.toHaveBeenCalled();
});
it("keeps the local primary CTA visible while switching from remote to local mode", async () => {
await act(async () => {
root.render(
<RuntimeSettingsDrawer
open
snapshot={makeSnapshot({
config: {
connection_mode: "remote",
server_url: "https://api.example.com",
api_key: "sk-demo",
},
})}
busy={false}
onClose={vi.fn()}
onSave={vi.fn(async () => makeSnapshot())}
onStart={vi.fn(async () => makeSnapshot({ status: "starting" }))}
onStop={vi.fn(async () => makeSnapshot({ status: "stopped" }))}
/>,
);
});
const localTab = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Local API"));
expect(localTab).toBeTruthy();
await act(async () => {
localTab?.click();
});
const action = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Save mode before applying"));
expect(action).toBeTruthy();
expect((action as HTMLButtonElement | undefined)?.disabled).toBe(true);
});
it("covers the saved local startup lifecycle from stopped to starting to ready", async () => {
const initialSnapshot = makeSnapshot({
binary_path: "D:/bin/localllm.exe",
config: {
connection_mode: "local",
model_path: "D:/models/demo.gguf",
start_policy: "on_app_launch",
},
});
const startDeferred = createDeferred<RuntimeSnapshot>();
vi.mocked(bootstrapRuntime).mockResolvedValue(initialSnapshot);
vi.mocked(getRuntimeSnapshot).mockResolvedValue(initialSnapshot);
vi.mocked(startRuntime).mockReturnValue(startDeferred.promise);
await act(async () => {
root.render(<RuntimeManagerHarness />);
await Promise.resolve();
});
expect(container.textContent).toContain("Local API offline");
expect(container.textContent).toContain("demo.gguf");
const startButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Start"));
expect(startButton).toBeTruthy();
await act(async () => {
startButton?.click();
});
expect(container.textContent).toContain("Starting local API");
expect(vi.mocked(startRuntime)).toHaveBeenCalledTimes(1);
await act(async () => {
startDeferred.resolve(makeSnapshot({
...initialSnapshot,
status: "ready",
process_running: true,
ready: true,
}));
await startDeferred.promise;
});
expect(container.textContent).toContain("Ready");
expect(container.textContent).toContain("Stop");
});
it("supports local onboarding from browsing a models folder through save and start", async () => {
const initialSnapshot = makeSnapshot({
binary_path: "D:/bin/localllm.exe",
config: {
connection_mode: "local",
model_path: null,
models_dir: null,
},
});
vi.mocked(pickRuntimeModelsDir).mockResolvedValue({
dir: "D:/models",
models: [{ name: "demo.gguf", path: "D:/models/demo.gguf" }],
});
vi.mocked(bootstrapRuntime).mockResolvedValue(initialSnapshot);
vi.mocked(getRuntimeSnapshot).mockResolvedValue(initialSnapshot);
vi.mocked(saveRuntimeConfig).mockImplementation(async (config: RuntimeConfig) => makeSnapshot({
...initialSnapshot,
config,
}));
vi.mocked(startRuntime).mockResolvedValue(makeSnapshot({
...initialSnapshot,
status: "ready",
process_running: true,
ready: true,
config: {
...initialSnapshot.config,
models_dir: "D:/models",
model_path: "D:/models/demo.gguf",
},
}));
await act(async () => {
root.render(<RuntimeManagerHarness startOpen />);
await Promise.resolve();
});
const browseButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Browse"));
expect(browseButton).toBeTruthy();
await act(async () => {
browseButton?.click();
await Promise.resolve();
});
const select = container.querySelector("#runtime-model-select") as HTMLSelectElement | null;
expect(select?.value).toBe("D:/models/demo.gguf");
expect(container.textContent).toContain("D:/models");
const action = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Save and start local API"));
expect(action).toBeTruthy();
expect((action as HTMLButtonElement | undefined)?.disabled).toBe(false);
await act(async () => {
action?.click();
await Promise.resolve();
await Promise.resolve();
});
expect(vi.mocked(saveRuntimeConfig)).toHaveBeenCalledTimes(1);
expect(vi.mocked(saveRuntimeConfig).mock.calls[0]?.[0]).toMatchObject({
connection_mode: "local",
models_dir: "D:/models",
model_path: "D:/models/demo.gguf",
});
expect(vi.mocked(startRuntime)).toHaveBeenCalledTimes(1);
expect(container.textContent).toContain("Local API ready");
expect(container.textContent).toContain("Stop local API");
});
});
@@ -0,0 +1,164 @@
import { describe, expect, it } from "vitest";
import { getRuntimeConnection, getRuntimeStartGuard, getRuntimeStateLabel } from "./runtime-utils";
import type { RuntimeSnapshot } from "./types";
function createSnapshot(overrides: Partial<RuntimeSnapshot> = {}): RuntimeSnapshot {
return {
status: "stopped",
ready: false,
process_running: false,
resolved_url: "http://127.0.0.1:8080",
binary_path: null,
last_error: null,
config: {
connection_mode: "local",
start_policy: "manual",
server_url: "",
api_key: "",
model_path: null,
models_dir: null,
addr: "127.0.0.1:8080",
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: [],
},
...overrides,
};
}
describe("getRuntimeConnection", () => {
it("prefers the resolved local runtime url in local mode", () => {
const connection = getRuntimeConnection(createSnapshot({
resolved_url: "http://127.0.0.1:9000",
config: {
connection_mode: "local",
start_policy: "manual",
server_url: "http://example.com:9999",
api_key: "",
model_path: null,
models_dir: null,
addr: "127.0.0.1:9000",
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: [],
},
}));
expect(connection.serverUrl).toBe("http://127.0.0.1:9000");
expect(connection.mode).toBe("local");
});
it("uses remote settings in remote mode", () => {
const connection = getRuntimeConnection(createSnapshot({
config: {
connection_mode: "remote",
start_policy: "manual",
server_url: "https://api.example.com",
api_key: "sk-demo",
model_path: null,
models_dir: null,
addr: "127.0.0.1:8080",
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: [],
},
}));
expect(connection.serverUrl).toBe("https://api.example.com");
expect(connection.apiKey).toBe("sk-demo");
expect(connection.mode).toBe("remote");
});
});
describe("getRuntimeStateLabel", () => {
it("reports ready snapshots as ready", () => {
expect(getRuntimeStateLabel(createSnapshot({ status: "ready", ready: true, process_running: true }))).toBe("Ready");
});
it("reports local background start progress", () => {
expect(getRuntimeStateLabel(createSnapshot({ status: "starting" }))).toBe("Starting local API");
});
it("reports remote mode without runtime ownership", () => {
expect(getRuntimeStateLabel(createSnapshot({
status: "stopped",
config: {
connection_mode: "remote",
start_policy: "manual",
server_url: "https://api.example.com",
api_key: "",
model_path: null,
models_dir: null,
addr: "127.0.0.1:8080",
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: [],
},
}))).toBe("Remote API");
});
});
describe("getRuntimeStartGuard", () => {
it("requires a saved model before starting the local runtime", () => {
const guard = getRuntimeStartGuard(createSnapshot({
binary_path: "D:/bin/localllm.exe",
config: {
connection_mode: "local",
start_policy: "manual",
server_url: "",
api_key: "",
model_path: null,
models_dir: null,
addr: "127.0.0.1:8080",
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: [],
},
}));
expect(guard.canStart).toBe(false);
expect(guard.reason.toLowerCase()).toContain("model");
});
it("allows starting when the saved local runtime config is complete", () => {
const guard = getRuntimeStartGuard(createSnapshot({
binary_path: "D:/bin/localllm.exe",
config: {
connection_mode: "local",
start_policy: "manual",
server_url: "",
api_key: "",
model_path: "D:/models/demo.gguf",
models_dir: "D:/models",
addr: "127.0.0.1:8080",
gpu_layers: 24,
ctx_size: 8192,
extra_model_paths: [],
},
}));
expect(guard.canStart).toBe(true);
expect(guard.reason).toBe("");
});
it("never offers managed start controls in remote mode", () => {
const guard = getRuntimeStartGuard(createSnapshot({
binary_path: "D:/bin/localllm.exe",
config: {
connection_mode: "remote",
start_policy: "manual",
server_url: "https://api.example.com",
api_key: "sk-demo",
model_path: null,
models_dir: null,
addr: "127.0.0.1:8080",
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: [],
},
}));
expect(guard.canStart).toBe(false);
expect(guard.reason.toLowerCase()).toContain("remote");
});
});
@@ -0,0 +1,119 @@
import type { RuntimeConnection, RuntimeSnapshot } from "./types";
const DEFAULT_LOCAL_URL = "http://127.0.0.1:8080";
function normalizeUrl(value: string | null | undefined): string {
const trimmed = (value || "").trim();
return trimmed ? trimmed.replace(/\/+$/, "") : "";
}
export function getRuntimeConnection(snapshot: RuntimeSnapshot): RuntimeConnection {
if (snapshot.config.connection_mode === "remote") {
return {
mode: "remote",
serverUrl: normalizeUrl(snapshot.config.server_url),
apiKey: snapshot.config.api_key || "",
managed: false,
};
}
return {
mode: "local",
serverUrl: normalizeUrl(snapshot.resolved_url) || DEFAULT_LOCAL_URL,
apiKey: snapshot.config.api_key || "",
managed: true,
};
}
export function getRuntimeStateLabel(snapshot: RuntimeSnapshot): string {
if (snapshot.config.connection_mode === "remote") {
return "Remote API";
}
switch (snapshot.status) {
case "ready":
return "Ready";
case "starting":
return "Starting local API";
case "stopping":
return "Stopping local API";
case "error":
return "Local API error";
case "degraded":
return "Attention needed";
default:
return "Local API offline";
}
}
export function getRuntimeStatusTone(snapshot: RuntimeSnapshot): "neutral" | "success" | "warning" | "danger" {
if (snapshot.config.connection_mode === "remote") {
return "neutral";
}
switch (snapshot.status) {
case "ready":
return "success";
case "starting":
case "stopping":
case "degraded":
return "warning";
case "error":
return "danger";
default:
return "neutral";
}
}
export interface RuntimeStartGuard {
canStart: boolean;
reason: string;
}
export function getRuntimeStartGuard(snapshot: RuntimeSnapshot): RuntimeStartGuard {
if (snapshot.config.connection_mode === "remote") {
return {
canStart: false,
reason: "Remote mode uses an external API and does not launch the local runtime.",
};
}
if (!snapshot.binary_path) {
return {
canStart: false,
reason: "Build or bundle the local API binary before starting the managed runtime.",
};
}
if (!snapshot.config.model_path?.trim()) {
return {
canStart: false,
reason: "Choose a local model before starting the managed runtime.",
};
}
return { canStart: true, reason: "" };
}
export function createDefaultRuntimeSnapshot(): RuntimeSnapshot {
return {
status: "stopped",
ready: false,
process_running: false,
resolved_url: DEFAULT_LOCAL_URL,
binary_path: null,
last_error: null,
config: {
connection_mode: "local",
start_policy: "manual",
server_url: "",
api_key: "",
model_path: null,
models_dir: null,
addr: "127.0.0.1:8080",
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: [],
},
};
}
+54
View File
@@ -0,0 +1,54 @@
export type ConnectionMode = "local" | "remote";
export type StartPolicy = "manual" | "on_app_launch";
export type RuntimeStatus = "stopped" | "starting" | "ready" | "degraded" | "stopping" | "error";
export interface RuntimeConfig {
connection_mode: ConnectionMode;
start_policy: StartPolicy;
server_url: string;
api_key: string;
model_path: string | null;
models_dir: string | null;
addr: string;
gpu_layers: number;
ctx_size: number;
extra_model_paths: string[];
}
export interface RuntimeErrorInfo {
code: string;
message: string;
}
export interface RuntimeSnapshot {
status: RuntimeStatus;
ready: boolean;
process_running: boolean;
resolved_url: string;
binary_path: string | null;
last_error: RuntimeErrorInfo | null;
config: RuntimeConfig;
}
export interface RuntimeConnection {
mode: ConnectionMode;
serverUrl: string;
apiKey: string;
managed: boolean;
}
export interface RuntimeModelFile {
name: string;
path: string;
}
export interface RuntimePickDirResult {
dir: string;
models: RuntimeModelFile[];
}
export interface RuntimeGpuInfo {
name: string;
vram_mib: number;
recommended_layers: number;
}
@@ -0,0 +1,101 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { bootstrapRuntime, getRuntimeSnapshot, saveRuntimeConfig, startRuntime, stopRuntime } from "./api";
import { createDefaultRuntimeSnapshot, getRuntimeConnection } from "./runtime-utils";
import type { RuntimeConfig, RuntimeSnapshot } from "./types";
export function useRuntimeManager() {
const [snapshot, setSnapshot] = useState<RuntimeSnapshot>(createDefaultRuntimeSnapshot());
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
const next = await getRuntimeSnapshot();
setSnapshot(next);
return next;
}, []);
useEffect(() => {
let active = true;
bootstrapRuntime()
.then((next) => {
if (active) {
setSnapshot(next);
setLoading(false);
}
})
.catch(() => {
if (active) {
setLoading(false);
}
});
const id = window.setInterval(() => {
refresh().catch(() => undefined);
}, 3000);
return () => {
active = false;
window.clearInterval(id);
};
}, [refresh]);
const saveConfig = useCallback(async (patch: Partial<RuntimeConfig>) => {
setBusy(true);
try {
const next = await saveRuntimeConfig({ ...snapshot.config, ...patch });
setSnapshot(next);
return next;
} finally {
setBusy(false);
}
}, [snapshot.config]);
const startLocal = useCallback(async () => {
setBusy(true);
setSnapshot((current) => ({
...current,
status: "starting",
process_running: true,
ready: false,
last_error: null,
}));
try {
const next = await startRuntime();
setSnapshot(next);
return next;
} finally {
setBusy(false);
}
}, []);
const stopLocal = useCallback(async () => {
setBusy(true);
setSnapshot((current) => ({
...current,
status: "stopping",
ready: false,
last_error: null,
}));
try {
const next = await stopRuntime();
setSnapshot(next);
return next;
} finally {
setBusy(false);
}
}, []);
const connection = useMemo(() => getRuntimeConnection(snapshot), [snapshot]);
return {
snapshot,
connection,
loading,
busy,
refresh,
saveConfig,
startLocal,
stopLocal,
};
}
+17 -3
View File
@@ -1,26 +1,40 @@
import { useState, useEffect, useCallback } from "react";
import type { ModelInfo, ServerStatus } from "../lib/types";
export function useModels(server: string, apiKey: string) {
export function useModels(server: string, apiKey: string, enabled = true) {
const [models, setModels] = useState<ModelInfo[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("");
const [status, setStatus] = useState<ServerStatus>("loading");
const refresh = useCallback(async () => {
if (!enabled || !server.trim()) {
setModels([]);
setStatus("error");
return;
}
setStatus("loading");
try {
const headers: Record<string, string> = {};
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const res = await fetch(`${server}/v1/models`, { headers });
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();
const list: ModelInfo[] = data.data || [];
setModels(list);
if (list.length > 0 && !selectedModel) setSelectedModel(list[0].id);
if (list.length === 0) {
setSelectedModel("");
} else if (!selectedModel || !list.some((item) => item.id === selectedModel)) {
setSelectedModel(list[0].id);
}
setStatus("ok");
} catch {
setModels([]);
setStatus("error");
}
}, [server, apiKey, selectedModel]);
}, [enabled, server, apiKey, selectedModel]);
useEffect(() => {
refresh();
+18 -18
View File
@@ -2,23 +2,23 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0c0c0e;
--bg2: #141416;
--bg3: #1c1c1f;
--bg4: #242428;
--bg5: #2c2c31;
--border: #2a2a30;
--border2: #38383f;
--text: #e8e8ed;
--text2: #8e8e99;
--text3: #5a5a66;
--accent: #5b6af0;
--accent2: #4a58e0;
--accent-glow: rgba(91,106,240,0.15);
--user-bg: #1a2040;
--user-border: #2a3560;
--asst-bg: #141416;
--asst-border: #2a2a30;
--bg: #0a1016;
--bg2: #0f1721;
--bg3: #14202d;
--bg4: #182636;
--bg5: #203246;
--border: #243241;
--border2: #344759;
--text: #edf5ff;
--text2: #93a8bc;
--text3: #5d7288;
--accent: #48a6ff;
--accent2: #2f8ae3;
--accent-glow: rgba(72,166,255,0.16);
--user-bg: #10263f;
--user-border: #214468;
--asst-bg: #101924;
--asst-border: #243241;
--danger: #f04040;
--success: #30d158;
--warning: #ffd60a;
@@ -36,7 +36,7 @@ html, body, #root {
overflow: hidden;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
font-family: "Segoe UI Variable", "SF Pro Display", "IBM Plex Sans", "Inter", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
+59
View File
@@ -0,0 +1,59 @@
export interface ParsedAssistantResponse {
rawContent: string;
content: string;
reasoning: string;
thinkingOpen: boolean;
}
const THINK_OPEN = "<think>";
const THINK_CLOSE = "</think>";
export function buildChatTitle(text: string): string {
const normalized = text.replace(/\s+/g, " ").trim();
if (!normalized) return "New Chat";
return normalized.slice(0, 50) + (normalized.length > 50 ? "..." : "");
}
export function parseAssistantResponse(rawContent: string): ParsedAssistantResponse {
let cursor = 0;
let thinkingOpen = false;
let content = "";
let reasoning = "";
while (cursor < rawContent.length) {
if (!thinkingOpen) {
const start = rawContent.indexOf(THINK_OPEN, cursor);
if (start === -1) {
content += rawContent.slice(cursor);
break;
}
content += rawContent.slice(cursor, start);
cursor = start + THINK_OPEN.length;
thinkingOpen = true;
continue;
}
const end = rawContent.indexOf(THINK_CLOSE, cursor);
if (end === -1) {
reasoning += rawContent.slice(cursor);
cursor = rawContent.length;
break;
}
reasoning += rawContent.slice(cursor, end);
cursor = end + THINK_CLOSE.length;
thinkingOpen = false;
}
const normalizedContent = reasoning.trim().length > 0
? content.replace(/^\s+/, "")
: content;
return {
rawContent,
content: normalizedContent.trimEnd(),
reasoning: reasoning.trim(),
thinkingOpen,
};
}
+17
View File
@@ -59,6 +59,17 @@ export class DB {
}
}
function hasColumn(db: DB, table: string, column: string): boolean {
const rows = db.query<{ name?: string }>(`PRAGMA table_info(${table})`);
return rows.some((row) => row.name === column);
}
function ensureColumn(db: DB, table: string, column: string, definition: string) {
if (!hasColumn(db, table, column)) {
db.run(`ALTER TABLE ${table} ADD COLUMN ${definition}`);
}
}
export async function initDB(): Promise<DB> {
// Inject sql.js script if not already loaded
if (!window.initSqlJs) {
@@ -105,5 +116,11 @@ export async function initDB(): Promise<DB> {
`);
db.run(`CREATE INDEX IF NOT EXISTS idx_msg_chat ON messages(chat_id, created_at)`);
ensureColumn(db, "messages", "reasoning_content", "reasoning_content TEXT");
ensureColumn(db, "messages", "prompt_tokens", "prompt_tokens INTEGER NOT NULL DEFAULT 0");
ensureColumn(db, "messages", "completion_tokens", "completion_tokens INTEGER NOT NULL DEFAULT 0");
ensureColumn(db, "messages", "total_tokens", "total_tokens INTEGER NOT NULL DEFAULT 0");
ensureColumn(db, "messages", "tokens_per_second", "tokens_per_second REAL");
return db;
}
+11
View File
@@ -10,9 +10,20 @@ export interface Message {
chat_id: number;
role: "user" | "assistant" | "system";
content: string;
reasoning_content?: string | null;
prompt_tokens?: number | null;
completion_tokens?: number | null;
total_tokens?: number | null;
tokens_per_second?: number | null;
created_at: number;
}
export interface ChatTokenTotals {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
}
export interface ModelInfo {
id: string;
object: string;