From d44c2d3f5aeab25a9405896f48a36082cee5d8ac Mon Sep 17 00:00:00 2001 From: firecoperana Date: Sun, 20 Jul 2025 05:33:55 -0500 Subject: Webui: New Features for Conversations, Settings, and Chat Messages (#618) * Webui: add Rename/Upload conversation in header and sidebar webui: don't change modified date when renaming conversation * webui: add a preset feature to the settings #14649 * webui: Add editing assistant messages #13522 Webui: keep the following message while editing assistance response. webui: change icon to edit message * webui: DB import and export #14347 * webui: Wrap long numbers instead of infinite horizontal scroll (#14062) fix sidebar being covered by main content #14082 --------- Co-authored-by: firecoperana --- examples/server/public/index.html.gz | Bin 1263655 -> 1292690 bytes examples/server/webui/dist/index.html | 347 +++++++++++++++---- examples/server/webui/index.html | 2 +- examples/server/webui/package-lock.json | 37 ++ examples/server/webui/package.json | 2 + examples/server/webui/src/App.tsx | 29 +- .../server/webui/src/components/ChatMessage.tsx | 17 +- .../server/webui/src/components/ChatScreen.tsx | 57 ++- examples/server/webui/src/components/Header.tsx | 86 ++++- .../server/webui/src/components/ModalProvider.tsx | 151 ++++++++ .../server/webui/src/components/SettingDialog.tsx | 268 ++++++++++++++- examples/server/webui/src/components/Sidebar.tsx | 382 +++++++++++++++++++-- examples/server/webui/src/index.scss | 4 + examples/server/webui/src/utils/app.context.tsx | 99 +++++- examples/server/webui/src/utils/common.tsx | 36 ++ examples/server/webui/src/utils/storage.ts | 198 ++++++++++- examples/server/webui/src/utils/types.ts | 8 + 17 files changed, 1560 insertions(+), 163 deletions(-) create mode 100644 examples/server/webui/src/components/ModalProvider.tsx (limited to 'examples') diff --git a/examples/server/public/index.html.gz b/examples/server/public/index.html.gz index 4a29a921..d358fdf7 100644 Binary files a/examples/server/public/index.html.gz and b/examples/server/public/index.html.gz differ diff --git a/examples/server/webui/dist/index.html b/examples/server/webui/dist/index.html index e042d5cc..0b2aaa30 100644 --- a/examples/server/webui/dist/index.html +++ b/examples/server/webui/dist/index.html @@ -7,8 +7,8 @@ content="width=device-width, initial-scale=1, maximum-scale=1" /> - 🦙 llama.cpp - chat - - +`;let C0;const ou=Uv?new Uint8Array(new SharedArrayBuffer(1)):null,jv=()=>{C0||(C0=new Worker(URL.createObjectURL(new Blob([cC],{type:"text/javascript"}))))};Zt.getConfig().pyIntepreterEnabled&&jv();const fC=(e,t)=>{jv();const n=Math.random()*1e8,r={};return ou&&(ou[0]=0),{donePromise:new Promise(s=>{C0.onmessage=c=>{const{error:f,stdOutAndErr:d,running:m}=c.data;if(n===c.data.id)if(m){t();return}else s(f?f.toString():d.join(` +`))},C0.postMessage({id:n,python:e,context:r,interruptBuffer:ou})}),interrupt:()=>{console.log("Interrupting..."),console.trace(),ou&&(ou[0]=2)}}};function dC(){const{canvasData:e,setCanvasData:t}=_a(),[n,r]=Y.useState((e==null?void 0:e.content)??""),[a,l]=Y.useState(!1),[s,c]=Y.useState(""),[f,d]=Y.useState(),[m,g]=Y.useState(!1),w=async b=>{f==null||f(),l(!0),c("Loading Pyodide...");const{donePromise:_,interrupt:T}=fC(b,()=>{c("Running..."),g(Uv)});d(()=>T);const D=await _;c(D),l(!1),g(!1)};return Y.useEffect(()=>{r((e==null?void 0:e.content)??""),w((e==null?void 0:e.content)??"")},[e==null?void 0:e.content]),(e==null?void 0:e.type)!==G0.PY_INTERPRETER?null:j.jsx("div",{className:"card bg-base-200 w-full h-full shadow-xl",children:j.jsxs("div",{className:"card-body",children:[j.jsxs("div",{className:"flex justify-between items-center mb-4",children:[j.jsx("span",{className:"text-lg font-bold",children:"Python Interpreter"}),j.jsx(d7,{className:"bg-base-100",onClick:()=>t(null)})]}),j.jsxs("div",{className:"grid grid-rows-3 gap-4 h-full",children:[j.jsx("textarea",{className:"textarea textarea-bordered w-full h-full font-mono",value:n,onChange:b=>r(b.target.value)}),j.jsxs("div",{className:"font-mono flex flex-col row-span-2",children:[j.jsxs("div",{className:"flex items-center mb-2",children:[j.jsxs("button",{className:"btn btn-sm bg-base-100",onClick:()=>w(n),disabled:a,children:[j.jsx(r7,{className:"h-6 w-6"})," Run"]}),m&&j.jsxs("button",{className:"btn btn-sm bg-base-100 ml-2",onClick:()=>f==null?void 0:f(),children:[j.jsx(l7,{className:"h-6 w-6"})," Stop"]}),j.jsx("span",{className:"grow text-right text-xs",children:j.jsx(d0,{href:"https://github.com/ggerganov/llama.cpp/issues/11762",children:"Report a bug"})})]}),j.jsx("textarea",{className:"textarea textarea-bordered h-full dark-color",value:s,readOnly:!0})]})]})]})})}const hC=e=>{const[t,n]=Y.useState(null);return Y.useEffect(()=>{const r=a=>{var l;if(((l=a.data)==null?void 0:l.command)==="setText"){const s=a.data;e.setValue(s==null?void 0:s.text),s!=null&&s.context&&s.context.length>0&&n({type:"context",content:s.context}),e.focus()}};return window.addEventListener("message",r),()=>window.removeEventListener("message",r)},[e]),Y.useEffect(()=>{const r=a=>{a.key==="Escape"&&window.parent.postMessage({command:"escapePressed"},"*")};return window.addEventListener("keydown",r),()=>window.removeEventListener("keydown",r)},[]),{extraContext:t,clearExtraContext:()=>n(null)}},pC="(min-width: 1024px)",o0=e=>{if(!e)return;if(!window.matchMedia(pC).matches){e.style.height="",e.style.maxHeight="";return}const n=window.getComputedStyle(e).maxHeight;e.style.maxHeight="none",e.style.height="auto",e.style.height=`${e.scrollHeight}px`,e.style.maxHeight=n};function mC(e){const[t,n]=Y.useState(e),r=Y.useRef(null);Y.useEffect(()=>{const l=r.current;l&&(typeof t=="string"&&t.length>0?(l.value=t,setTimeout(()=>o0(l),0),n("")):setTimeout(()=>o0(l),0))},[r,t]);const a=Y.useCallback(l=>{o0(l.currentTarget)},[]);return{value:()=>{var l;return((l=r.current)==null?void 0:l.value)??""},setValue:l=>{const s=r.current;s&&(s.value=l,setTimeout(()=>o0(s),0))},focus:()=>{r.current&&r.current.focus()},ref:r,onInput:a}}const lh={content(){const e=new URL(window.location.href);return e.searchParams.get("m")??e.searchParams.get("q")??""},shouldSend(){return new URL(window.location.href).searchParams.has("q")},clear(){A3(["m","q"])}};function gC(e,t){const n=Zt.filterByLeafNodeId(e,t,!0),r=[],a=new Map;for(const s of e)a.set(s.id,s);const l=s=>{let c=a.get(s);for(;c&&c.children.length!==0;)c=a.get(c.children.at(-1)??-1);return(c==null?void 0:c.id)??-1};for(const s of n){const c=a.get(s.parent??-1);if(!c)continue;const f=c.children;s.type!=="root"&&r.push({msg:s,siblingLeafNodeIds:f.map(l),siblingCurrIdx:f.indexOf(s.id)})}return r}const Xa=C3((e,t=80)=>{const n=document.getElementById("main-scroll");if(!n)return;const r=n.scrollHeight-n.scrollTop-n.clientHeight;(!e||r<50)&&setTimeout(()=>n.scrollTo({top:n.scrollHeight}),t)},80);function Oy(){const{viewingChat:e,sendMessage:t,isGenerating:n,stopGenerating:r,pendingMessages:a,canvasData:l,replaceMessageAndGenerate:s,continueMessageAndGenerate:c}=_a(),f=mC(lh.content()),{extraContext:d,clearExtraContext:m}=hC(f),g=d?[d]:void 0,[w,b]=Y.useState(-1),_=Y.useMemo(()=>e?gC(e.messages,w):[],[w,e]),T=(e==null?void 0:e.conv.id)??null,D=a[T??""];Y.useEffect(()=>{b(-1),Xa(!1,1)},[T]);const R=Oe=>{Oe&&b(Oe),Xa(!0)},U=async()=>{var C;const Oe=f.value();if(Oe.trim().length===0||n(T??""))return;f.setValue(""),Xa(!1),b(-1);const V=((C=_.at(-1))==null?void 0:C.msg.id)??null;await t(T,V,Oe,g,R)||f.setValue(Oe),m()},F=async(Oe,V)=>{e&&(b(Oe.id),Xa(!1),await s(e.conv.id,Oe.parent,V,Oe.extra,R),b(-1),Xa(!1))},oe=async Oe=>{e&&(b(Oe.parent),Xa(!1),await s(e.conv.id,Oe.parent,null,Oe.extra,R),b(-1),Xa(!1))},ie=async(Oe,V)=>{!e||!c||(b(Oe.id),Xa(!1),await c(e.conv.id,Oe.id,V,R),b(-1),Xa(!1))},K=!!l;Y.useEffect(()=>{lh.shouldSend()?U():f.focus(),lh.clear()},[f.ref]);const we=D&&!_.some(Oe=>Oe.msg.id===D.id)?[{msg:D,siblingLeafNodeIds:[],siblingCurrIdx:0,isPending:!0}]:[];return j.jsxs("div",{className:Yr({"grid lg:gap-8 grow transition-[300ms]":!0,"grid-cols-[1fr_0fr] lg:grid-cols-[1fr_1fr]":K,"grid-cols-[1fr_0fr]":!K}),children:[j.jsxs("div",{className:Yr({"flex flex-col w-full max-w-[900px] mx-auto":!0,"hidden lg:flex":K,flex:!K}),children:[j.jsxs("div",{id:"messages-list",className:"grow",children:[j.jsx("div",{className:"mt-auto flex justify-center",children:e?"":"Send a message to start"}),[..._,...we].map(Oe=>{const V=Oe.msg,C=(D==null?void 0:D.id)===V.id;return j.jsx(uC,{msg:C?D:V,siblingLeafNodeIds:Oe.siblingLeafNodeIds,siblingCurrIdx:Oe.siblingCurrIdx,onRegenerateMessage:oe,onEditMessage:F,onChangeSibling:b,isPending:C||Oe.isPending,onContinueMessage:ie},V.id)})]}),j.jsxs("div",{className:"flex flex-row items-end pt-8 pb-6 sticky bottom-0 bg-base-100",children:[j.jsx("textarea",{className:"textarea textarea-bordered w-full resize-vertical lg:resize-none lg:max-h-48 lg:overflow-y-auto",placeholder:"Type a message (Shift+Enter to add a new line)",ref:f.ref,onInput:f.onInput,onKeyDown:Oe=>{Oe.nativeEvent.isComposing||Oe.keyCode===229||Oe.key==="Enter"&&!Oe.shiftKey&&(Oe.preventDefault(),U())},id:"msg-input",dir:"auto",rows:2}),n(T??"")?j.jsx("button",{className:"btn btn-neutral ml-2",onClick:()=>r(T??""),children:"Stop"}):j.jsx("button",{className:"btn btn-primary ml-2",onClick:U,children:"Send"})]})]}),j.jsx("div",{className:"w-full sticky top-[7em] h-[calc(100vh-9em)]",children:(l==null?void 0:l.type)===G0.PY_INTERPRETER&&j.jsx(dC,{})})]})}const yC=["temperature","top_k","top_p","min_p","max_tokens"],vC=["dynatemp_range","dynatemp_exponent","typical_p","xtc_probability","xtc_threshold","top_n_sigma"],bC=["repeat_last_n","repeat_penalty","presence_penalty","frequency_penalty","dry_multiplier","dry_base","dry_allowed_length","dry_penalty_last_n"],Sl="w-4 h-4 mr-1 inline";function wC({currentConfig:e,onLoadPreset:t}){const[n,r]=Y.useState(()=>Zt.getPresets()),[a,l]=Y.useState(""),[s,c]=Y.useState(null),{showConfirm:f,showAlert:d}=Xh(),m=async()=>{if(!a.trim()){await d("Please enter a preset name");return}const b=n.find(_=>_.name===a.trim());if(b)await f(`Preset "${a}" already exists. Do you want to overwrite it?`)&&(Zt.updatePreset(b.id,e),r(Zt.getPresets()),l(""),await d("Preset updated successfully"));else{const _=Zt.savePreset(a.trim(),e);r([...n,_]),l(""),await d("Preset saved successfully")}},g=async b=>{await f(`Load preset "${b.name}"? Current settings will be replaced.`)&&(t(b.config),c(b.id))},w=async b=>{await f(`Delete preset "${b.name}"?`)&&(Zt.deletePreset(b.id),r(n.filter(_=>_.id!==b.id)),s===b.id&&c(null))};return j.jsxs("div",{className:"space-y-4",children:[j.jsxs("div",{className:"form-control",children:[j.jsx("label",{className:"label",children:j.jsx("span",{className:"label-text",children:"Save current settings as preset"})}),j.jsxs("div",{className:"join",children:[j.jsx("input",{type:"text",placeholder:"Enter preset name",className:"input input-bordered join-item flex-1",value:a,onChange:b=>l(b.target.value),onKeyPress:b=>{b.key==="Enter"&&m()}}),j.jsx("button",{className:"btn btn-primary join-item",onClick:m,children:"Save Preset"})]})]}),j.jsxs("div",{className:"form-control",children:[j.jsx("label",{className:"label",children:j.jsx("span",{className:"label-text",children:"Saved presets"})}),n.length===0?j.jsx("div",{className:"alert",children:j.jsx("span",{children:"No presets saved yet"})}):j.jsx("div",{className:"space-y-2 max-h-64 overflow-y-auto",children:n.map(b=>j.jsx("div",{className:Yr({"card bg-base-200 p-3":!0,"ring-2 ring-primary":s===b.id}),children:j.jsxs("div",{className:"flex items-center justify-between",children:[j.jsxs("div",{children:[j.jsx("h4",{className:"font-semibold",children:b.name}),j.jsxs("p",{className:"text-sm opacity-70",children:["Created: ",new Date(b.createdAt).toLocaleString()]})]}),j.jsxs("div",{className:"flex gap-2",children:[j.jsx("button",{className:"btn btn-sm btn-primary",onClick:()=>g(b),children:"Load"}),j.jsx("button",{className:"btn btn-sm btn-error",onClick:()=>w(b),children:j.jsx(Zh,{className:"w-4 h-4"})})]})]})},b.id))})]})]})}const xC=(e,t)=>[{title:j.jsxs(j.Fragment,{children:[j.jsx(V6,{className:Sl}),"General"]}),fields:[{type:0,label:"API Key",key:"apiKey"},{type:1,label:"System Message (will be disabled if left empty)",key:"systemMessage"},...yC.map(n=>({type:0,label:n,key:n}))]},{title:j.jsxs(j.Fragment,{children:[j.jsx(X6,{className:Sl}),"Samplers"]}),fields:[{type:0,label:"Samplers queue",key:"samplers"},...vC.map(n=>({type:0,label:n,key:n}))]},{title:j.jsxs(j.Fragment,{children:[j.jsx(Q6,{className:Sl}),"Penalties"]}),fields:bC.map(n=>({type:0,label:n,key:n}))},{title:j.jsxs(j.Fragment,{children:[j.jsx(U6,{className:Sl}),"Reasoning"]}),fields:[{type:2,label:"Expand thought process by default when generating messages",key:"showThoughtInProgress"},{type:2,label:"Exclude thought process when sending requests to API (Recommended for DeepSeek-R1)",key:"excludeThoughtOnReq"}]},{title:j.jsxs(j.Fragment,{children:[j.jsx(a7,{className:Sl}),"Advanced"]}),fields:[{type:3,key:"custom",component:()=>{const n=async()=>{const a=await(await fetch("/demo-conversation.json")).json();Zt.remove(a.id);for(const l of a.messages)Zt.appendMsg(a.id,l)};return j.jsx("button",{className:"btn",onClick:n,children:"(debug) Import demo conversation"})}},{type:3,key:"custom",component:()=>{const n=async()=>{const r=await Zt.exportDB(),a=document.createElement("a");document.body.appendChild(a),a.href=URL.createObjectURL(r),document.body.appendChild(a),a.download="llamawebui_dump.json",a.click(),document.body.removeChild(a)};return j.jsx("button",{className:"btn",onClick:n,children:"Export conversation database"})}},{type:3,key:"custom",component:()=>{const n=async r=>{if(console.log(r),!r.target.files)throw oa.error("Target.files cant be null"),new Error("e.target.files cant be null");if(r.target.files.length!=1)throw oa.error("Number of selected files for DB import must be 1 but was "+r.target.files.length+"."),new Error("Number of selected files for DB import must be 1 but was "+r.target.files.length+".");const a=r.target.files[0];try{if(!a)throw new Error("No DB found to import.");console.log("Importing DB "+a.name),await Zt.importDB(a),oa.success("Import complete"),window.location.reload()}catch(l){console.error(""+l),oa.error(""+l)}};return j.jsxs("div",{children:[j.jsxs("label",{htmlFor:"db-import",className:"btn",role:"button",tabIndex:0,children:[" ","Reset and import conversation database"," "]}),j.jsx("input",{id:"db-import",type:"file",accept:".json",className:"file-upload",onInput:n,hidden:!0})]})}},{type:2,label:"Show tokens per second",key:"showTokensPerSecond"},{type:1,label:j.jsxs(j.Fragment,{children:["Custom JSON config (For more info, refer to"," ",j.jsx(d0,{href:"https://github.com/ikawrakow/ik_llama.cpp/tree/main/examples/server/README.md",children:"server documentation"}),")"]}),key:"custom"}]},{title:j.jsxs(j.Fragment,{children:[j.jsx(L6,{className:Sl}),"Experimental"]}),fields:[{type:3,key:"custom",component:()=>j.jsx(j.Fragment,{children:j.jsxs("p",{className:"mb-8",children:["Experimental features are not guaranteed to work correctly.",j.jsx("br",{}),j.jsx("br",{}),"If you encounter any problems, create a"," ",j.jsx(d0,{href:"https://github.com/ikawrakow/ik_llama.cpp/issues/new?template=019-bug-misc.yml",children:"Bug (misc.)"})," ","report on Github. Please also specify ",j.jsx("b",{children:"webui/experimental"})," on the report title and include screenshots.",j.jsx("br",{}),j.jsx("br",{}),"Some features may require packages downloaded from CDN, so they need internet connection."]})})},{type:2,label:j.jsxs(j.Fragment,{children:[j.jsx("b",{children:"Enable Python interpreter"}),j.jsx("br",{}),j.jsxs("small",{className:"text-xs",children:["This feature uses"," ",j.jsx(d0,{href:"https://pyodide.org",children:"pyodide"}),', downloaded from CDN. To use this feature, ask the LLM to generate Python code inside a Markdown code block. You will see a "Run" button on the code block, near the "Copy" button.']})]}),key:"pyIntepreterEnabled"}]},{title:j.jsxs(j.Fragment,{children:[j.jsx(B6,{className:Sl}),"Presets"]}),fields:[{type:3,key:"custom",component:()=>j.jsx(wC,{currentConfig:e,onLoadPreset:t})}]}];function kC({show:e,onClose:t}){const{config:n,saveConfig:r}=_a(),[a,l]=Y.useState(0),[s,c]=Y.useState(JSON.parse(JSON.stringify(n))),f=xC(s,c),d=()=>{window.confirm("Are you sure you want to reset all settings?")&&c(Do)},m=()=>{const w=JSON.parse(JSON.stringify(s));for(const b in w){const _=w[b],T=dh(Do[b]),D=fh(Do[b]),R=hh(Do[b]);if(D){if(!fh(_)){alert(`Value for ${b} must be string`);return}}else if(R){const U=_.toString().trim(),F=Number(U);if(isNaN(F)||!hh(F)||U.length===0){alert(`Value for ${b} must be numeric`);return}w[b]=F}else if(T){if(!dh(_)){alert(`Value for ${b} must be boolean`);return}}else console.error(`Unknown default type for key ${b}`)}r(w),t()},g=w=>b=>{c({...s,[w]:b})};return j.jsx("dialog",{className:Yr({modal:!0,"modal-open":e}),children:j.jsxs("div",{className:"modal-box w-11/12 max-w-3xl",children:[j.jsx("h3",{className:"text-lg font-bold mb-6",children:"Settings"}),j.jsxs("div",{className:"flex flex-col md:flex-row h-[calc(90vh-12rem)]",children:[j.jsx("div",{className:"hidden md:flex flex-col items-stretch pr-4 mr-4 border-r-2 border-base-200",children:f.map((w,b)=>j.jsx("div",{className:Yr({"btn btn-ghost justify-start font-normal w-44 mb-1":!0,"btn-active":a===b}),onClick:()=>l(b),dir:"auto",children:w.title},b))}),j.jsx("div",{className:"md:hidden flex flex-row gap-2 mb-4",children:j.jsxs("details",{className:"dropdown",children:[j.jsx("summary",{className:"btn bt-sm w-full m-1",children:f[a].title}),j.jsx("ul",{className:"menu dropdown-content bg-base-100 rounded-box z-[1] w-52 p-2 shadow",children:f.map((w,b)=>j.jsx("div",{className:Yr({"btn btn-ghost justify-start font-normal":!0,"btn-active":a===b}),onClick:()=>l(b),dir:"auto",children:w.title},b))})]})}),j.jsxs("div",{className:"grow overflow-y-auto px-4",children:[f[a].fields.map((w,b)=>{const _=`${a}-${b}`;if(w.type===0)return j.jsx(SC,{configKey:w.key,value:s[w.key],onChange:g(w.key),label:w.label},_);if(w.type===1)return j.jsx(EC,{configKey:w.key,value:s[w.key].toString(),onChange:g(w.key),label:w.label},_);if(w.type===2)return j.jsx(_C,{configKey:w.key,value:!!s[w.key],onChange:g(w.key),label:w.label},_);if(w.type===3)return j.jsx("div",{className:"mb-2",children:typeof w.component=="string"?w.component:w.component({value:s[w.key],onChange:g(w.key)})},_)}),j.jsx("p",{className:"opacity-40 mb-6 text-sm mt-8",children:"Settings are saved in browser's localStorage"})]})]}),j.jsxs("div",{className:"modal-action",children:[j.jsx("button",{className:"btn",onClick:d,children:"Reset to default"}),j.jsx("button",{className:"btn",onClick:t,children:"Close"}),j.jsx("button",{className:"btn btn-primary",onClick:m,children:"Save"})]})]})})}function EC({configKey:e,value:t,onChange:n,label:r}){return j.jsxs("label",{className:"form-control mb-2",children:[j.jsx("div",{className:"label inline",children:r||e}),j.jsx("textarea",{className:"textarea textarea-bordered h-24",placeholder:`Default: ${Do[e]||"none"}`,value:t,onChange:a=>n(a.target.value)})]})}function SC({configKey:e,value:t,onChange:n,label:r}){const a=R3[e];return j.jsxs(j.Fragment,{children:[a&&j.jsxs("div",{className:"block md:hidden mb-1",children:[j.jsx("b",{children:r||e}),j.jsx("br",{}),j.jsx("p",{className:"text-xs whitespace-normal",children:a})]}),j.jsxs("label",{className:"input input-bordered join-item grow flex items-center gap-2 mb-2",children:[j.jsxs("div",{className:"dropdown dropdown-hover",children:[j.jsx("div",{tabIndex:0,role:"button",className:"font-bold hidden md:block",children:r||e}),a&&j.jsx("div",{className:"dropdown-content menu bg-base-100 rounded-box z-10 w-64 p-2 shadow mt-4 whitespace-normal break-words",children:a})]}),j.jsx("input",{type:"text",className:"grow",placeholder:`Default: ${Do[e]||"none"}`,value:t,onChange:l=>n(l.target.value)})]})]})}function _C({configKey:e,value:t,onChange:n,label:r}){return j.jsxs("div",{className:"flex flex-row items-center mb-2",children:[j.jsx("input",{type:"checkbox",className:"toggle",checked:t,onChange:a=>n(a.target.checked)}),j.jsx("span",{className:"ml-4",children:r||e})]})}function NC(){return j.jsx(R6,{children:j.jsx(u3,{children:j.jsx("div",{className:"flex flex-row drawer lg:drawer-open",children:j.jsx(M6,{children:j.jsx(Fb,{children:j.jsxs(l0,{element:j.jsx(TC,{}),children:[j.jsx(l0,{path:"/chat/:convId",element:j.jsx(Oy,{})}),j.jsx(l0,{path:"*",element:j.jsx(Oy,{})})]})})})})})})}function TC(){const{showSettings:e,setShowSettings:t}=_a();return j.jsxs(j.Fragment,{children:[j.jsx(m7,{}),j.jsxs("div",{className:"drawer-content grow flex flex-col h-screen mx-auto px-4 overflow-auto bg-base-100",id:"main-scroll",children:[j.jsx(f7,{}),j.jsx(zb,{})]}),j.jsx(kC,{show:e,onClose:()=>t(!1)})]})}K5.createRoot(document.getElementById("root")).render(j.jsx(Y.StrictMode,{children:j.jsx(NC,{})})); +
diff --git a/examples/server/webui/index.html b/examples/server/webui/index.html index 471f46b3..47fb9557 100644 --- a/examples/server/webui/index.html +++ b/examples/server/webui/index.html @@ -7,7 +7,7 @@ content="width=device-width, initial-scale=1, maximum-scale=1" /> - 🦙 llama.cpp - chat + 🦙 ik_llama.cpp - chat
diff --git a/examples/server/webui/package-lock.json b/examples/server/webui/package-lock.json index b2e3cf94..ed229881 100644 --- a/examples/server/webui/package-lock.json +++ b/examples/server/webui/package-lock.json @@ -16,11 +16,13 @@ "autoprefixer": "^10.4.20", "daisyui": "^5.0.12", "dexie": "^4.0.11", + "dexie-export-import": "^4.0.11", "highlight.js": "^11.10.0", "katex": "^0.16.15", "postcss": "^8.4.49", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hot-toast": "^2.5.2", "react-markdown": "^9.0.3", "react-router": "^7.1.5", "rehype-highlight": "^7.0.2", @@ -2444,6 +2446,15 @@ "integrity": "sha512-SOKO002EqlvBYYKQSew3iymBoN2EQ4BDw/3yprjh7kAfFzjBYkaMNa/pZvcA7HSWlcKSQb9XhPe3wKyQ0x4A8A==", "license": "Apache-2.0" }, + "node_modules/dexie-export-import": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/dexie-export-import/-/dexie-export-import-4.1.4.tgz", + "integrity": "sha512-3bw171qUuOTWSYLXI7C/0M6p1X65Rho3tu1IvD9By8jn0+3t3dLSkDlZ1BC6MbABl3kRlhtGigzC+SF+qcS5Og==", + "license": "Apache-2.0", + "peerDependencies": { + "dexie": "^2.0.4 || ^3.0.0 || ^4.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.91", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.91.tgz", @@ -2917,6 +2928,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", + "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4938,6 +4958,23 @@ "react": "^18.3.1" } }, + "node_modules/react-hot-toast": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.5.2.tgz", + "integrity": "sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-markdown": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.0.3.tgz", diff --git a/examples/server/webui/package.json b/examples/server/webui/package.json index 6ac06b1a..48f60f58 100644 --- a/examples/server/webui/package.json +++ b/examples/server/webui/package.json @@ -19,11 +19,13 @@ "autoprefixer": "^10.4.20", "daisyui": "^5.0.12", "dexie": "^4.0.11", + "dexie-export-import": "^4.0.11", "highlight.js": "^11.10.0", "katex": "^0.16.15", "postcss": "^8.4.49", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hot-toast": "^2.5.2", "react-markdown": "^9.0.3", "react-router": "^7.1.5", "rehype-highlight": "^7.0.2", diff --git a/examples/server/webui/src/App.tsx b/examples/server/webui/src/App.tsx index cc4659e1..a1e19a70 100644 --- a/examples/server/webui/src/App.tsx +++ b/examples/server/webui/src/App.tsx @@ -4,21 +4,24 @@ import Sidebar from './components/Sidebar'; import { AppContextProvider, useAppContext } from './utils/app.context'; import ChatScreen from './components/ChatScreen'; import SettingDialog from './components/SettingDialog'; +import { ModalProvider } from './components/ModalProvider'; function App() { return ( - -
- - - }> - } /> - } /> - - - -
-
+ + +
+ + + }> + } /> + } /> + + + +
+
+
); } @@ -28,7 +31,7 @@ function AppLayout() { <>
diff --git a/examples/server/webui/src/components/ChatMessage.tsx b/examples/server/webui/src/components/ChatMessage.tsx index 40ea7471..a60fd241 100644 --- a/examples/server/webui/src/components/ChatMessage.tsx +++ b/examples/server/webui/src/components/ChatMessage.tsx @@ -20,6 +20,7 @@ export default function ChatMessage({ onEditMessage, onChangeSibling, isPending, + onContinueMessage, }: { msg: Message | PendingMessage; siblingLeafNodeIds: Message['id'][]; @@ -27,6 +28,7 @@ export default function ChatMessage({ id?: string; onRegenerateMessage(msg: Message): void; onEditMessage(msg: Message, content: string): void; + onContinueMessage(msg: Message, content: string): void; onChangeSibling(sibling: Message['id']): void; isPending?: boolean; }) { @@ -112,7 +114,11 @@ export default function ChatMessage({ onClick={() => { if (msg.content !== null) { setEditingContent(null); - onEditMessage(msg as Message, editingContent); + if (msg.role === 'user') { + onEditMessage(msg as Message, editingContent); + } else { + onContinueMessage(msg as Message, editingContent); + } } }} > @@ -283,6 +289,15 @@ export default function ChatMessage({ 🔄 Regenerate )} + {!isPending && ( + + )} )} { + if (!viewingChat || !continueMessageAndGenerate) return; + setCurrNodeId(msg.id); + scrollToBottom(false); + await continueMessageAndGenerate( + viewingChat.conv.id, + msg.id, + content, + onChunk + ); + setCurrNodeId(-1); + scrollToBottom(false); + }; + const hasCanvas = !!canvasData; useEffect(() => { @@ -204,7 +219,7 @@ export default function ChatScreen() { // due to some timing issues of StorageUtils.appendMsg(), we need to make sure the pendingMsg is not duplicated upon rendering (i.e. appears once in the saved conversation and once in the pendingMsg) const pendingMsgDisplay: MessageDisplay[] = - pendingMsg && messages.at(-1)?.msg.id !== pendingMsg.id + pendingMsg && !messages.some((m) => m.msg.id === pendingMsg.id) // Only show if pendingMsg is not an existing message being continued ? [ { msg: pendingMsg, @@ -236,17 +251,35 @@ export default function ChatScreen() { {/* placeholder to shift the message to the bottom */} {viewingChat ? '' : 'Send a message to start'}
- {[...messages, ...pendingMsgDisplay].map((msg) => ( - - ))} + {[...messages, ...pendingMsgDisplay].map((msgDisplay) => { + const actualMsgObject = msgDisplay.msg; + // Check if the current message from the list is the one actively being generated/continued + const isThisMessageTheActivePendingOne = + pendingMsg?.id === actualMsgObject.id; + + return ( + + ); + })} {/* chat input */} diff --git a/examples/server/webui/src/components/Header.tsx b/examples/server/webui/src/components/Header.tsx index 84fea191..601fb960 100644 --- a/examples/server/webui/src/components/Header.tsx +++ b/examples/server/webui/src/components/Header.tsx @@ -5,6 +5,14 @@ import { classNames } from '../utils/misc'; import daisyuiThemes from 'daisyui/theme/object'; import { THEMES } from '../Config'; import { useNavigate } from 'react-router'; +import toast from 'react-hot-toast'; +import { useModals } from './ModalProvider'; +import { + ArrowUpTrayIcon, + ArrowDownTrayIcon, + PencilIcon, + TrashIcon, +} from '@heroicons/react/24/outline'; export default function Header() { const navigate = useNavigate(); @@ -24,9 +32,11 @@ export default function Header() { ); }, [selectedTheme]); + const {showPrompt } = useModals(); const { isGenerating, viewingChat } = useAppContext(); const isCurrConvGenerating = isGenerating(viewingChat?.conv.id ?? ''); + // remove conversation const removeConversation = () => { if (isCurrConvGenerating || !viewingChat) return; const convId = viewingChat?.conv.id; @@ -35,6 +45,37 @@ export default function Header() { navigate('/'); } }; + + // rename conversation + async function renameConversation() { + if (isGenerating(viewingChat?.conv.id ?? '')) { + toast.error( + 'Cannot rename conversation while generating' + ); + return; + } + const newName = await showPrompt( + 'Enter new name for the conversation', + viewingChat?.conv.name + ); + if (newName && newName.trim().length > 0) { + StorageUtils.updateConversationName(viewingChat?.conv.id ?? '', newName); + } + //const importedConv = await StorageUtils.updateConversationName(); + //if (importedConv) { + //console.log('Successfully imported:', importedConv.name); + // Refresh UI or navigate to conversation + //} + }; + + // at the top of your file, alongside ConversationExport: + async function importConversation() { + const importedConv = await StorageUtils.importConversationFromFile(); + if (importedConv) { + console.log('Successfully imported:', importedConv.name); + // Refresh UI or navigate to conversation + } + }; const downloadConversation = () => { if (isCurrConvGenerating || !viewingChat) return; @@ -99,12 +140,45 @@ export default function Header() { tabIndex={0} className="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow" > -
  • - Download -
  • -
  • - Delete -
  • + {/* Always show Upload when viewingChat is false */} + {!viewingChat && ( +
  • + + + Upload + +
  • + )} + + {/* Show all three when viewingChat is true */} + {viewingChat && ( + <> +
  • + + + Upload + +
  • +
  • + + + Rename + +
  • +
  • + + + Download + +
  • +
  • + + + Delete + +
  • + + )} )} diff --git a/examples/server/webui/src/components/ModalProvider.tsx b/examples/server/webui/src/components/ModalProvider.tsx new file mode 100644 index 00000000..f2ebf8e0 --- /dev/null +++ b/examples/server/webui/src/components/ModalProvider.tsx @@ -0,0 +1,151 @@ +import React, { createContext, useState, useContext } from 'react'; + +type ModalContextType = { + showConfirm: (message: string) => Promise; + showPrompt: ( + message: string, + defaultValue?: string + ) => Promise; + showAlert: (message: string) => Promise; +}; +const ModalContext = createContext(null!); + +interface ModalState { + isOpen: boolean; + message: string; + defaultValue?: string; + resolve: ((value: T) => void) | null; +} + +export function ModalProvider({ children }: { children: React.ReactNode }) { + const [confirmState, setConfirmState] = useState>({ + isOpen: false, + message: '', + resolve: null, + }); + const [promptState, setPromptState] = useState< + ModalState + >({ isOpen: false, message: '', resolve: null }); + const [alertState, setAlertState] = useState>({ + isOpen: false, + message: '', + resolve: null, + }); + const inputRef = React.useRef(null); + + const showConfirm = (message: string): Promise => { + return new Promise((resolve) => { + setConfirmState({ isOpen: true, message, resolve }); + }); + }; + + const showPrompt = ( + message: string, + defaultValue?: string + ): Promise => { + return new Promise((resolve) => { + setPromptState({ isOpen: true, message, defaultValue, resolve }); + }); + }; + + const showAlert = (message: string): Promise => { + return new Promise((resolve) => { + setAlertState({ isOpen: true, message, resolve }); + }); + }; + + const handleConfirm = (result: boolean) => { + confirmState.resolve?.(result); + setConfirmState({ isOpen: false, message: '', resolve: null }); + }; + + const handlePrompt = (result?: string) => { + promptState.resolve?.(result); + setPromptState({ isOpen: false, message: '', resolve: null }); + }; + + const handleAlertClose = () => { + alertState.resolve?.(); + setAlertState({ isOpen: false, message: '', resolve: null }); + }; + + return ( + + {children} + + {/* Confirm Modal */} + {confirmState.isOpen && ( + +
    +

    {confirmState.message}

    +
    + + +
    +
    +
    + )} + + {/* Prompt Modal */} + {promptState.isOpen && ( + +
    +

    {promptState.message}

    + { + if (e.key === 'Enter') { + handlePrompt((e.target as HTMLInputElement).value); + } + }} + /> +
    + + +
    +
    +
    + )} + + {/* Alert Modal */} + {alertState.isOpen && ( + +
    +

    {alertState.message}

    +
    + +
    +
    +
    + )} +
    + ); +} + +export function useModals() { + const context = useContext(ModalContext); + if (!context) throw new Error('useModals must be used within ModalProvider'); + return context; +} diff --git a/examples/server/webui/src/components/SettingDialog.tsx b/examples/server/webui/src/components/SettingDialog.tsx index cd091a55..004b51ab 100644 --- a/examples/server/webui/src/components/SettingDialog.tsx +++ b/examples/server/webui/src/components/SettingDialog.tsx @@ -3,16 +3,21 @@ import { useAppContext } from '../utils/app.context'; import { CONFIG_DEFAULT, CONFIG_INFO } from '../Config'; import { isDev } from '../Config'; import StorageUtils from '../utils/storage'; +import { useModals } from './ModalProvider'; import { classNames, isBoolean, isNumeric, isString } from '../utils/misc'; import { BeakerIcon, + BookmarkIcon, ChatBubbleOvalLeftEllipsisIcon, Cog6ToothIcon, FunnelIcon, HandRaisedIcon, SquaresPlusIcon, + TrashIcon, } from '@heroicons/react/24/outline'; import { OpenInNewTab } from '../utils/common'; +import { SettingsPreset } from '../utils/types'; +import toast from 'react-hot-toast' type SettKey = keyof typeof CONFIG_DEFAULT; @@ -74,7 +79,155 @@ interface SettingSection { const ICON_CLASSNAME = 'w-4 h-4 mr-1 inline'; -const SETTING_SECTIONS: SettingSection[] = [ +// Presets Component +function PresetsManager({ + currentConfig, + onLoadPreset, +}: { + currentConfig: typeof CONFIG_DEFAULT; + onLoadPreset: (config: typeof CONFIG_DEFAULT) => void; +}) { + const [presets, setPresets] = useState(() => + StorageUtils.getPresets() + ); + const [presetName, setPresetName] = useState(''); + const [selectedPresetId, setSelectedPresetId] = useState(null); + const { showConfirm, showAlert } = useModals(); + + const handleSavePreset = async () => { + if (!presetName.trim()) { + await showAlert('Please enter a preset name'); + return; + } + + // Check if preset name already exists + const existingPreset = presets.find((p) => p.name === presetName.trim()); + if (existingPreset) { + if ( + await showConfirm( + `Preset "${presetName}" already exists. Do you want to overwrite it?` + ) + ) { + StorageUtils.updatePreset(existingPreset.id, currentConfig); + setPresets(StorageUtils.getPresets()); + setPresetName(''); + await showAlert('Preset updated successfully'); + } + } else { + const newPreset = StorageUtils.savePreset( + presetName.trim(), + currentConfig + ); + setPresets([...presets, newPreset]); + setPresetName(''); + await showAlert('Preset saved successfully'); + } + }; + + const handleLoadPreset = async (preset: SettingsPreset) => { + if ( + await showConfirm( + `Load preset "${preset.name}"? Current settings will be replaced.` + ) + ) { + onLoadPreset(preset.config as typeof CONFIG_DEFAULT); + setSelectedPresetId(preset.id); + } + }; + + const handleDeletePreset = async (preset: SettingsPreset) => { + if (await showConfirm(`Delete preset "${preset.name}"?`)) { + StorageUtils.deletePreset(preset.id); + setPresets(presets.filter((p) => p.id !== preset.id)); + if (selectedPresetId === preset.id) { + setSelectedPresetId(null); + } + } + }; + + return ( +
    + {/* Save current settings as preset */} +
    + +
    + setPresetName(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + handleSavePreset(); + } + }} + /> + +
    +
    + + {/* List of saved presets */} +
    + + {presets.length === 0 ? ( +
    + No presets saved yet +
    + ) : ( +
    + {presets.map((preset) => ( +
    +
    +
    +

    {preset.name}

    +

    + Created: {new Date(preset.createdAt).toLocaleString()} +

    +
    +
    + + +
    +
    +
    + ))} +
    + )} +
    +
    + ); +} + +const SETTING_SECTIONS = ( + localConfig: typeof CONFIG_DEFAULT, + setLocalConfig: (config: typeof CONFIG_DEFAULT) => void +): SettingSection[] => [ { title: ( <> @@ -187,6 +340,85 @@ const SETTING_SECTIONS: SettingSection[] = [ ); }, }, + { + type: SettingInputType.CUSTOM, + key: 'custom', // dummy key, won't be used + component: () => { + const exportDB = async () => { + const blob = await StorageUtils.exportDB(); + const a = document.createElement('a'); + document.body.appendChild(a); + a.href = URL.createObjectURL(blob); + document.body.appendChild(a); + a.download = `llamawebui_dump.json`; + a.click(); + document.body.removeChild(a); + }; + return ( + + ); + }, + }, + { + type: SettingInputType.CUSTOM, + key: 'custom', // dummy key, won't be used + component: () => { + const importDB = async (e: React.ChangeEvent) => { + console.log(e); + if (!e.target.files) { + toast.error('Target.files cant be null'); + throw new Error('e.target.files cant be null'); + } + if (e.target.files.length != 1) + { + toast.error( + 'Number of selected files for DB import must be 1 but was ' + + e.target.files.length + + '.'); + throw new Error( + 'Number of selected files for DB import must be 1 but was ' + + e.target.files.length + + '.' + ); + } + const file = e.target.files[0]; + try { + if (!file) throw new Error('No DB found to import.'); + console.log('Importing DB ' + file.name); + await StorageUtils.importDB(file); + toast.success('Import complete') + window.location.reload(); + } catch (error) { + console.error('' + error); + toast.error('' + error); + } + }; + return ( +
    + + +
    + ); + }, + }, + { type: SettingInputType.CHECKBOX, label: 'Show tokens per second', @@ -257,6 +489,26 @@ const SETTING_SECTIONS: SettingSection[] = [ }, ], }, + { + title: ( + <> + + Presets + + ), + fields: [ + { + type: SettingInputType.CUSTOM, + key: 'custom', // dummy key for presets + component: () => ( + + ), + }, + ], + }, ]; export default function SettingDialog({ @@ -274,6 +526,12 @@ export default function SettingDialog({ JSON.parse(JSON.stringify(config)) ); + // Generate sections with access to local state + const SETTING_SECTIONS_GENERATED = SETTING_SECTIONS( + localConfig, + setLocalConfig + ); + const resetConfig = () => { if (window.confirm('Are you sure you want to reset all settings?')) { setLocalConfig(CONFIG_DEFAULT); @@ -332,7 +590,7 @@ export default function SettingDialog({
    {/* Left panel, showing sections - Desktop version */}
    - {SETTING_SECTIONS.map((section, idx) => ( + {SETTING_SECTIONS_GENERATED.map((section, idx) => (
    - {SETTING_SECTIONS[sectionIdx].title} + {SETTING_SECTIONS_GENERATED[sectionIdx].title}
      - {SETTING_SECTIONS.map((section, idx) => ( + {SETTING_SECTIONS_GENERATED.map((section, idx) => (
      - {SETTING_SECTIONS[sectionIdx].fields.map((field, idx) => { + {SETTING_SECTIONS_GENERATED[sectionIdx].fields.map((field, idx) => { const key = `${sectionIdx}-${idx}`; if (field.type === SettingInputType.SHORT_INPUT) { return ( diff --git a/examples/server/webui/src/components/Sidebar.tsx b/examples/server/webui/src/components/Sidebar.tsx index 34727c62..4b0adbae 100644 --- a/examples/server/webui/src/components/Sidebar.tsx +++ b/examples/server/webui/src/components/Sidebar.tsx @@ -1,13 +1,36 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { classNames } from '../utils/misc'; import { Conversation } from '../utils/types'; import StorageUtils from '../utils/storage'; import { useNavigate, useParams } from 'react-router'; +import { + ArrowUpTrayIcon, + ArrowDownTrayIcon, + EllipsisVerticalIcon, + PencilIcon, + PencilSquareIcon, + TrashIcon, + XMarkIcon, +} from '@heroicons/react/24/outline'; +import { BtnWithTooltips } from '../utils/common'; +import { useAppContext } from '../utils/app.context'; +import toast from 'react-hot-toast'; +import { useModals } from './ModalProvider'; + // at the top of your file, alongside ConversationExport: + async function importConversation() { + const importedConv = await StorageUtils.importConversationFromFile(); + if (importedConv) { + console.log('Successfully imported:', importedConv.name); + // Refresh UI or navigate to conversation + } + }; export default function Sidebar() { const params = useParams(); const navigate = useNavigate(); + const { isGenerating, viewingChat } = useAppContext(); + const [conversations, setConversations] = useState([]); const [currConv, setCurrConv] = useState(null); @@ -25,68 +48,176 @@ export default function Sidebar() { StorageUtils.offConversationChanged(handleConversationChange); }; }, []); + const { showConfirm, showPrompt } = useModals(); + + const groupedConv = useMemo( + () => groupConversationsByDate(conversations), + [conversations] + ); + + return ( <> -
      +
      + + + Skip to main content + +
      -

      Conversations

      +

      + Conversations +

      {/* close sidebar button */} -
      - {/* list of conversations */} -
      navigate('/')} + aria-label="New conversation" > - + New conversation -
      - {conversations.map((conv) => ( -
      navigate(`/chat/${conv.id}`)} - dir="auto" - > - {conv.name} + + New conversation + + + {/* list of conversations */} + {groupedConv.map((group, i) => ( +
      + {/* group name (by date) */} + {group.title ? ( + // we use btn class here to make sure that the padding/margin are aligned with the other items + + {group.title} + + ) : ( +
      + )} + + {group.conversations.map((conv) => ( + { + navigate(`/chat/${conv.id}`); + }} + onDelete={async () => { + if (isGenerating(conv.id)) { + toast.error( + 'Cannot delete conversation while generating' + ); + return; + } + if ( + await showConfirm( + 'Are you sure to delete this conversation?' + ) + ) { + toast.success('Conversation deleted'); + StorageUtils.remove(conv.id); + navigate('/'); + } + }} + + onDownload={() => { + if (isGenerating(conv.id)) { + toast.error( + 'Cannot download conversation while generating' + ); + return; + } + // Get the current system message from config + const systemMessage = StorageUtils.getConfig().systemMessage; + + // Clone the viewingChat object to avoid modifying the original + const exportData = { + conv: { ...viewingChat?.conv }, + messages: viewingChat?.messages.map(msg => ({ ...msg })) + }; + + // Find the root message and update its content + const rootMessage = exportData.messages?.find(m => m.type === 'root'); + if (rootMessage) { + rootMessage.content = systemMessage; + } + + + const conversationJson = JSON.stringify(exportData, null, 2); + // const conversationJson = JSON.stringify(conv, null, 2); + const blob = new Blob([conversationJson], { + type: 'application/json', + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `conversation_${conv.id}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }} + onRename={async () => { + if (isGenerating(conv.id)) { + toast.error( + 'Cannot rename conversation while generating' + ); + return; + } + const newName = await showPrompt( + 'Enter new name for the conversation', + conv.name + ); + if (newName && newName.trim().length > 0) { + StorageUtils.updateConversationName(conv.id, newName); + } + }} + /> + ))}
      ))} -
      +
      Conversations are saved to browser's IndexedDB
      @@ -94,3 +225,182 @@ export default function Sidebar() { ); } + +function ConversationItem({ + conv, + isCurrConv, + onSelect, + onDelete, + onDownload, + onRename, +}: { + conv: Conversation; + isCurrConv: boolean; + onSelect: () => void; + onDelete: () => void; + onDownload: () => void; + onRename: () => void; +}) { + return ( +
      + +
      + + {/* dropdown menu */} + +
      +
      + ); +} + +// WARN: vibe code below + +export interface GroupedConversations { + title?: string; + conversations: Conversation[]; +} + +// TODO @ngxson : add test for this function +// Group conversations by date +// - "Previous 7 Days" +// - "Previous 30 Days" +// - "Month Year" (e.g., "April 2023") +export function groupConversationsByDate( + conversations: Conversation[] +): GroupedConversations[] { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); // Start of today + + const sevenDaysAgo = new Date(today); + sevenDaysAgo.setDate(today.getDate() - 7); + + const thirtyDaysAgo = new Date(today); + thirtyDaysAgo.setDate(today.getDate() - 30); + + const groups: { [key: string]: Conversation[] } = { + Today: [], + 'Previous 7 Days': [], + 'Previous 30 Days': [], + }; + const monthlyGroups: { [key: string]: Conversation[] } = {}; // Key format: "Month Year" e.g., "April 2023" + + // Sort conversations by lastModified date in descending order (newest first) + // This helps when adding to groups, but the final output order of groups is fixed. + const sortedConversations = [...conversations].sort( + (a, b) => b.lastModified - a.lastModified + ); + + for (const conv of sortedConversations) { + const convDate = new Date(conv.lastModified); + + if (convDate >= today) { + groups['Today'].push(conv); + } else if (convDate >= sevenDaysAgo) { + groups['Previous 7 Days'].push(conv); + } else if (convDate >= thirtyDaysAgo) { + groups['Previous 30 Days'].push(conv); + } else { + const monthName = convDate.toLocaleString('default', { month: 'long' }); + const year = convDate.getFullYear(); + const monthYearKey = `${monthName} ${year}`; + if (!monthlyGroups[monthYearKey]) { + monthlyGroups[monthYearKey] = []; + } + monthlyGroups[monthYearKey].push(conv); + } + } + + const result: GroupedConversations[] = []; + + if (groups['Today'].length > 0) { + result.push({ + title: undefined, // no title for Today + conversations: groups['Today'], + }); + } + + if (groups['Previous 7 Days'].length > 0) { + result.push({ + title: 'Previous 7 Days', + conversations: groups['Previous 7 Days'], + }); + } + + if (groups['Previous 30 Days'].length > 0) { + result.push({ + title: 'Previous 30 Days', + conversations: groups['Previous 30 Days'], + }); + } + + // Sort monthly groups by date (most recent month first) + const sortedMonthKeys = Object.keys(monthlyGroups).sort((a, b) => { + const dateA = new Date(a); // "Month Year" can be parsed by Date constructor + const dateB = new Date(b); + return dateB.getTime() - dateA.getTime(); + }); + + for (const monthKey of sortedMonthKeys) { + if (monthlyGroups[monthKey].length > 0) { + result.push({ title: monthKey, conversations: monthlyGroups[monthKey] }); + } + } + + return result; +} diff --git a/examples/server/webui/src/index.scss b/examples/server/webui/src/index.scss index a18f0945..d4ae4a41 100644 --- a/examples/server/webui/src/index.scss +++ b/examples/server/webui/src/index.scss @@ -41,6 +41,10 @@ html { max-width: 900px; } +.chat-bubble { + @apply break-words; +} + .chat-bubble-base-300 { --tw-bg-opacity: 1; --tw-text-opacity: 1; diff --git a/examples/server/webui/src/utils/app.context.tsx b/examples/server/webui/src/utils/app.context.tsx index ce81fccd..509b4ee0 100644 --- a/examples/server/webui/src/utils/app.context.tsx +++ b/examples/server/webui/src/utils/app.context.tsx @@ -15,7 +15,7 @@ import { } from './misc'; import { BASE_URL, CONFIG_DEFAULT, isDev } from '../Config'; import { matchPath, useLocation, useNavigate } from 'react-router'; - +import toast from 'react-hot-toast'; class Timer { static timercount = 1; } @@ -39,7 +39,12 @@ interface AppContextValue { extra: Message['extra'], onChunk: CallbackGeneratedChunk ) => Promise; - + continueMessageAndGenerate: ( + convId: string, + messageIdToContinue: Message['id'], + newContent: string, + onChunk: CallbackGeneratedChunk + ) => Promise; // canvas canvasData: CanvasData | null; setCanvasData: (data: CanvasData | null) => void; @@ -136,7 +141,8 @@ export const AppContextProvider = ({ const generateMessage = async ( convId: string, leafNodeId: Message['id'], - onChunk: CallbackGeneratedChunk + onChunk: CallbackGeneratedChunk, + isContinuation: boolean = false ) => { if (isGenerating(convId)) return; @@ -160,17 +166,36 @@ export const AppContextProvider = ({ const pendingId = Date.now() + Timer.timercount + 1; Timer.timercount=Timer.timercount+2; - let pendingMsg: PendingMessage = { - id: pendingId, - convId, - type: 'text', - timestamp: pendingId, - role: 'assistant', - content: null, - parent: leafNodeId, - children: [], - }; - setPending(convId, pendingMsg); + let pendingMsg: Message | PendingMessage; + + if (isContinuation) { + const existingAsstMsg = await StorageUtils.getMessage(convId, leafNodeId); + if (!existingAsstMsg || existingAsstMsg.role !== 'assistant') { + toast.error( + 'Cannot continue: target message not found or not an assistant message.' + ); + throw new Error( + 'Cannot continue: target message not found or not an assistant message.' + ); + } + pendingMsg = { + ...existingAsstMsg, + content: existingAsstMsg.content || '', + }; + setPending(convId, pendingMsg as PendingMessage); + } else { + pendingMsg = { + id: pendingId, + convId, + type: 'text', + timestamp: pendingId, + role: 'assistant', + content: null, + parent: leafNodeId, + children: [], + }; + setPending(convId, pendingMsg as PendingMessage); + } try { // prepare messages for API @@ -254,7 +279,7 @@ export const AppContextProvider = ({ predicted_ms: timings.predicted_ms, }; } - setPending(convId, pendingMsg); + setPending(convId, pendingMsg as PendingMessage); onChunk(); // don't need to switch node for pending message } } catch (err) { @@ -271,11 +296,16 @@ export const AppContextProvider = ({ } finally { if (pendingMsg.content !== null) { - await StorageUtils.appendMsg(pendingMsg as Message, leafNodeId); + if (isContinuation) { + await StorageUtils.updateMessage(pendingMsg as Message); + } else if (pendingMsg.content.trim().length > 0) { + await StorageUtils.appendMsg(pendingMsg as Message, leafNodeId); + } } } setPending(convId, null); - onChunk(pendingId); // trigger scroll to bottom and switch to the last node + const finalNodeId = (pendingMsg as Message).id; + onChunk(finalNodeId); // trigger scroll to bottom and switch to the last node }; const sendMessage = async ( @@ -317,7 +347,7 @@ export const AppContextProvider = ({ onChunk(currMsgId); try { - await generateMessage(convId, currMsgId, onChunk); + await generateMessage(convId, currMsgId, onChunk, false); return true; } catch (_) { // TODO: rollback @@ -364,6 +394,38 @@ export const AppContextProvider = ({ await generateMessage(convId, parentNodeId, onChunk); }; + const continueMessageAndGenerate = async ( + convId: string, + messageIdToContinue: Message['id'], + newContent: string, + onChunk: CallbackGeneratedChunk + ) => { + if (isGenerating(convId)) return; + + const existingMessage = await StorageUtils.getMessage( + convId, + messageIdToContinue + ); + if (!existingMessage || existingMessage.role !== 'assistant') { + console.error( + 'Cannot continue non-assistant message or message not found' + ); + toast.error( + 'Failed to continue message: Not an assistant message or not found.' + ); + return; + } + const updatedAssistantMessage: Message = { + ...existingMessage, + content: newContent, + }; + //children: [], // Clear existing children to start a new branch of generation + + await StorageUtils.updateMessage(updatedAssistantMessage); + onChunk; + }; + + const saveConfig = (config: typeof CONFIG_DEFAULT) => { StorageUtils.setConfig(config); setConfig(config); @@ -378,6 +440,7 @@ export const AppContextProvider = ({ sendMessage, stopGenerating, replaceMessageAndGenerate, + continueMessageAndGenerate, canvasData, setCanvasData, config, diff --git a/examples/server/webui/src/utils/common.tsx b/examples/server/webui/src/utils/common.tsx index 09b08b5c..f664f6e8 100644 --- a/examples/server/webui/src/utils/common.tsx +++ b/examples/server/webui/src/utils/common.tsx @@ -36,3 +36,39 @@ export const OpenInNewTab = ({ {children} ); + +export function BtnWithTooltips({ + className, + onClick, + onMouseLeave, + children, + tooltipsContent, + disabled, +}: { + className?: string; + onClick: () => void; + onMouseLeave?: () => void; + children: React.ReactNode; + tooltipsContent: string; + disabled?: boolean; +}) { + // the onClick handler is on the container, so screen readers can safely ignore the inner button + // this prevents the label from being read twice + return ( +
      + +
      + ); +} \ No newline at end of file diff --git a/examples/server/webui/src/utils/storage.ts b/examples/server/webui/src/utils/storage.ts index 1dfc9d97..88b1fef4 100644 --- a/examples/server/webui/src/utils/storage.ts +++ b/examples/server/webui/src/utils/storage.ts @@ -2,8 +2,9 @@ // format: { [convId]: { id: string, lastModified: number, messages: [...] } } import { CONFIG_DEFAULT } from '../Config'; -import { Conversation, Message, TimingReport } from './types'; +import { Conversation, Message, TimingReport, SettingsPreset } from './types'; import Dexie, { Table } from 'dexie'; +import { exportDB as exportDexieDB } from 'dexie-export-import'; const event = new EventTarget(); @@ -32,6 +33,27 @@ db.version(1).stores({ // convId is a string prefixed with 'conv-' const StorageUtils = { + + async exportDB() { + return await exportDexieDB(db); + }, + + async importDB(file: File) { + await db.delete(); + await db.open(); + return await db.import(file); + }, + + /** + * update the name of a conversation + */ + async updateConversationName(convId: string, name: string): Promise { + await db.conversations.update(convId, { + name, + // lastModified: Date.now(), Don't update modified date + }); + dispatchConversationChange(convId); + }, /** * manage conversations */ @@ -203,8 +225,182 @@ const StorageUtils = { localStorage.setItem('theme', theme); } }, + +// Add to StorageUtils object +// Add this to the StorageUtils object +async importConversation(importedData: { + conv: Conversation; + messages: Message[]; +}): Promise { + const { conv, messages } = importedData; + + // Check for existing conversation ID + let newConvId = conv.id; + const existing = await StorageUtils.getOneConversation(newConvId); + if (existing) { + newConvId = `conv-${Date.now()}`; + } + + // Create ID mapping for messages + const idMap = new Map(); + const baseId = Date.now(); + messages.forEach((msg, index) => { + idMap.set(msg.id, baseId + index); + }); + + // Create a mutable copy of messages + const updatedMessages = messages.map(msg => ({ ...msg })); + + // Find root message before we process IDs + const rootMessage = updatedMessages.find(m => m.type === 'root'); + + // Ask user about system prompt update BEFORE processing IDs + let shouldUpdateSystemPrompt = false; + if (rootMessage) { + shouldUpdateSystemPrompt = confirm( + `This conversation contains a system prompt:\n\n"${rootMessage.content.slice(0, 100)}${rootMessage.content.length > 100 ? '...' : ''}"\n\nUpdate your system settings to use this prompt?` + ); + } + + // Now update messages with new IDs + updatedMessages.forEach(msg => { + msg.id = idMap.get(msg.id)!; + msg.convId = newConvId; + msg.parent = msg.parent === -1 ? -1 : (idMap.get(msg.parent) ?? -1); + msg.children = msg.children.map(childId => idMap.get(childId)!); + }); + + // Create new conversation with updated IDs + const conversation: Conversation = { + ...conv, + id: newConvId, + currNode: idMap.get(conv.currNode) || updatedMessages[0]?.id || -1 + }; + + // Update system prompt ONLY if user confirmed + if (shouldUpdateSystemPrompt && rootMessage) { + const config = StorageUtils.getConfig(); + config.systemMessage = rootMessage.content || ''; + StorageUtils.setConfig(config); + } + + // Insert in transaction + await db.transaction('rw', db.conversations, db.messages, async () => { + await db.conversations.add(conversation); + await db.messages.bulkAdd(updatedMessages); + }); + + // Store conversation ID for post-refresh navigation + //localStorage.setItem('postImportNavigation', newConvId); + + // Refresh the page to apply changes + window.location.reload(); + + return conversation; +}, +/** + * Open file dialog and import conversation from JSON file + * @returns Promise resolving to imported conversation or null + */ + async importConversationFromFile(): Promise { + return new Promise((resolve) => { + // Create invisible file input + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = '.json,application/json'; + fileInput.style.display = 'none'; + + fileInput.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) { + resolve(null); + return; + } + + try { + const fileText = await file.text(); + const jsonData = JSON.parse(fileText); + + // Validate JSON structure + if (!jsonData.conv || !jsonData.messages) { + throw new Error('Invalid conversation format'); + } + + const conversation = await StorageUtils.importConversation(jsonData); + resolve(conversation); + } catch (error) { + console.error('Import failed:', error); + alert(`Import failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + resolve(null); + } finally { + document.body.removeChild(fileInput); + } + }; + + // Add to DOM and trigger click + document.body.appendChild(fileInput); + fileInput.click(); + }); + }, + + // get message + async getMessage( + convId: string, + messageId: Message['id'] + ): Promise { + return await db.messages.where({ convId, id: messageId }).first(); + }, + async updateMessage(updatedMessage: Message): Promise { + await db.transaction('rw', db.conversations, db.messages, async () => { + await db.messages.put(updatedMessage); + await db.conversations.update(updatedMessage.convId, { + lastModified: Date.now(), + currNode: updatedMessage.id, + }); + }); + dispatchConversationChange(updatedMessage.convId); + }, + // manage presets + getPresets(): SettingsPreset[] { + const presetsJson = localStorage.getItem('presets'); + if (!presetsJson) return []; + try { + return JSON.parse(presetsJson); + } catch (e) { + console.error('Failed to parse presets', e); + return []; + } + }, + savePreset(name: string, config: typeof CONFIG_DEFAULT): SettingsPreset { + const presets = StorageUtils.getPresets(); + const now = Date.now(); + const preset: SettingsPreset = { + id: `preset-${now}`, + name, + createdAt: now, + config: { ...config }, // copy the config + }; + presets.push(preset); + localStorage.setItem('presets', JSON.stringify(presets)); + return preset; + }, + updatePreset(id: string, config: typeof CONFIG_DEFAULT): void { + const presets = StorageUtils.getPresets(); + const index = presets.findIndex((p) => p.id === id); + if (index !== -1) { + presets[index].config = { ...config }; + localStorage.setItem('presets', JSON.stringify(presets)); + } + }, + deletePreset(id: string): void { + const presets = StorageUtils.getPresets(); + const filtered = presets.filter((p) => p.id !== id); + localStorage.setItem('presets', JSON.stringify(filtered)); + }, }; + + export default StorageUtils; // Migration from localStorage to IndexedDB diff --git a/examples/server/webui/src/utils/types.ts b/examples/server/webui/src/utils/types.ts index 0eb77400..2edfc8f3 100644 --- a/examples/server/webui/src/utils/types.ts +++ b/examples/server/webui/src/utils/types.ts @@ -89,3 +89,11 @@ export interface CanvasPyInterpreter { } export type CanvasData = CanvasPyInterpreter; + + +export interface SettingsPreset { + id: string; // format: `preset-{timestamp}` + name: string; + createdAt: number; // timestamp from Date.now() + config: Record; // partial CONFIG_DEFAULT +} \ No newline at end of file -- cgit v1.2.3