This commit is contained in:
Saifeddine ALOUI 2025-04-09 19:54:13 +02:00
parent 456e0a8da7
commit b37fbf6c41
18 changed files with 824 additions and 660 deletions

View File

@ -1,3 +1,7 @@
# LoLLMs v19.2242 (tWINS) Changelog
Date: April 09, 2025
- Added the possibility to star discussions
# LoLLMs v19.10 (Ghibli) Changelog
Date: April 02, 2025

View File

@ -1 +1 @@
import{b as O,d as b,L as r,f as s,g as a,s as t,j as P,l as n,t as e}from"./index-BcINHkQK.js";const S={__proto__:null,anyref:34,dataref:34,eqref:34,externref:34,i31ref:34,funcref:34,i8:34,i16:34,i32:34,i64:34,f32:34,f64:34},Q=r.deserialize({version:14,states:"!^Q]QPOOOqQPO'#CbOOQO'#Cd'#CdOOQO'#Cl'#ClOOQO'#Ch'#ChQ]QPOOOOQO,58|,58|OxQPO,58|OOQO-E6f-E6fOOQO1G.h1G.h",stateData:"!P~O_OSPOSQOS~OTPOVROXROYROZROaQO~OSUO~P]OSXO~P]O",goto:"xaPPPPPPbPbPPPhPPPrXROPTVQTOQVPTWTVXSOPTV",nodeNames:"⚠ LineComment BlockComment Module ) ( App Identifier Type Keyword Number String",maxTerm:17,nodeProps:[["isolate",-3,1,2,11,""],["openedBy",4,"("],["closedBy",5,")"],["group",-6,6,7,8,9,10,11,"Expression"]],skippedNodes:[0,1,2],repeatNodeCount:1,tokenData:"0o~R^XY}YZ}]^}pq}rs!Stu#pxy'Uyz(e{|(j}!O(j!Q!R(s!R![*p!]!^.^#T#o.{~!SO_~~!VVOr!Srs!ls#O!S#O#P!q#P;'S!S;'S;=`#j<%lO!S~!qOZ~~!tRO;'S!S;'S;=`!};=`O!S~#QWOr!Srs!ls#O!S#O#P!q#P;'S!S;'S;=`#j;=`<%l!S<%lO!S~#mP;=`<%l!S~#siqr%bst%btu%buv%bvw%bwx%bz{%b{|%b}!O%b!O!P%b!P!Q%b!Q![%b![!]%b!^!_%b!_!`%b!`!a%b!a!b%b!b!c%b!c!}%b#Q#R%b#R#S%b#S#T%b#T#o%b#p#q%b#r#s%b~%giV~qr%bst%btu%buv%bvw%bwx%bz{%b{|%b}!O%b!O!P%b!P!Q%b!Q![%b![!]%b!^!_%b!_!`%b!`!a%b!a!b%b!b!c%b!c!}%b#Q#R%b#R#S%b#S#T%b#T#o%b#p#q%b#r#s%b~'ZPT~!]!^'^~'aTO!]'^!]!^'p!^;'S'^;'S;=`(_<%lO'^~'sVOy'^yz(Yz!]'^!]!^'p!^;'S'^;'S;=`(_<%lO'^~(_OQ~~(bP;=`<%l'^~(jOS~~(mQ!Q!R(s!R![*p~(xUY~!O!P)[!Q![*p!g!h){#R#S+U#X#Y){#l#m+[~)aRY~!Q![)j!g!h){#X#Y){~)oSY~!Q![)j!g!h){#R#S*j#X#Y){~*OR{|*X}!O*X!Q![*_~*[P!Q![*_~*dQY~!Q![*_#R#S*X~*mP!Q![)j~*uTY~!O!P)[!Q![*p!g!h){#R#S+U#X#Y){~+XP!Q![*p~+_R!Q![+h!c!i+h#T#Z+h~+mVY~!O!P,S!Q![+h!c!i+h!r!s-P#R#S+[#T#Z+h#d#e-P~,XTY~!Q![,h!c!i,h!r!s-P#T#Z,h#d#e-P~,mUY~!Q![,h!c!i,h!r!s-P#R#S.Q#T#Z,h#d#e-P~-ST{|-c}!O-c!Q![-o!c!i-o#T#Z-o~-fR!Q![-o!c!i-o#T#Z-o~-tSY~!Q![-o!c!i-o#R#S-c#T#Z-o~.TR!Q![,h!c!i,h#T#Z,h~.aP!]!^.d~.iSP~OY.dZ;'S.d;'S;=`.u<%lO.d~.xP;=`<%l.d~/QiX~qr.{st.{tu.{uv.{vw.{wx.{z{.{{|.{}!O.{!O!P.{!P!Q.{!Q![.{![!].{!^!_.{!_!`.{!`!a.{!a!b.{!b!c.{!c!}.{#Q#R.{#R#S.{#S#T.{#T#o.{#p#q.{#r#s.{",tokenizers:[0],topRules:{Module:[0,3]},specialized:[{term:9,get:o=>S[o]||-1}],tokenPrec:0}),i=O.define({name:"wast",parser:Q.configure({props:[s.add({App:P({closing:")",align:!1})}),a.add({App:n,BlockComment(o){return{from:o.from+2,to:o.to-2}}}),t({Keyword:e.keyword,Type:e.typeName,Number:e.number,String:e.string,Identifier:e.variableName,LineComment:e.lineComment,BlockComment:e.blockComment,"( )":e.paren})]}),languageData:{commentTokens:{line:";;",block:{open:"(;",close:";)"}},closeBrackets:{brackets:["(",'"']}}});function p(){return new b(i)}export{p as wast,i as wastLanguage};
import{b as O,d as b,L as r,f as s,g as a,s as t,j as P,l as n,t as e}from"./index-Cj8ZvKDR.js";const S={__proto__:null,anyref:34,dataref:34,eqref:34,externref:34,i31ref:34,funcref:34,i8:34,i16:34,i32:34,i64:34,f32:34,f64:34},Q=r.deserialize({version:14,states:"!^Q]QPOOOqQPO'#CbOOQO'#Cd'#CdOOQO'#Cl'#ClOOQO'#Ch'#ChQ]QPOOOOQO,58|,58|OxQPO,58|OOQO-E6f-E6fOOQO1G.h1G.h",stateData:"!P~O_OSPOSQOS~OTPOVROXROYROZROaQO~OSUO~P]OSXO~P]O",goto:"xaPPPPPPbPbPPPhPPPrXROPTVQTOQVPTWTVXSOPTV",nodeNames:"⚠ LineComment BlockComment Module ) ( App Identifier Type Keyword Number String",maxTerm:17,nodeProps:[["isolate",-3,1,2,11,""],["openedBy",4,"("],["closedBy",5,")"],["group",-6,6,7,8,9,10,11,"Expression"]],skippedNodes:[0,1,2],repeatNodeCount:1,tokenData:"0o~R^XY}YZ}]^}pq}rs!Stu#pxy'Uyz(e{|(j}!O(j!Q!R(s!R![*p!]!^.^#T#o.{~!SO_~~!VVOr!Srs!ls#O!S#O#P!q#P;'S!S;'S;=`#j<%lO!S~!qOZ~~!tRO;'S!S;'S;=`!};=`O!S~#QWOr!Srs!ls#O!S#O#P!q#P;'S!S;'S;=`#j;=`<%l!S<%lO!S~#mP;=`<%l!S~#siqr%bst%btu%buv%bvw%bwx%bz{%b{|%b}!O%b!O!P%b!P!Q%b!Q![%b![!]%b!^!_%b!_!`%b!`!a%b!a!b%b!b!c%b!c!}%b#Q#R%b#R#S%b#S#T%b#T#o%b#p#q%b#r#s%b~%giV~qr%bst%btu%buv%bvw%bwx%bz{%b{|%b}!O%b!O!P%b!P!Q%b!Q![%b![!]%b!^!_%b!_!`%b!`!a%b!a!b%b!b!c%b!c!}%b#Q#R%b#R#S%b#S#T%b#T#o%b#p#q%b#r#s%b~'ZPT~!]!^'^~'aTO!]'^!]!^'p!^;'S'^;'S;=`(_<%lO'^~'sVOy'^yz(Yz!]'^!]!^'p!^;'S'^;'S;=`(_<%lO'^~(_OQ~~(bP;=`<%l'^~(jOS~~(mQ!Q!R(s!R![*p~(xUY~!O!P)[!Q![*p!g!h){#R#S+U#X#Y){#l#m+[~)aRY~!Q![)j!g!h){#X#Y){~)oSY~!Q![)j!g!h){#R#S*j#X#Y){~*OR{|*X}!O*X!Q![*_~*[P!Q![*_~*dQY~!Q![*_#R#S*X~*mP!Q![)j~*uTY~!O!P)[!Q![*p!g!h){#R#S+U#X#Y){~+XP!Q![*p~+_R!Q![+h!c!i+h#T#Z+h~+mVY~!O!P,S!Q![+h!c!i+h!r!s-P#R#S+[#T#Z+h#d#e-P~,XTY~!Q![,h!c!i,h!r!s-P#T#Z,h#d#e-P~,mUY~!Q![,h!c!i,h!r!s-P#R#S.Q#T#Z,h#d#e-P~-ST{|-c}!O-c!Q![-o!c!i-o#T#Z-o~-fR!Q![-o!c!i-o#T#Z-o~-tSY~!Q![-o!c!i-o#R#S-c#T#Z-o~.TR!Q![,h!c!i,h#T#Z,h~.aP!]!^.d~.iSP~OY.dZ;'S.d;'S;=`.u<%lO.d~.xP;=`<%l.d~/QiX~qr.{st.{tu.{uv.{vw.{wx.{z{.{{|.{}!O.{!O!P.{!P!Q.{!Q![.{![!].{!^!_.{!_!`.{!`!a.{!a!b.{!b!c.{!c!}.{#Q#R.{#R#S.{#S#T.{#T#o.{#p#q.{#r#s.{",tokenizers:[0],topRules:{Module:[0,3]},specialized:[{term:9,get:o=>S[o]||-1}],tokenPrec:0}),i=O.define({name:"wast",parser:Q.configure({props:[s.add({App:P({closing:")",align:!1})}),a.add({App:n,BlockComment(o){return{from:o.from+2,to:o.to-2}}}),t({Keyword:e.keyword,Type:e.typeName,Number:e.number,String:e.string,Identifier:e.variableName,LineComment:e.lineComment,BlockComment:e.blockComment,"( )":e.paren})]}),languageData:{commentTokens:{line:";;",block:{open:"(;",close:";)"}},closeBrackets:{brackets:["(",'"']}}});function p(){return new b(i)}export{p as wast,i as wastLanguage};

File diff suppressed because one or more lines are too long

26
web/dist/assets/index-B_pLDTYj.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{b as q,d as P,q as g,r as l,L as c,u as i,s as R,t as r,E as p}from"./index-BcINHkQK.js";const b=1,$=33,m=34,v=35,x=36,d=new p(O=>{let e=O.pos;for(;;){if(O.next==10){O.advance();break}else if(O.next==123&&O.peek(1)==123||O.next<0)break;O.advance()}O.pos>e&&O.acceptToken(b)});function n(O,e,a){return new p(t=>{let u=t.pos;for(;t.next!=O&&t.next>=0&&(a||t.next!=38&&(t.next!=123||t.peek(1)!=123));)t.advance();t.pos>u&&t.acceptToken(e)})}const W=n(39,$,!1),C=n(34,m,!1),T=n(39,v,!0),f=n(34,x,!0),A=c.deserialize({version:14,states:"(jOVOqOOOeQpOOOvO!bO'#CaOOOP'#Cx'#CxQVOqOOO!OQpO'#CfO!WQpO'#ClO!]QpO'#CrO!bQpO'#CsOOQO'#Cv'#CvQ!gQpOOQ!lQpOOQ!qQpOOOOOV,58{,58{O!vOpO,58{OOOP-E6v-E6vO!{QpO,59QO#TQpO,59QOOQO,59W,59WO#YQpO,59^OOQO,59_,59_O#_QpOOO#_QpOOO#gQpOOOOOV1G.g1G.gO#oQpO'#CyO#tQpO1G.lOOQO1G.l1G.lO#|QpO1G.lOOQO1G.x1G.xO$UO`O'#DUO$ZOWO'#DUOOQO'#Co'#CoQOQpOOOOQO'#Cu'#CuO$`OtO'#CwO$qOrO'#CwOOQO,59e,59eOOQO-E6w-E6wOOQO7+$W7+$WO%SQpO7+$WO%[QpO7+$WOOOO'#Cp'#CpO%aOpO,59pOOOO'#Cq'#CqO%fOpO,59pOOOS'#Cz'#CzO%kOtO,59cOOQO,59c,59cOOOQ'#C{'#C{O%|OrO,59cO&_QpO<<GrOOQO<<Gr<<GrOOQO1G/[1G/[OOOS-E6x-E6xOOQO1G.}1G.}OOOQ-E6y-E6yOOQOAN=^AN=^",stateData:"&d~OvOS~OPROSQOVROWRO~OZTO[XO^VOaUOhWO~OR]OU^O~O[`O^aO~O[bO~O[cO~O[dO~ObeO~ObfO~ObgO~ORhO~O]kOwiO~O[lO~O_mO~OynOzoO~OysOztO~O[uO~O]wOwiO~O_yOwiO~OtzO~Os|O~OSQOV!OOW!OOr!OOy!QO~OSQOV!ROW!ROq!ROz!QO~O_!TOwiO~O]!UO~Oy!VO~Oz!VO~OSQOV!OOW!OOr!OOy!XO~OSQOV!ROW!ROq!ROz!XO~O]!ZO~O",goto:"#dyPPPPPzPPPP!WPPPPP!WPP!Z!^!a!d!dP!g!j!m!p!v#Q#WPPPPPPPP#^SROSS!Os!PT!Rt!SRYPRqeR{nR}oRZPRqfR[PRqgQSOR_SQj`SvjxRxlQ!PsR!W!PQ!StR!Y!SQpeRrf",nodeNames:"⚠ Text Content }} {{ Interpolation InterpolationContent Entity InvalidEntity Attribute BoundAttributeName [ Identifier ] ( ) ReferenceName # Is ExpressionAttributeValue AttributeInterpolation AttributeInterpolation EventName DirectiveName * StatementAttributeValue AttributeName AttributeValue",maxTerm:42,nodeProps:[["openedBy",3,"{{",15,"("],["closedBy",4,"}}",14,")"],["isolate",-4,5,19,25,27,""]],skippedNodes:[0],repeatNodeCount:4,tokenData:"0r~RyOX#rXY$mYZ$mZ]#r]^$m^p#rpq$mqr#rrs%jst&Qtv#rvw&hwx)zxy*byz*xz{+`{}#r}!O+v!O!P-]!P!Q#r!Q![+v![!]+v!]!_#r!_!`-s!`!c#r!c!}+v!}#O.Z#O#P#r#P#Q.q#Q#R#r#R#S+v#S#T#r#T#o+v#o#p/X#p#q#r#q#r0Z#r%W#r%W;'S+v;'S;:j-V;:j;=`$g<%lO+vQ#wTUQO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rQ$ZSO#q#r#r;'S#r;'S;=`$g<%lO#rQ$jP;=`<%l#rR$t[UQvPOX#rXY$mYZ$mZ]#r]^$m^p#rpq$mq#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR%qTyPUQO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR&XTaPUQO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR&oXUQWPOp'[pq#rq!]'[!]!^#r!^#q'[#q#r(d#r;'S'[;'S;=`)t<%lO'[R'aXUQOp'[pq#rq!]'[!]!^'|!^#q'[#q#r(d#r;'S'[;'S;=`)t<%lO'[R(TTVPUQO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR(gXOp'[pq#rq!]'[!]!^'|!^#q'[#q#r)S#r;'S'[;'S;=`)t<%lO'[P)VUOp)Sq!])S!]!^)i!^;'S)S;'S;=`)n<%lO)SP)nOVPP)qP;=`<%l)SR)wP;=`<%l'[R*RTzPUQO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR*iT^PUQO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR+PT_PUQO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR+gThPUQO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR+}b[PUQO}#r}!O+v!O!Q#r!Q![+v![!]+v!]!c#r!c!}+v!}#R#r#R#S+v#S#T#r#T#o+v#o#q#r#q#r$W#r%W#r%W;'S+v;'S;:j-V;:j;=`$g<%lO+vR-YP;=`<%l+vR-dTwPUQO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR-zTUQbPO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR.bTZPUQO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR.xT]PUQO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR/^VUQO#o#r#o#p/s#p#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR/zTSPUQO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#r~0^TO#q#r#q#r0m#r;'S#r;'S;=`$g<%lO#r~0rOR~",tokenizers:[d,W,C,T,f,0,1],topRules:{Content:[0,2],Attribute:[1,9]},tokenPrec:0}),V=i.parser.configure({top:"SingleExpression"}),Q=A.configure({props:[R({Text:r.content,Is:r.definitionOperator,AttributeName:r.attributeName,"AttributeValue ExpressionAttributeValue StatementAttributeValue":r.attributeValue,Entity:r.character,InvalidEntity:r.invalid,"BoundAttributeName/Identifier":r.attributeName,"EventName/Identifier":r.special(r.attributeName),"ReferenceName/Identifier":r.variableName,"DirectiveName/Identifier":r.keyword,"{{ }}":r.brace,"( )":r.paren,"[ ]":r.bracket,"# '*'":r.punctuation})]}),o={parser:V},w={parser:i.parser},U=Q.configure({wrap:l((O,e)=>O.name=="InterpolationContent"?o:null)}),y=Q.configure({wrap:l((O,e)=>{var a;return O.name=="InterpolationContent"?o:O.name!="AttributeInterpolation"?null:((a=O.node.parent)===null||a===void 0?void 0:a.name)=="StatementAttributeValue"?w:o}),top:"Attribute"}),E={parser:U},N={parser:y},s=g();function S(O){return O.configure({wrap:l(z)},"angular")}const k=S(s.language);function z(O,e){switch(O.name){case"Attribute":return/^[*#(\[]|\{\{/.test(e.read(O.from,O.to))?N:null;case"Text":return E}return null}function G(O={}){let e=s;if(O.base){if(O.base.language.name!="html"||!(O.base.language instanceof q))throw new RangeError("The base option must be the result of calling html(...)");e=O.base}return new P(e.language==s.language?k:S(e.language),[e.support,e.language.data.of({closeBrackets:{brackets:["[","{",'"']},indentOnInput:/^\s*[\}\]]$/})])}export{G as angular,k as angularLanguage};
import{b as q,d as P,q as g,r as l,L as c,u as i,s as R,t as r,E as p}from"./index-Cj8ZvKDR.js";const b=1,$=33,m=34,v=35,x=36,d=new p(O=>{let e=O.pos;for(;;){if(O.next==10){O.advance();break}else if(O.next==123&&O.peek(1)==123||O.next<0)break;O.advance()}O.pos>e&&O.acceptToken(b)});function n(O,e,a){return new p(t=>{let u=t.pos;for(;t.next!=O&&t.next>=0&&(a||t.next!=38&&(t.next!=123||t.peek(1)!=123));)t.advance();t.pos>u&&t.acceptToken(e)})}const W=n(39,$,!1),C=n(34,m,!1),T=n(39,v,!0),f=n(34,x,!0),A=c.deserialize({version:14,states:"(jOVOqOOOeQpOOOvO!bO'#CaOOOP'#Cx'#CxQVOqOOO!OQpO'#CfO!WQpO'#ClO!]QpO'#CrO!bQpO'#CsOOQO'#Cv'#CvQ!gQpOOQ!lQpOOQ!qQpOOOOOV,58{,58{O!vOpO,58{OOOP-E6v-E6vO!{QpO,59QO#TQpO,59QOOQO,59W,59WO#YQpO,59^OOQO,59_,59_O#_QpOOO#_QpOOO#gQpOOOOOV1G.g1G.gO#oQpO'#CyO#tQpO1G.lOOQO1G.l1G.lO#|QpO1G.lOOQO1G.x1G.xO$UO`O'#DUO$ZOWO'#DUOOQO'#Co'#CoQOQpOOOOQO'#Cu'#CuO$`OtO'#CwO$qOrO'#CwOOQO,59e,59eOOQO-E6w-E6wOOQO7+$W7+$WO%SQpO7+$WO%[QpO7+$WOOOO'#Cp'#CpO%aOpO,59pOOOO'#Cq'#CqO%fOpO,59pOOOS'#Cz'#CzO%kOtO,59cOOQO,59c,59cOOOQ'#C{'#C{O%|OrO,59cO&_QpO<<GrOOQO<<Gr<<GrOOQO1G/[1G/[OOOS-E6x-E6xOOQO1G.}1G.}OOOQ-E6y-E6yOOQOAN=^AN=^",stateData:"&d~OvOS~OPROSQOVROWRO~OZTO[XO^VOaUOhWO~OR]OU^O~O[`O^aO~O[bO~O[cO~O[dO~ObeO~ObfO~ObgO~ORhO~O]kOwiO~O[lO~O_mO~OynOzoO~OysOztO~O[uO~O]wOwiO~O_yOwiO~OtzO~Os|O~OSQOV!OOW!OOr!OOy!QO~OSQOV!ROW!ROq!ROz!QO~O_!TOwiO~O]!UO~Oy!VO~Oz!VO~OSQOV!OOW!OOr!OOy!XO~OSQOV!ROW!ROq!ROz!XO~O]!ZO~O",goto:"#dyPPPPPzPPPP!WPPPPP!WPP!Z!^!a!d!dP!g!j!m!p!v#Q#WPPPPPPPP#^SROSS!Os!PT!Rt!SRYPRqeR{nR}oRZPRqfR[PRqgQSOR_SQj`SvjxRxlQ!PsR!W!PQ!StR!Y!SQpeRrf",nodeNames:"⚠ Text Content }} {{ Interpolation InterpolationContent Entity InvalidEntity Attribute BoundAttributeName [ Identifier ] ( ) ReferenceName # Is ExpressionAttributeValue AttributeInterpolation AttributeInterpolation EventName DirectiveName * StatementAttributeValue AttributeName AttributeValue",maxTerm:42,nodeProps:[["openedBy",3,"{{",15,"("],["closedBy",4,"}}",14,")"],["isolate",-4,5,19,25,27,""]],skippedNodes:[0],repeatNodeCount:4,tokenData:"0r~RyOX#rXY$mYZ$mZ]#r]^$m^p#rpq$mqr#rrs%jst&Qtv#rvw&hwx)zxy*byz*xz{+`{}#r}!O+v!O!P-]!P!Q#r!Q![+v![!]+v!]!_#r!_!`-s!`!c#r!c!}+v!}#O.Z#O#P#r#P#Q.q#Q#R#r#R#S+v#S#T#r#T#o+v#o#p/X#p#q#r#q#r0Z#r%W#r%W;'S+v;'S;:j-V;:j;=`$g<%lO+vQ#wTUQO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rQ$ZSO#q#r#r;'S#r;'S;=`$g<%lO#rQ$jP;=`<%l#rR$t[UQvPOX#rXY$mYZ$mZ]#r]^$m^p#rpq$mq#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR%qTyPUQO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR&XTaPUQO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR&oXUQWPOp'[pq#rq!]'[!]!^#r!^#q'[#q#r(d#r;'S'[;'S;=`)t<%lO'[R'aXUQOp'[pq#rq!]'[!]!^'|!^#q'[#q#r(d#r;'S'[;'S;=`)t<%lO'[R(TTVPUQO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR(gXOp'[pq#rq!]'[!]!^'|!^#q'[#q#r)S#r;'S'[;'S;=`)t<%lO'[P)VUOp)Sq!])S!]!^)i!^;'S)S;'S;=`)n<%lO)SP)nOVPP)qP;=`<%l)SR)wP;=`<%l'[R*RTzPUQO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR*iT^PUQO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR+PT_PUQO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR+gThPUQO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR+}b[PUQO}#r}!O+v!O!Q#r!Q![+v![!]+v!]!c#r!c!}+v!}#R#r#R#S+v#S#T#r#T#o+v#o#q#r#q#r$W#r%W#r%W;'S+v;'S;:j-V;:j;=`$g<%lO+vR-YP;=`<%l+vR-dTwPUQO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR-zTUQbPO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR.bTZPUQO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR.xT]PUQO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR/^VUQO#o#r#o#p/s#p#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#rR/zTSPUQO#q#r#q#r$W#r;'S#r;'S;=`$g<%lO#r~0^TO#q#r#q#r0m#r;'S#r;'S;=`$g<%lO#r~0rOR~",tokenizers:[d,W,C,T,f,0,1],topRules:{Content:[0,2],Attribute:[1,9]},tokenPrec:0}),V=i.parser.configure({top:"SingleExpression"}),Q=A.configure({props:[R({Text:r.content,Is:r.definitionOperator,AttributeName:r.attributeName,"AttributeValue ExpressionAttributeValue StatementAttributeValue":r.attributeValue,Entity:r.character,InvalidEntity:r.invalid,"BoundAttributeName/Identifier":r.attributeName,"EventName/Identifier":r.special(r.attributeName),"ReferenceName/Identifier":r.variableName,"DirectiveName/Identifier":r.keyword,"{{ }}":r.brace,"( )":r.paren,"[ ]":r.bracket,"# '*'":r.punctuation})]}),o={parser:V},w={parser:i.parser},U=Q.configure({wrap:l((O,e)=>O.name=="InterpolationContent"?o:null)}),y=Q.configure({wrap:l((O,e)=>{var a;return O.name=="InterpolationContent"?o:O.name!="AttributeInterpolation"?null:((a=O.node.parent)===null||a===void 0?void 0:a.name)=="StatementAttributeValue"?w:o}),top:"Attribute"}),E={parser:U},N={parser:y},s=g();function S(O){return O.configure({wrap:l(z)},"angular")}const k=S(s.language);function z(O,e){switch(O.name){case"Attribute":return/^[*#(\[]|\{\{/.test(e.read(O.from,O.to))?N:null;case"Text":return E}return null}function G(O={}){let e=s;if(O.base){if(O.base.language.name!="html"||!(O.base.language instanceof q))throw new RangeError("The base option must be the result of calling html(...)");e=O.base}return new P(e.language==s.language?k:S(e.language),[e.support,e.language.data.of({closeBrackets:{brackets:["[","{",'"']},indentOnInput:/^\s*[\}\]]$/})])}export{G as angular,k as angularLanguage};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
web/dist/index.html vendored
View File

@ -6,8 +6,8 @@
<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LoLLMS WebUI</title>
<script type="module" crossorigin src="/assets/index-BcINHkQK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BxPTDKFo.css">
<script type="module" crossorigin src="/assets/index-Cj8ZvKDR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B_pLDTYj.css">
</head>
<body>
<div id="app"></div>

View File

@ -87,7 +87,7 @@ export default {
showAudioViewer: false,
// Style Management
injectedStyleElements: [],
renderCount: 0,
renderCount: 0, // Added for detailed logging
};
},
computed: {
@ -102,335 +102,475 @@ export default {
handler(newValue, oldValue) {
this.renderCount++;
const currentRender = this.renderCount;
console.log(`[${this.instanceId}] Watcher triggered (Render #${currentRender}). UI changed: ${newValue !== oldValue}.`);
console.log(`[${this.instanceId}] Watcher triggered (Render #${currentRender}). UI changed: ${newValue !== oldValue}. New UI length: ${newValue?.length ?? 0}`);
// Check if the container ref exists before evaluating if it needs render based on child nodes
const htmlContainer = this.$refs.htmlContentContainer;
// Ensure render happens if value changes OR if the container is somehow empty (e.g., HMR)
const needsRender = newValue !== oldValue || !htmlContainer || !htmlContainer.hasChildNodes();
console.log(`[${this.instanceId}] Needs render evaluation (Render #${currentRender}): ${needsRender}`);
const needsRenderBasedOnContent = !htmlContainer || !htmlContainer.hasChildNodes();
// Determine if a render is needed: either the value changed OR the container is unexpectedly empty.
const needsRender = newValue !== oldValue || needsRenderBasedOnContent;
console.log(`[${this.instanceId}] Needs render evaluation (Render #${currentRender}): Value Changed=${newValue !== oldValue}, Container Empty=${needsRenderBasedOnContent} => Needs Render=${needsRender}`);
if (needsRender) {
console.log(`[${this.instanceId}] --- Starting Update Cycle (Render #${currentRender}) ---`);
this.cleanupDynamicContent(currentRender);
// Pass render context to cleanup for better logging
this.cleanupDynamicContent(`before render #${currentRender}`);
this.$nextTick(() => {
console.log(`[${this.instanceId}] $nextTick after cleanup (Render #${currentRender}): Starting renderContent.`);
// Pass render context to renderContent
this.renderContent(currentRender);
});
} else {
console.log(`[${this.instanceId}] Watcher triggered but skipping render (Render #${currentRender}).`);
console.log(`[${this.instanceId}] Watcher triggered but skipping render (Render #${currentRender}). UI appears unchanged and container has content.`);
}
}
}
},
beforeUnmount() {
console.log(`[${this.instanceId}] Component beforeUnmount hook.`);
// Pass context to cleanup
this.cleanupDynamicContent('beforeUnmount');
},
methods: {
logAlbumViewerMounted(type) {
console.log(`[${this.instanceId}] ${type} AlbumViewer successfully MOUNTED.`);
// Log when the conditional viewers actually get mounted
console.log(`%c[${this.instanceId}] ${type} AlbumViewer successfully MOUNTED.`, 'color: green; font-weight: bold;');
},
renderContent(renderContext) {
console.log(`[${this.instanceId}] renderContent CALLED (Context: ${renderContext})`);
console.log(`%c[${this.instanceId}] renderContent CALLED (Context: Render #${renderContext})`, 'color: blue; font-weight: bold;');
const targetContainer = this.$refs.htmlContentContainer;
if (!targetContainer) {
console.error(`[${this.instanceId}] ERROR: htmlContentContainer ref NOT FOUND during renderContent (Context: ${renderContext})!`);
// This is a critical error for rendering
console.error(`[${this.instanceId}] CRITICAL ERROR: htmlContentContainer ref NOT FOUND during renderContent (Context: Render #${renderContext})! Cannot render HTML.`);
return;
}
targetContainer.innerHTML = ''; // Ensure clean slate
console.log(`[${this.instanceId}] Cleared targetContainer innerHTML (Context: ${renderContext})`);
// Clear previous standard HTML content *before* processing new UI
targetContainer.innerHTML = '';
console.log(`[${this.instanceId}] Cleared targetContainer innerHTML (Context: Render #${renderContext})`);
let imagesForMedia = []; // Local collection for images
let videosForMedia = []; // Local collection for videos
let audiosForMedia = []; // Local collection for audios
let mediaPlaceholderNeeded = false; // Flag if any media viewer might be needed
// Initialize local collections for media elements found in this render cycle
let imagesForMedia = [];
let videosForMedia = [];
let audiosForMedia = [];
let mediaPlaceholderNeeded = false; // Flag to check if *any* media viewer might be needed later
const incomingUi = this.ui || '';
const incomingUi = this.ui || ''; // Ensure we have a string
// Handle empty UI string explicitly
if (!incomingUi.trim()) {
console.warn(`[${this.instanceId}] No UI content provided (Context: ${renderContext}). Skipping parsing.`);
this.resetMediaState(`empty UI (${renderContext})`); // Ensure state is reset
return;
console.warn(`[${this.instanceId}] No UI content provided or UI is empty whitespace (Context: Render #${renderContext}). Skipping parsing and rendering.`);
// Explicitly reset media state if UI is empty
this.resetMediaState(`empty UI received (Render #${renderContext})`);
return; // Stop processing if there's nothing to render
}
console.log(`[${this.instanceId}] Parsing UI content (length: ${incomingUi.length}) (Context: ${renderContext})`);
const parser = new DOMParser();
const doc = parser.parseFromString(incomingUi, 'text/html');
console.log(`[${this.instanceId}] Parsing UI content (length: ${incomingUi.length}) (Context: Render #${renderContext})`);
try {
const parser = new DOMParser();
const doc = parser.parseFromString(incomingUi, 'text/html');
// --- 1. Inject Scoped CSS ---
const styles = doc.head.querySelectorAll('style');
console.log(`[${this.instanceId}] Found ${styles.length} style tags in <head>.`);
styles.forEach((style, index) => {
console.log(`[${this.instanceId}] Injecting head style #${index + 1}.`);
this.injectScopedCss(style.textContent, renderContext);
});
const bodyStyles = doc.body.querySelectorAll('style');
console.log(`[${this.instanceId}] Found ${bodyStyles.length} style tags in <body>.`);
bodyStyles.forEach((style, index) => {
console.log(`[${this.instanceId}] Injecting body style #${index + 1}.`);
this.injectScopedCss(style.textContent, renderContext);
});
// --- 1. Inject Scoped CSS ---
// Note: Styles are injected into the document <head>, not the component's container
const styles = doc.head.querySelectorAll('style');
console.log(`[${this.instanceId}] Found ${styles.length} style tags in parsed <head>.`);
styles.forEach((style, index) => {
console.log(`[${this.instanceId}] Injecting head style #${index + 1} (Context: Render #${renderContext}).`);
this.injectScopedCss(style.textContent, `head style #${index + 1} / Render #${renderContext}`);
});
const bodyStyles = doc.body.querySelectorAll('style');
console.log(`[${this.instanceId}] Found ${bodyStyles.length} style tags in parsed <body>.`);
bodyStyles.forEach((style, index) => {
console.log(`[${this.instanceId}] Injecting body style #${index + 1} (Context: Render #${renderContext}).`);
this.injectScopedCss(style.textContent, `body style #${index + 1} / Render #${renderContext}`);
});
// --- 2. Process HTML Body ---
console.log(`[${this.instanceId}] Processing body childNodes (Context: ${renderContext})`);
const processedNodes = []; // Nodes to be appended directly
const nodesToProcess = Array.from(doc.body.childNodes);
console.log(`[${this.instanceId}] Found ${nodesToProcess.length} nodes in parsed body.`);
// --- 2. Process HTML Body Nodes ---
console.log(`[${this.instanceId}] Processing body childNodes (Context: Render #${renderContext})`);
const processedNodes = []; // Collect nodes to be appended to targetContainer
const nodesToProcess = Array.from(doc.body.childNodes);
console.log(`[${this.instanceId}] Found ${nodesToProcess.length} nodes in parsed body.`);
nodesToProcess.forEach((node, index) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const tagName = node.tagName.toUpperCase();
const classList = node.classList;
console.log(`[${this.instanceId}] Processing Node #${index + 1}: <${tagName}>, Classes: ${classList}`);
nodesToProcess.forEach((node, index) => {
const nodeIdentifier = `Node #${index + 1} (Type: ${node.nodeType})`;
if (node.nodeType === Node.ELEMENT_NODE) {
const tagName = node.tagName.toUpperCase();
const classList = node.classList;
console.log(`[${this.instanceId}] Processing ${nodeIdentifier}: <${tagName}>, Classes: [${Array.from(classList).join(', ')}]`);
// --- Special Handling: Media Elements (Image, Video, Audio) ---
if (classList.contains('media')) {
const src = node.getAttribute('src'); // Common attribute
if (!src) {
console.warn(`[${this.instanceId}] -> Found '<${tagName}.media>' but it has NO src attribute. Skipping.`);
return; // Skip nodes without src
}
mediaPlaceholderNeeded = true; // Mark that some media viewer might be needed
// --- Special Handling: Media Elements (Image, Video, Audio) ---
if (classList.contains('media')) {
const src = node.getAttribute('src'); // Common attribute for media
if (!src) {
// Warn if a media element lacks a source
console.warn(`[${this.instanceId}] -> Found '<${tagName}.media>' but it has NO 'src' attribute. Skipping this media element.`);
return; // Skip this specific node, don't add to processedNodes or media collections
}
mediaPlaceholderNeeded = true; // Mark that we found at least one valid media element
if (tagName === 'IMG') {
console.log(`[${this.instanceId}] -> Found 'img.media' with src: ${src}`);
imagesForMedia.push(src);
return; // Don't append media elements directly, they go to viewers
} else if (tagName === 'VIDEO') {
console.log(`[${this.instanceId}] -> Found 'video.media' with src: ${src}`);
videosForMedia.push(src);
return; // Don't append media elements directly
} else if (tagName === 'AUDIO') {
console.log(`[${this.instanceId}] -> Found 'audio.media' with src: ${src}`);
audiosForMedia.push(src);
return; // Don't append media elements directly
} else {
console.warn(`[${this.instanceId}] -> Found '<${tagName}.media>' but it's not an IMG, VIDEO, or AUDIO tag. Treating as standard node.`);
// Fall through to be added to processedNodes if not returned
}
if (tagName === 'IMG') {
console.log(`[${this.instanceId}] -> Found 'img.media' with src: ${src}. Adding to image album list.`);
imagesForMedia.push(src);
// IMPORTANT: Do *not* add to processedNodes, handled by ImageAlbumViewer
return;
} else if (tagName === 'VIDEO') {
console.log(`[${this.instanceId}] -> Found 'video.media' with src: ${src}. Adding to video album list.`);
videosForMedia.push(src);
// IMPORTANT: Do *not* add to processedNodes
return;
} else if (tagName === 'AUDIO') {
console.log(`[${this.instanceId}] -> Found 'audio.media' with src: ${src}. Adding to audio album list.`);
audiosForMedia.push(src);
// IMPORTANT: Do *not* add to processedNodes
return;
} else {
// Log if a non-standard tag has .media class
console.warn(`[${this.instanceId}] -> Found '<${tagName}.media>' but it's not an IMG, VIDEO, or AUDIO tag. Treating as a standard HTML node.`);
// Fall through to be added to processedNodes below
}
}
// --- Special Handling: Clickable Image POST ---
if (tagName === 'IMG' && classList.contains('clickable-post')) {
console.log(`[${this.instanceId}] -> Found 'img.clickable-post'. Ensuring data attributes are present (or defaults).`);
// Ensure necessary data attributes exist for the click handler
if (!node.dataset.endpoint) node.dataset.endpoint = '/post_to_personality'; // Default endpoint
if (!node.dataset.payloadKey) node.dataset.payloadKey = 'img_path'; // Default payload key
// Fall through to be added to processedNodes
}
// --- Special Handling: Open Folder Link ---
if (tagName === 'A' && classList.contains('open-folder')) {
console.log(`[${this.instanceId}] -> Found 'a.open-folder'. Setting href='#' to prevent navigation.`);
node.setAttribute('href', '#'); // Prevent default link navigation
// Fall through to be added to processedNodes
}
// --- Special Handling: Source Elements (Rendered as standard HTML) ---
if (tagName === 'INTERNET_SOURCE' || tagName === 'LOCAL_SOURCE') {
console.log(`[${this.instanceId}] -> Found '${tagName}'. Will be rendered directly as HTML.`);
// Extract attributes for logging clarity, but mainly just render the element as is.
const href = node.getAttribute('href');
const summary = node.getAttribute('summary');
const similarity = node.getAttribute('similarity');
console.log(`[${this.instanceId}] Attributes - href: ${href}, summary: ${summary}, similarity: ${similarity}`);
// Fall through to be added to processedNodes
}
// If node hasn't been returned (like media elements), add it for standard HTML rendering
processedNodes.push(node);
console.log(`[${this.instanceId}] -> Adding <${tagName}> to processedNodes list for direct rendering.`);
} else if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) {
// Process non-empty text nodes
console.log(`[${this.instanceId}] Processing ${nodeIdentifier}: TextNode (non-empty). Adding to processedNodes list.`);
processedNodes.push(node);
} else {
// Log skipped nodes (like empty text nodes, comments)
console.log(`[${this.instanceId}] Skipping ${nodeIdentifier}`);
}
});
// --- 3. Append Processed Standard HTML & Source Nodes ---
console.log(`[${this.instanceId}] Appending ${processedNodes.length} processed nodes to targetContainer (Context: Render #${renderContext})`);
if (processedNodes.length > 0) {
processedNodes.forEach(node => {
// Use importNode to ensure nodes are correctly adopted by the document
targetContainer.appendChild(document.importNode(node, true));
});
console.log(`[${this.instanceId}] Finished appending nodes. Current targetContainer innerHTML length: ${targetContainer.innerHTML.length}`);
} else {
console.log(`[${this.instanceId}] No standard HTML nodes to append.`);
}
// --- Special Handling: Clickable Image POST ---
if (tagName === 'IMG' && classList.contains('clickable-post')) {
console.log(`[${this.instanceId}] -> Found 'img.clickable-post'. Ensuring data attributes.`);
if (!node.dataset.endpoint) node.dataset.endpoint = '/post_to_personality';
if (!node.dataset.payloadKey) node.dataset.payloadKey = 'img_path';
// Fall through to be added to processedNodes
}
// --- 4. Update State for Media Viewers ---
// This happens *after* standard HTML is rendered
console.log(`[${this.instanceId}] Evaluating media state updates (Context: Render #${renderContext}). Found media elements needing viewers: ${mediaPlaceholderNeeded}`);
// --- Special Handling: Open Folder Link ---
if (tagName === 'A' && classList.contains('open-folder')) {
console.log(`[${this.instanceId}] -> Found 'a.open-folder'. Setting href='#'`);
node.setAttribute('href', '#'); // Prevent navigation
// Fall through to be added to processedNodes
}
// --- Special Handling: Source Elements ---
if (tagName === 'INTERNET_SOURCE' || tagName === 'LOCAL_SOURCE') {
console.log(`[${this.instanceId}] -> Found '${tagName}'. Will be rendered directly.`);
// Extract attributes for potential future use, but mainly just render it.
// Attributes: icon, href, summary, similarity
// Fall through to be added to processedNodes
}
processedNodes.push(node); // Add node for standard appending
} else if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) {
console.log(`[${this.instanceId}] Processing Node #${index + 1}: TextNode (non-empty)`);
processedNodes.push(node); // Add text node
} else {
console.log(`[${this.instanceId}] Skipping Node #${index + 1} (Type: ${node.nodeType})`);
}
});
// --- 3. Append Processed Standard HTML & Source Nodes ---
console.log(`[${this.instanceId}] Appending ${processedNodes.length} processed nodes to targetContainer (Context: ${renderContext})`);
processedNodes.forEach(node => {
targetContainer.appendChild(document.importNode(node, true));
});
console.log(`[${this.instanceId}] Finished appending nodes. Current targetContainer innerHTML length: ${targetContainer.innerHTML.length}`);
// --- 4. Update State for Media Viewers ---
console.log(`[${this.instanceId}] Evaluating media state (Context: ${renderContext}). mediaPlaceholderNeeded=${mediaPlaceholderNeeded}`);
if (mediaPlaceholderNeeded) {
// Images
// Update Image Album State
if (imagesForMedia.length > 0) {
this.albumImages = [...imagesForMedia];
this.showAlbumViewer = true;
console.log(`[${this.instanceId}] SETTING Image Album state: show=${this.showAlbumViewer}, count=${this.albumImages.length}`);
console.log(`[${this.instanceId}] SETTING Image Album state: show=true, count=${imagesForMedia.length}`);
this.albumImages = [...imagesForMedia]; // Update with new images
if (!this.showAlbumViewer) this.showAlbumViewer = true; // Trigger v-if
} else {
this.albumImages = [];
this.showAlbumViewer = false;
if (this.showAlbumViewer) {
console.log(`[${this.instanceId}] RESETTING Image Album state: show=false`);
this.showAlbumViewer = false; // Hide viewer if no images
}
if (this.albumImages.length > 0) this.albumImages = []; // Clear array
}
// Videos
// Update Video Album State
if (videosForMedia.length > 0) {
console.log(`[${this.instanceId}] SETTING Video Album state: show=true, count=${videosForMedia.length}`);
this.albumVideos = [...videosForMedia];
this.showVideoViewer = true;
console.log(`[${this.instanceId}] SETTING Video Album state: show=${this.showVideoViewer}, count=${this.albumVideos.length}`);
if (!this.showVideoViewer) this.showVideoViewer = true;
} else {
this.albumVideos = [];
this.showVideoViewer = false;
if (this.showVideoViewer) {
console.log(`[${this.instanceId}] RESETTING Video Album state: show=false`);
this.showVideoViewer = false;
}
if (this.albumVideos.length > 0) this.albumVideos = [];
}
// Audios
// Update Audio Album State
if (audiosForMedia.length > 0) {
console.log(`[${this.instanceId}] SETTING Audio Album state: show=true, count=${audiosForMedia.length}`);
this.albumAudios = [...audiosForMedia];
this.showAudioViewer = true;
console.log(`[${this.instanceId}] SETTING Audio Album state: show=${this.showAudioViewer}, count=${this.albumAudios.length}`);
if (!this.showAudioViewer) this.showAudioViewer = true;
} else {
this.albumAudios = [];
this.showAudioViewer = false;
if (this.showAudioViewer) {
console.log(`[${this.instanceId}] RESETTING Audio Album state: show=false`);
this.showAudioViewer = false;
}
if (this.albumAudios.length > 0) this.albumAudios = [];
}
} else {
console.log(`[${this.instanceId}] No media elements found. Resetting all media states.`);
this.resetMediaState(`no media found (${renderContext})`);
// If no media elements were found at all in this UI update
if (!mediaPlaceholderNeeded && (this.showAlbumViewer || this.showVideoViewer || this.showAudioViewer)) {
console.log(`[${this.instanceId}] No media elements found in this UI update. Resetting all media states.`);
this.resetMediaState(`no media found in UI (Render #${renderContext})`);
}
// --- 5. Handle Original Scripts (Still generally discouraged for security/complexity) ---
// const scripts = doc.body.getElementsByTagName('script');
// console.log(`[${this.instanceId}] Found ${scripts.length} script tags in parsed body (Execution is disabled).`);
// ... logic to handle scripts if ever needed ...
} catch (error) {
console.error(`[${this.instanceId}] Error during DOM parsing or processing (Context: Render #${renderContext}):`, error);
// Consider resetting state or showing an error message in the UI
this.cleanupDynamicContent(`error during render #${renderContext}`); // Attempt cleanup on error
}
// --- 5. Handle Original Scripts (Still discouraged) ---
// const scripts = doc.body.getElementsByTagName('script');
// ...
console.log(`[${this.instanceId}] --- Finished renderContent (Context: ${renderContext}) ---`);
console.log(`%c[${this.instanceId}] --- Finished renderContent (Context: Render #${renderContext}) ---`, 'color: blue; font-weight: bold;');
},
injectScopedCss(css, renderContext) {
console.log(`[${this.instanceId}] Injecting scoped CSS (Context: ${renderContext})`);
injectScopedCss(css, context) {
console.log(`[${this.instanceId}] Attempting to inject scoped CSS (Context: ${context})`);
if (!css || !css.trim()) {
console.warn(`[${this.instanceId}] CSS content is empty, skipping injection.`);
console.warn(`[${this.instanceId}] CSS content is empty or whitespace-only, skipping injection (Context: ${context}).`);
return;
}
const scopedCss = this.scopeCSS(css);
const styleElement = document.createElement('style');
styleElement.textContent = scopedCss;
document.head.appendChild(styleElement);
this.injectedStyleElements.push(styleElement);
console.log(`[${this.instanceId}] Injected style element. Total injected: ${this.injectedStyleElements.length}`);
try {
const scopedCss = this.scopeCSS(css);
const styleElement = document.createElement('style');
styleElement.textContent = scopedCss;
// Append to document head so it applies globally but selectors are scoped
document.head.appendChild(styleElement);
this.injectedStyleElements.push(styleElement); // Track for removal
console.log(`[${this.instanceId}] Injected style element #${this.injectedStyleElements.length} into <head> (Context: ${context}).`);
} catch (error) {
console.error(`[${this.instanceId}] Error injecting scoped CSS (Context: ${context}):`, error);
}
},
scopeCSS(css) {
// Ensure the container ID is available for scoping selectors
const id = this.containerId;
if (!id) {
console.error(`[${this.instanceId}] Cannot scope CSS: containerId is missing!`);
if (!id) {
console.error(`[${this.instanceId}] Cannot scope CSS: containerId is missing or empty! Returning original CSS.`);
return css;
}
const idSelector = `#${id}`;
// Regex needs careful adjustment for custom elements like <internet_source>
// This simpler prefixing approach is generally safer for dynamic content.
return css.replace(/([^\r\n,{}\s][^{}]*)(?=\s*\{)/g, (match, selector) => {
selector = selector.trim();
if (selector.startsWith('@') || selector.startsWith('%') || /^\d+%$/.test(selector) || selector.includes(idSelector)) {
return selector;
}
const scopedSelector = selector.split(',')
.map(part => `${idSelector} ${part.trim()}`)
.join(', ');
return scopedSelector;
}
const idSelector = `#${id}`; // e.g., #dynamic-ui-instance123
// Improved Regex to handle various selectors including direct children (>), adjacent siblings (+), general siblings (~)
// and pseudo-classes/elements (:hover, ::before, etc.)
// It avoids scoping @-rules (like @keyframes, @media), pseudo-selectors starting with ':', and root selectors like 'html', 'body'.
// It tries to prepend the ID selector correctly.
const scopedCss = css.replace(/(^|[\s,}\]])([^@:%\s>+~][^{>,+~]*?)(\s*\{)/g, (match, prefix, selector, suffix) => {
const trimmedSelector = selector.trim();
// Skip scoping for already scoped selectors, @ rules, percentages, pseudo-classes/elements, html/body
if (trimmedSelector.startsWith(idSelector) ||
trimmedSelector.startsWith('@') ||
trimmedSelector.startsWith(':') || // Handles pseudo-classes/elements
trimmedSelector.startsWith('%') ||
/^\d+%$/.test(trimmedSelector) ||
trimmedSelector === 'html' ||
trimmedSelector === 'body') {
// console.log(`[${this.instanceId}] scopeCSS: Skipping selector "${trimmedSelector}"`);
return match; // Return the original match
}
// Prepend the ID selector carefully
const scopedSelector = trimmedSelector.split(',')
.map(part => {
part = part.trim();
// Prepend ID selector, handling potential combinators at the start
if (part.startsWith('>') || part.startsWith('+') || part.startsWith('~')) {
return `${idSelector}${part}`; // e.g., #container>div
} else {
return `${idSelector} ${part}`; // e.g., #container div
}
})
.join(', ');
// console.log(`[${this.instanceId}] scopeCSS: Scoping "${trimmedSelector}" to "${scopedSelector}"`);
return `${prefix}${scopedSelector}${suffix}`;
});
// console.log(`[${this.instanceId}] scopeCSS: Final scoped CSS:\n${scopedCss}`);
return scopedCss;
},
handleContainerClick(event) {
const target = event.target;
console.log(`[${this.instanceId}] Container clicked. Target: <${target.tagName}>, Classes: ${target.classList}`);
// Log the exact element clicked
console.log(`[${this.instanceId}] Container clicked. Target: <${target.tagName.toLowerCase()}>`, target);
// Use closest() to find the relevant interactive element, even if the click was on a child
const clickablePost = target.closest('img.clickable-post');
if (clickablePost && clickablePost.dataset.endpoint) {
event.preventDefault();
if (clickablePost) {
console.log(`[${this.instanceId}] Click detected on or within 'img.clickable-post'.`);
const endpoint = clickablePost.dataset.endpoint;
const payloadKey = clickablePost.dataset.payloadKey || 'img_path';
const payloadKey = clickablePost.dataset.payloadKey; // Already defaulted during render
const src = clickablePost.getAttribute('src');
if (!endpoint) {
console.error(`[${this.instanceId}] Clickable POST image is missing 'data-endpoint'! Cannot send POST.`);
return;
}
if (!src) {
console.error(`[${this.instanceId}] Clickable POST image is missing 'src'! Cannot determine payload.`);
return;
}
event.preventDefault(); // Prevent any default image behavior if applicable
const payload = { [payloadKey]: src };
console.log(`[${this.instanceId}] Clickable POST triggered. Endpoint: ${endpoint}, Payload:`, payload);
console.log(`[${this.instanceId}] Triggering POST request. Endpoint: ${endpoint}, Payload:`, payload);
axios.post(endpoint, payload)
.then(response => console.log(`[${this.instanceId}] Post to ${endpoint} successful:`, response.data))
.catch(error => console.error(`[${this.instanceId}] Error posting to ${endpoint}:`, error));
return;
.then(response => console.log(`[${this.instanceId}] POST to ${endpoint} successful:`, response.data))
.catch(error => console.error(`[${this.instanceId}] Error posting to ${endpoint}:`, error.response ? error.response.data : error.message));
return; // Handled
}
const openFolderLink = target.closest('a.open-folder');
if (openFolderLink && openFolderLink.dataset.discussionId) {
event.preventDefault();
if (openFolderLink) {
console.log(`[${this.instanceId}] Click detected on or within 'a.open-folder'.`);
event.preventDefault(); // Prevent default link behavior (already set href="#")
const discussionId = openFolderLink.dataset.discussionId;
console.log(`[${this.instanceId}] Open folder link clicked. Discussion ID: ${discussionId}`);
if (!discussionId) {
console.error(`[${this.instanceId}] Open folder link is missing 'data-discussion-id'! Cannot open folder.`);
return;
}
if (!this.clientId) {
console.error(`[${this.instanceId}] ERROR: Client ID not found in Vuex store for open_discussion_folder!`);
alert("Error: Client information is missing.");
console.error(`[${this.instanceId}] CRITICAL: Client ID not found in Vuex store! Cannot send open_discussion_folder request.`);
alert("Error: Client information is missing. Cannot perform this action.");
return;
}
console.log(`[${this.instanceId}] Posting to /open_discussion_folder with client_id: ${this.clientId}, discussion_id: ${discussionId}`);
axios.post('/open_discussion_folder', { client_id: this.clientId, discussion_id: discussionId })
const payload = { client_id: this.clientId, discussion_id: discussionId };
console.log(`[${this.instanceId}] Posting to '/open_discussion_folder'. Payload:`, payload);
axios.post('/open_discussion_folder', payload)
.then(response => console.log(`[${this.instanceId}] Open folder request successful:`, response.data))
.catch(error => console.error(`[${this.instanceId}] Error opening folder:`, error));
return;
.catch(error => console.error(`[${this.instanceId}] Error requesting open folder:`, error.response ? error.response.data : error.message));
return; // Handled
}
// Handle clicks on <internet_source> to open href
const internetSource = target.closest('internet_source');
if (internetSource && internetSource.hasAttribute('href')) {
event.preventDefault();
// Handle clicks on <internet_source> to open its href
const internetSource = target.closest('internet_source[href]'); // Target only those with href
if (internetSource) {
event.preventDefault(); // Prevent any potential default behavior
const href = internetSource.getAttribute('href');
console.log(`[${this.instanceId}] Internet Source clicked. Opening href: ${href}`);
window.open(href, '_blank', 'noopener,noreferrer');
return;
console.log(`[${this.instanceId}] Internet Source clicked. Opening href in new tab: ${href}`);
window.open(href, '_blank', 'noopener,noreferrer'); // Security best practice
return; // Handled
}
// Potentially handle clicks on <local_source> differently if needed (e.g., show details)
// Handle clicks on <local_source> (currently just logs info)
const localSource = target.closest('local_source');
if (localSource) {
event.preventDefault(); // Prevent any default action
const summary = localSource.getAttribute('summary') || 'No summary provided.';
const similarity = localSource.getAttribute('similarity');
console.log(`[${this.instanceId}] Local Source clicked. Summary: ${summary}, Similarity: ${similarity}%`);
// Could potentially trigger a modal or other UI element here
if (localSource) {
event.preventDefault(); // Prevent any potential default behavior
const summary = localSource.getAttribute('summary') || 'No summary available.';
const similarity = localSource.getAttribute('similarity') || 'N/A';
console.log(`[${this.instanceId}] Local Source clicked. Summary: "${summary}", Similarity: ${similarity}%`);
// Optional: Implement further UI interaction like showing details in a modal
// alert(`Local Source:\nSimilarity: ${similarity}%\nSummary: ${summary}`);
return;
return; // Handled
}
console.log(`[${this.instanceId}] Click was not handled by specific handlers.`);
// If the click wasn't on any known interactive element within the dynamic content
console.log(`[${this.instanceId}] Click occurred but was not on a recognized interactive element (clickable-post, open-folder, internet_source[href], local_source). No action taken.`);
},
cleanupDynamicContent(cleanupContext) {
console.log(`[${this.instanceId}] cleanupDynamicContent CALLED (Context: ${cleanupContext})`);
// Provides context on *why* cleanup is happening (e.g., before new render, on unmount)
console.log(`%c[${this.instanceId}] cleanupDynamicContent CALLED (Context: ${cleanupContext})`, 'color: orange;');
// Reset reactive data for all media viewers
// 1. Reset reactive data for all media viewers to ensure they are removed/reset
this.resetMediaState(`cleanup (${cleanupContext})`);
// Remove injected stylesheets
console.log(`[${this.instanceId}] Removing ${this.injectedStyleElements.length} injected style elements.`);
// 2. Remove injected stylesheets from the document <head>
console.log(`[${this.instanceId}] Removing ${this.injectedStyleElements.length} injected style elements (Context: ${cleanupContext}).`);
this.injectedStyleElements.forEach((styleElement, index) => {
// Check if the element still exists and has a parent before trying to remove
if (styleElement?.parentNode) {
styleElement.parentNode.removeChild(styleElement);
// console.log(`[${this.instanceId}] Removed style element #${index + 1}.`);
} else {
console.warn(`[${this.instanceId}] Could not remove style element #${index + 1}.`);
// Warn if removal fails, might indicate issues elsewhere or double-cleanup
console.warn(`[${this.instanceId}] Could not remove style element #${index + 1}. It might have already been removed or was never properly appended.`);
}
});
this.injectedStyleElements = [];
this.injectedStyleElements = []; // Clear the tracking array
// Clear dynamically injected HTML content
// 3. Clear dynamically injected HTML content from its container
const htmlContainer = this.$refs.htmlContentContainer;
if (htmlContainer) {
console.log(`[${this.instanceId}] Clearing innerHTML of htmlContentContainer.`);
console.log(`[${this.instanceId}] Clearing innerHTML of htmlContentContainer (Context: ${cleanupContext}).`);
htmlContainer.innerHTML = '';
} else {
console.warn(`[${this.instanceId}] htmlContentContainer ref not found during cleanup (Context: ${cleanupContext}).`);
// This might happen during unmount if refs are already gone, or if render failed initially
console.warn(`[${this.instanceId}] htmlContentContainer ref not found during cleanup (Context: ${cleanupContext}). Cannot clear innerHTML.`);
}
console.log(`[${this.instanceId}] --- Finished cleanupDynamicContent (Context: ${cleanupContext}) ---`);
console.log(`%c[${this.instanceId}] --- Finished cleanupDynamicContent (Context: ${cleanupContext}) ---`, 'color: orange;');
},
resetMediaState(context) {
// Centralized method to reset all media-related state, with logging
console.log(`[${this.instanceId}] Resetting media state (Context: ${context})`);
let changed = false;
if (this.showAlbumViewer) { this.showAlbumViewer = false; changed = true; }
if (this.albumImages.length > 0) { this.albumImages = []; changed = true; }
if (this.showVideoViewer) { this.showVideoViewer = false; changed = true; }
if (this.albumVideos.length > 0) { this.albumVideos = []; changed = true; }
if (this.showAudioViewer) { this.showAudioViewer = false; changed = true; }
if (this.albumAudios.length > 0) { this.albumAudios = []; changed = true; }
if (!changed) {
console.log(`[${this.instanceId}] Media state was already reset.`);
let stateChanged = false;
if (this.showAlbumViewer) {
this.showAlbumViewer = false;
console.log(`[${this.instanceId}] - Set showAlbumViewer = false`);
stateChanged = true;
}
if (this.albumImages.length > 0) {
this.albumImages = [];
console.log(`[${this.instanceId}] - Cleared albumImages array`);
stateChanged = true;
}
if (this.showVideoViewer) {
this.showVideoViewer = false;
console.log(`[${this.instanceId}] - Set showVideoViewer = false`);
stateChanged = true;
}
if (this.albumVideos.length > 0) {
this.albumVideos = [];
console.log(`[${this.instanceId}] - Cleared albumVideos array`);
stateChanged = true;
}
if (this.showAudioViewer) {
this.showAudioViewer = false;
console.log(`[${this.instanceId}] - Set showAudioViewer = false`);
stateChanged = true;
}
if (this.albumAudios.length > 0) {
this.albumAudios = [];
console.log(`[${this.instanceId}] - Cleared albumAudios array`);
stateChanged = true;
}
if (!stateChanged) {
console.log(`[${this.instanceId}] - Media state was already in its default (reset) state.`);
}
}
}
@ -449,55 +589,74 @@ export default {
border-radius: 4px;
background-color: #f9f9f9;
font-size: 0.9em;
line-height: 1.4; /* Improve readability */
}
:deep(internet_source) {
border-left: 3px solid #007bff; /* Blue accent for internet */
}
/* Style internet sources with href to look clickable */
:deep(internet_source[href]) {
cursor: pointer; /* Indicate clickability */
color: #0056b3; /* Link-like color */
}
:deep(internet_source[href]:hover) {
background-color: #eef; /* Slight hover effect */
text-decoration: underline;
}
:deep(local_source) {
border-left: 3px solid #28a745; /* Green accent for local */
}
/* Optional: Style for icons within sources if provided */
:deep(internet_source[icon]),
:deep(local_source[icon]) {
/* Basic icon styling idea - adjust as needed */
/* This assumes the icon attr contains a URL */
/* You might prefer using ::before with content: attr(icon) and background-image */
padding-left: 28px; /* Make space for icon */
background-repeat: no-repeat;
background-position: 8px center;
background-size: 16px 16px; /* Adjust size */
background-image: var(--icon-url); /* Set via JS or more complex CSS if needed */
/* A simple implementation might just rely on the backend sending an <img> inside */
/* Style local sources to indicate they might be interactable */
:deep(local_source) {
cursor: default; /* Or 'pointer' if you add interaction */
}
:deep(local_source:hover) {
background-color: #efe; /* Slight hover effect for consistency */
}
/* Example if you want to display attributes directly (less common) */
/*
:deep(local_source)::after {
content: ' (Similarity: ' attr(similarity) '%)';
font-size: 0.8em;
color: #666;
margin-left: 5px;
}
*/
/* Style for icons within sources if the backend sends an <img> tag inside */
:deep(internet_source img),
:deep(local_source img) {
width: 16px;
height: 16px;
margin-right: 6px;
vertical-align: middle; /* Align icon nicely with text */
}
/* Display the summary attribute's content */
/* Using ::before allows text content and potential icon coexist */
:deep(internet_source::before),
:deep(local_source::before) {
content: attr(summary); /* Display summary text */
/* Additional styling for summary if needed */
/* Add styles if needed, e.g., font-weight */
/* display: inline-block; Optional: for better control with icons */
/* vertical-align: middle; */
}
/* Add similarity info after local source content */
:deep(local_source)::after {
content: ' (Similarity: ' attr(similarity) '%)';
font-size: 0.85em;
color: #555;
margin-left: 8px;
/* display: inline-block; */
/* vertical-align: middle; */
}
/* Ensure container has some height */
:deep(#dynamic-ui-root) {
min-height: 20px;
/* Ensure the main container has some visual space */
div[id^="dynamic-ui-"] {
min-height: 10px; /* Small minimum height */
padding: 5px; /* Some internal padding */
border: 1px dashed transparent; /* Invisible border for layout debugging if needed */
}
/* Style for the debug output */
pre {
white-space: pre-wrap; /* Allow wrapping */
word-break: break-all; /* Break long strings */
}
</style>

View File

@ -92,8 +92,8 @@ import axios from 'axios';
export default {
props: {
personality: {
type: Object,
required: true
type: [Object, null],
default: null
},
config: {
type: Object,

View File

@ -8,6 +8,9 @@ import './assets/tailwind.css'
const app = createApp(App)
const STARRED_LOCAL_STORAGE_KEY = 'lollms_starred_personalities';
const STARRED_FUNCTIONS_LOCAL_STORAGE_KEY = 'lollms_starred_functions';
const STARRED_DISCUSSIONS_LOCAL_STORAGE_KEY = 'lollms_starred_discussions';
function copyObject(obj) {
if (obj === null || typeof obj !== 'object') {
return obj;
@ -31,43 +34,24 @@ function copyObject(obj) {
return objCopy;
}
function loadStarredFromLocalStorage() {
function loadStarredFromLocalStorage(key, description) {
try {
const stored = localStorage.getItem(STARRED_LOCAL_STORAGE_KEY);
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : [];
} catch (e) {
console.error("Failed to load starred personalities from localStorage:", e);
console.error(`Failed to load starred ${description} from localStorage:`, e);
saveStarredToLocalStorage(key,[], description);
return [];
}
}
function saveStarredToLocalStorage(starredPaths) {
function saveStarredToLocalStorage(key, starredItems, description) {
try {
localStorage.setItem(STARRED_LOCAL_STORAGE_KEY, JSON.stringify(starredPaths));
localStorage.setItem(key, JSON.stringify(starredItems));
} catch (e) {
console.error("Failed to save starred personalities to localStorage:", e);
console.error(`Failed to save starred ${description} to localStorage:`, e);
}
}
const STARRED_FUNCTIONS_LOCAL_STORAGE_KEY = 'lollms_starred_functions';
function loadStarredFunctionsFromLocalStorage() {
try {
const stored = localStorage.getItem(STARRED_FUNCTIONS_LOCAL_STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch (e) {
console.error("Failed to load starred functions from localStorage:", e);
return [];
}
}
function saveStarredFunctionsToLocalStorage(starredPaths) {
try {
localStorage.setItem(STARRED_FUNCTIONS_LOCAL_STORAGE_KEY, JSON.stringify(starredPaths));
} catch (e) {
console.error("Failed to save starred functions to localStorage:", e);
}
}
export const store = createStore({
state () {
@ -97,8 +81,8 @@ export const store = createStore({
'Content-Type': 'application/json'
},
client_id:"",
leftPanelCollapsed: false,
rightPanelCollapsed: true,
leftPanelCollapsed: localStorage.getItem('lollms_webui_left_panel_collapsed') === 'true',
rightPanelCollapsed: localStorage.getItem('lollms_webui_right_panel_collapsed') !== 'false', // Default to true if not set or false
view_mode: localStorage.getItem('lollms_webui_view_mode') || 'compact',
yesNoDialog:null,
universalForm:null,
@ -112,6 +96,7 @@ export const store = createStore({
ready:false,
loading_infos: "",
loading_progress: 0,
progress_value:0,
version : "unknown",
settingsChanged:false,
isConnected: false,
@ -124,8 +109,10 @@ export const store = createStore({
modelsArr:[],
selectedModel:null,
personalities:[],
starredPersonalities: loadStarredFromLocalStorage(), // Initialize from localStorage
starredFunctions: loadStarredFunctionsFromLocalStorage(), // NEW: starred functions state
functions:[], // Added state for functions if managed globally
starredPersonalities: loadStarredFromLocalStorage(STARRED_LOCAL_STORAGE_KEY, 'personalities'),
starredFunctions: loadStarredFromLocalStorage(STARRED_FUNCTIONS_LOCAL_STORAGE_KEY, 'functions'),
starredDiscussions: loadStarredFromLocalStorage(STARRED_DISCUSSIONS_LOCAL_STORAGE_KEY, 'discussions'),
diskUsage:null,
ramUsage:null,
vramUsage:null,
@ -163,12 +150,15 @@ export const store = createStore({
state.view_mode = mode;
localStorage.setItem('lollms_webui_view_mode', mode);
},
setFunctions(state, functions) { state.functions = functions; }, // NEW: if managing functions in store
setStarredFunctions(state, starredPaths) { // NEW
setFunctions(state, functions) { state.functions = functions; },
setStarredFunctions(state, starredPaths) {
state.starredFunctions = starredPaths;
saveStarredFunctionsToLocalStorage(starredPaths);
},
saveStarredToLocalStorage(STARRED_FUNCTIONS_LOCAL_STORAGE_KEY, starredPaths, 'functions');
},
setStarredDiscussions(state, starredIds) {
state.starredDiscussions = starredIds;
saveStarredToLocalStorage(STARRED_DISCUSSIONS_LOCAL_STORAGE_KEY, starredIds, 'discussions');
},
setYesNoDialog(state, dialog) { state.yesNoDialog = dialog; },
setUniversalForm(state, form) { state.universalForm = form; },
setSaveConfiguration(state, saveFn) { state.saveConfiguration = saveFn; },
@ -181,6 +171,7 @@ export const store = createStore({
setIsReady(state, ready) { state.ready = ready; },
setLoadingInfos(state, infos) { state.loading_infos = infos; },
setLoadingProgress(state, progress) { state.loading_progress = progress; },
setProgressValue(state, progress_value) { state.progress_value = progress_value; },
setVersion(state, version) { state.version = version; },
setSettingsChanged(state, changed) { state.settingsChanged = changed; },
setIsConnected(state, isConnected) { state.isConnected = isConnected; },
@ -193,28 +184,47 @@ export const store = createStore({
setModelsArr(state, modelsArr) { state.modelsArr = modelsArr; },
setSelectedModel(state, selectedModel) { state.selectedModel = selectedModel; },
setPersonalities(state, personalities) { state.personalities = personalities; },
setStarredPersonalities(state, starredPaths) { state.starredPersonalities = starredPaths; saveStarredToLocalStorage(starredPaths); },
setStarredPersonalities(state, starredPaths) {
state.starredPersonalities = starredPaths;
saveStarredToLocalStorage(STARRED_LOCAL_STORAGE_KEY, starredPaths, 'personalities');
},
addStarredPersonality(state, personalityPath) {
try{
if (!state.starredPersonalities.includes(personalityPath)) {
state.starredPersonalities.push(personalityPath);
saveStarredToLocalStorage(state.starredPersonalities);
}
}catch{console.log("error")}
if (!state.starredPersonalities.includes(personalityPath)) {
state.starredPersonalities.push(personalityPath);
saveStarredToLocalStorage(STARRED_LOCAL_STORAGE_KEY, state.starredPersonalities, 'personalities');
}
},
removeStarredPersonality(state, personalityPath) {
const index = state.starredPersonalities.indexOf(personalityPath);
if (index > -1) {
state.starredPersonalities.splice(index, 1);
saveStarredToLocalStorage(state.starredPersonalities);
saveStarredToLocalStorage(STARRED_LOCAL_STORAGE_KEY, state.starredPersonalities, 'personalities');
}
},
removeStarredDiscussion(state, personalityPath) {
const index = state.starredPersonalities.indexOf(personalityPath);
addStarredFunction(state, functionPath) {
if (!state.starredFunctions.includes(functionPath)) {
state.starredFunctions.push(functionPath);
saveStarredToLocalStorage(STARRED_FUNCTIONS_LOCAL_STORAGE_KEY, state.starredFunctions, 'functions');
}
},
removeStarredFunction(state, functionPath) {
const index = state.starredFunctions.indexOf(functionPath);
if (index > -1) {
state.starredPersonalities.splice(index, 1);
saveStarredToLocalStorage(state.starredPersonalities);
state.starredFunctions.splice(index, 1);
saveStarredToLocalStorage(STARRED_FUNCTIONS_LOCAL_STORAGE_KEY, state.starredFunctions, 'functions');
}
},
addStarredDiscussion(state, discussionId) {
if (!state.starredDiscussions.includes(discussionId)) {
state.starredDiscussions.push(discussionId);
saveStarredToLocalStorage(STARRED_DISCUSSIONS_LOCAL_STORAGE_KEY, state.starredDiscussions, 'discussions');
}
},
removeStarredDiscussion(state, discussionId) {
const index = state.starredDiscussions.indexOf(discussionId);
if (index > -1) {
state.starredDiscussions.splice(index, 1);
saveStarredToLocalStorage(STARRED_DISCUSSIONS_LOCAL_STORAGE_KEY, state.starredDiscussions, 'discussions');
}
},
setDiskUsage(state, diskUsage) { state.diskUsage = diskUsage; },
@ -226,21 +236,7 @@ export const store = createStore({
setCurrentModel(state, currentModel) { state.currentModel = currentModel; },
setCurrentBinding(state, currentBinding){ state.currentBinding = currentBinding; },
setDatabases(state, databases) { state.databases = databases; },
addStarredFunction(state, functionPath) { // NEW
if (!state.starredFunctions.includes(functionPath)) {
state.starredFunctions.push(functionPath);
saveStarredFunctionsToLocalStorage(state.starredFunctions);
}
},
removeStarredFunction(state, functionPath) { // NEW
const index = state.starredFunctions.indexOf(functionPath);
if (index > -1) {
state.starredFunctions.splice(index, 1);
saveStarredFunctionsToLocalStorage(state.starredFunctions);
}
},
updateFunction(state, newFunctionData){ // NEW: If managing functions in store
updateFunction(state, newFunctionData){
const index = state.functions.findIndex(f => (f.id || f.full_path) === (newFunctionData.id || newFunctionData.full_path));
if (index !== -1) {
const updatedFunc = { ...state.functions[index], ...newFunctionData };
@ -248,7 +244,6 @@ export const store = createStore({
} else {
console.warn("Couldn't update function (not found):", newFunctionData.full_path);
}
// Maybe update mounted list if needed? For functions, this might not be necessary unless they are tied to config
},
updatePersonality(state, newPersonality){
const index = state.personalities.findIndex(p => (p.id || p.full_path) === (newPersonality.id || newPersonality.full_path));
@ -303,6 +298,7 @@ export const store = createStore({
getIsReady: state => state.ready,
getLoadingInfos: state => state.loading_infos,
getLoadingProgress: state => state.loading_progress,
getProgressValue: state => state.progress_value,
getVersion: state => state.version,
getSettingsChanged: state => state.settingsChanged,
getIsConnected: state => state.isConnected,
@ -325,8 +321,9 @@ export const store = createStore({
getCurrentModel: state => state.currentModel,
getCurrentBinding: state => state.currentBinding,
getDatabases: state => state.databases,
getFunctions: state => state.functions, // NEW: if managing functions in store
getStarredFunctions: state => state.starredFunctions, // NEW
getFunctions: state => state.functions,
getStarredFunctions: state => state.starredFunctions,
getStarredDiscussions: state => state.starredDiscussions,
},
actions: {
async fetchIsRtOn({ commit }) {
@ -357,8 +354,7 @@ export const store = createStore({
commit('setVersion', 'unknown (error)');
}
},
// NEW: Action to toggle function star status
toggleStarFunction({ commit, state, dispatch }, func) {
toggleStarFunction({ commit, state }, func) {
if (!func || !func.full_path) {
console.warn("Attempted to toggle star on invalid function:", func);
return;
@ -371,10 +367,37 @@ export const store = createStore({
} else {
commit('addStarredFunction', functionPath);
}
// Update the isStarred status in the main functions list if managed by store
// If function list is local to component, component needs to handle its own state update
// dispatch('updateFunctionStarredStatus', { functionPath, isStarred: !isCurrentlyStarred }); // Uncomment if functions list is in store
},
toggleStarPersonality({ commit, state, dispatch }, personality) {
if (!personality || !personality.full_path) {
console.warn("Attempted to toggle star on invalid personality:", personality);
return;
}
const personalityPath = personality.full_path;
const isCurrentlyStarred = state.starredPersonalities.includes(personalityPath);
if (isCurrentlyStarred) {
commit('removeStarredPersonality', personalityPath);
} else {
commit('addStarredPersonality', personalityPath);
}
dispatch('updatePersonalityStarredStatus', { personalityPath, isStarred: !isCurrentlyStarred });
},
toggleStarDiscussion({ commit, state }, discussion) {
if (!discussion || typeof discussion.id === 'undefined') {
console.warn("Attempted to toggle star on invalid discussion:", discussion);
return;
}
const discussionId = discussion.id;
const isCurrentlyStarred = state.starredDiscussions.includes(discussionId);
if (isCurrentlyStarred) {
commit('removeStarredDiscussion', discussionId);
} else {
commit('addStarredDiscussion', discussionId);
}
// No global discussion list update action needed unless discussions are managed in Vuex state
},
async refreshConfig({ commit, state }) {
console.log("Fetching configuration");
try {
@ -383,7 +406,6 @@ export const store = createStore({
if (!configData) {
throw new Error("Received null or undefined config file");
}
// Make a deep copy to avoid modifying the original response object if needed elsewhere
let configFile = copyObject(configData);
if (!configFile.personalities || configFile.personalities.length === 0) {
@ -412,7 +434,7 @@ export const store = createStore({
commit('setSettingsChanged', false);
} catch (error) {
console.error('Error during refreshConfig:', error);
commit('setSettingsChanged', false); // Assume config is stale, revert changes
commit('setSettingsChanged', false);
}
},
@ -483,7 +505,7 @@ export const store = createStore({
let personalities = [];
const catdictionary = await api_get_req("get_all_personalities");
const mountedSet = new Set(state.config?.personalities || []);
const starredSet = new Set(state.starredPersonalities || []); // Use starred from state
const starredSet = new Set(state.starredPersonalities || []);
if (!catdictionary) {
commit('setPersonalities', []);
@ -502,7 +524,7 @@ export const store = createStore({
const full_path = `${catkey}/${item.folder}`;
const langPaths = Array.isArray(item.languages) ? item.languages.map(lang => `${full_path}:${lang}`) : [];
const isMounted = mountedSet.has(full_path) || langPaths.some(lp => mountedSet.has(lp));
const isStarred = starredSet.has(full_path); // Check against store state
const isStarred = starredSet.has(full_path);
return {
...item,
@ -510,7 +532,7 @@ export const store = createStore({
full_path: full_path,
id: item.id || full_path,
isMounted: isMounted,
isStarred: isStarred, // Set initial starred status
isStarred: isStarred,
isProcessing: false,
};
}).filter(item => item !== null);
@ -549,7 +571,7 @@ export const store = createStore({
let pers = copyObject(personalitiesMap.get(basePath));
pers.language = typeof full_path_item === 'string' && full_path_item.includes(':') ? full_path_item.split(':')[1] : '';
pers.isMounted = true;
pers.isStarred = starredSet.has(basePath); // Ensure mounted also gets starred status
pers.isStarred = starredSet.has(basePath);
mountedPersArr.push(pers);
} else {
indicesToRemove.push(i);
@ -638,9 +660,6 @@ export const store = createStore({
async refreshModels({ commit, state }) {
try {
let modelsArr = await api_get_req("list_models");
console.log(`modelsArr: ${modelsArr}`)
console.log(`state.modelsZoo:`)
console.log(state.modelsZoo)
if (!Array.isArray(modelsArr)) modelsArr = [];
commit('setModelsArr', modelsArr);
@ -651,8 +670,6 @@ export const store = createStore({
const modelsZoo = state.modelsZoo || [];
const modelsArrSet = new Set(modelsArr);
modelsZoo.forEach(item => {
console.log("modelsArrSet")
console.log(modelsArrSet)
if (item && item.name) item.isInstalled = modelsArrSet.has(item.name);
});
commit('setModelsZoo', [...modelsZoo]);
@ -736,39 +753,6 @@ export const store = createStore({
}
},
toggleStarPersonality({ commit, state, dispatch }, personality) {
if (!personality || !personality.full_path) {
console.warn("Attempted to toggle star on invalid personality:", personality);
return;
}
const personalityPath = personality.full_path;
const isCurrentlyStarred = state.starredPersonalities.includes(personalityPath);
if (isCurrentlyStarred) {
commit('removeStarredPersonality', personalityPath);
} else {
commit('addStarredPersonality', personalityPath);
}
// Update the isStarred status in the main personalities list for immediate UI feedback
dispatch('updatePersonalityStarredStatus', { personalityPath, isStarred: !isCurrentlyStarred });
},
toggleStarDiscussion({ commit, state, dispatch }, discussion) {
if (!discussion) {
console.warn("Attempted to toggle star on invalid discussion:", discussion);
return;
}
const discussion_id = discussion.id;
const isCurrentlyStarred = state.starredDiscussions.includes(discussion_id);
if (isCurrentlyStarred) {
commit('removeStarredDiscussion', discussion_id);
} else {
commit('addStarredDiscussion', discussion_id);
}
// Update the isStarred status in the main personalities list for immediate UI feedback
dispatch('updateDiscussionStarredStatus', { discussion_id, isStarred: !isCurrentlyStarred });
},
updatePersonalityStarredStatus({ commit, state }, { personalityPath, isStarred }) {
const personality = state.personalities.find(p => p.full_path === personalityPath);
if (personality) {
@ -777,13 +761,16 @@ export const store = createStore({
console.warn("Could not find personality in main list to update starred status:", personalityPath);
}
},
async applyConfiguration({ commit, state }) {
async applyConfiguration({ commit, state, dispatch }) {
try {
const res = await axios.post('/apply_settings', { client_id:state.client_id, config: state.config }, { headers: state.posts_headers });
if (res.data.status) {
state.toast.showToast("Settings applied. Refreshing...", 4, true);
await this.refreshConfigInView(); // Refreshes store, resets local state
// Assuming refreshConfigInView exists in the component context where this action is called
// If not, need to call refreshConfig directly or handle UI refresh differently
// await this.refreshConfigInView(); // This line might cause issues if 'this' is not the component instance
await dispatch('refreshConfig'); // Refresh store config state
} else {
state.toast.showToast(`Apply failed: ${res.data.error || 'Error'}`, 4, false);
}
@ -791,15 +778,15 @@ export const store = createStore({
state.toast.showToast(`Error applying settings: ${error.message || error}`, 4, false);
}
},
async saveConfiguration({ commit, state }) {
this.isLoading = true;
this.loading_text = "Saving configuration...";
async saveConfiguration({ state }) {
// Typically isLoading and loading_text are component-local state, not Vuex state
// We'll assume the component calling this action handles its own loading state
try {
const res = await axios.post('/save_settings', { client_id:state.client_id }, { headers: state.posts_headers });
if (res.data.status)state.toast.showToast("Settings saved successfully.", 4, true);
if (res.data.status) state.toast.showToast("Settings saved successfully.", 4, true);
else state.messageBox.showMessage(`Error saving settings: ${res.data.error || 'Error'}`);
} catch (error) {state.messageBox.showMessage(`Error saving settings: ${error.message}`);
} finally { this.isLoading = false; }
}
},
}
})

View File

@ -10,7 +10,7 @@
<div v-if="isReady" class="flex flex-row h-screen w-screen overflow-hidden">
<LeftPanel
:show-left-panel="showLeftPanel"
:discussions-list="discussionsList"
:discussions-list="discussionsListWithStarred"
:current-discussion="currentDiscussion"
:toolbar-loading="isGenerating"
:formatted-database-name="formatted_database_name"
@ -43,7 +43,7 @@
@export-discussions-as-markdown="exportDiscussionsAsMarkdown"
@show-database-selector="showDatabaseSelector"
@import-discussion-file="importDiscussionFile"
@toggle-star-discussion="toggleStarDiscussion"
@toggle-star-discussion="handleToggleStarDiscussion"
/>
<ChatArea
@ -129,7 +129,7 @@ export default defineComponent({
},
data() {
return {
discussionsList: [],
discussionsList: [], // Base list fetched from API
currentDiscussion: {},
discussionArr: [],
personalityAvatars: [],
@ -188,9 +188,14 @@ export default defineComponent({
'ready', 'loading_infos', 'loading_progress', 'version', 'config',
'databases', 'isConnected', 'isGenerating', 'client_id', 'leftPanelCollapsed',
'rightPanelCollapsed', 'theme_vars', 'selectedPersonality',
'currentPersonConfig', 'personalities', 'personalities_ready'
'currentPersonConfig', 'personalities', 'personalities_ready',
'starredDiscussions' // Make sure this is in mapState or mapGetters
]),
...mapGetters([
'getIsReady', 'getVersion', 'getConfig', 'getClientId', 'getDatabases',
'getIsConnected', 'getIsGenerating', 'getLeftPanelCollapsed',
'getRightPanelCollapsed', 'getStarredDiscussions' // Use getter if defined
]),
...mapGetters(['getIsReady', 'getVersion', 'getConfig', 'getClientId', 'getDatabases', 'getIsConnected', 'getIsGenerating', 'getLeftPanelCollapsed', 'getRightPanelCollapsed']),
isReady() {
return this.getIsReady;
},
@ -208,6 +213,14 @@ export default defineComponent({
const db_name = this.config?.discussion_db_name || "default";
return db_name.replace(/_/g, " ");
},
// Computed property to add 'isStarred' to the discussions list for the LeftPanel prop
discussionsListWithStarred() {
const starredSet = new Set(this.getStarredDiscussions); // Use getter or direct state
return this.discussionsList.map(discussion => ({
...discussion,
isStarred: starredSet.has(discussion.id)
}));
}
},
methods: {
...mapActions(['refreshConfig', 'refreshDatabase', 'refreshBindings', 'refreshPersonalitiesZoo', 'refreshMountedPersonalities', 'refreshModelsZoo', 'refreshModels', 'fetchLanguages', 'fetchLanguage', 'fetchIsRtOn', 'toggleStarPersonality', 'toggleStarDiscussion', 'applyConfiguration', 'saveConfiguration', 'refreshModelStatus']),
@ -264,10 +277,11 @@ export default defineComponent({
this.loading = true;
const res = await axios.get('/list_discussions');
if (res && Array.isArray(res.data)) {
// Store the raw list, the computed prop will add 'isStarred'
this.discussionsList = res.data.map(item => ({
id: item.id,
title: item.title,
created_at: item.created_at, // Keep creation time for sorting/grouping
created_at: item.created_at,
loading: false,
})).sort((a, b) => b.id - a.id);
} else { this.discussionsList = []; }
@ -280,9 +294,10 @@ export default defineComponent({
},
loadLastUsedDiscussion() {
const id = localStorage.getItem('selected_discussion');
if (id) {
const discussionItem = this.discussionsList.find(d => String(d.id) === id);
const id_str = localStorage.getItem('selected_discussion');
if (id_str) {
const id = parseInt(id_str, 10); // Ensure ID is a number for comparison
const discussionItem = this.discussionsList.find(d => d.id === id);
if (discussionItem) { this.selectDiscussion(discussionItem); }
else { localStorage.removeItem('selected_discussion'); this.currentDiscussion = {}; this.discussionArr = []; }
} else { this.currentDiscussion = {}; this.discussionArr = []; }
@ -291,7 +306,8 @@ export default defineComponent({
selectDiscussion(item) {
if (this.isGenerating) { this.$store.state.toast.showToast("Please wait for generation to finish or stop.", 4, false); return; }
if (item && this.currentDiscussion?.id !== item.id) {
this.currentDiscussion = { ...item }; this.setPageTitle(item);
this.currentDiscussion = { ...item }; // Copy item, don't need isStarred here
this.setPageTitle(item);
localStorage.setItem('selected_discussion', item.id); this.load_discussion(item.id);
} else if (!item) {
this.currentDiscussion = {}; this.discussionArr = []; this.setPageTitle(); localStorage.removeItem('selected_discussion');
@ -332,6 +348,10 @@ export default defineComponent({
await axios.post('/delete_discussion', { client_id: this.client_id, id: id });
this.$store.state.toast.showToast(`Discussion ${id} deleted.`, 4, true);
this.discussionsList = this.discussionsList.filter(d => d.id !== id);
// Remove from starred if it was starred
if (this.getStarredDiscussions.includes(id)) {
this.$store.commit('removeStarredDiscussion', id);
}
if (this.currentDiscussion?.id === id) { this.selectDiscussion(null); }
} catch (error) {
console.error("Error deleting discussion:", error); this.$store.state.toast.showToast(`Error deleting discussion ${id}: ${error.message}`, 4, false); this.setDiscussionLoading(id, false);
@ -349,10 +369,16 @@ export default defineComponent({
this.$store.state.toast.showToast(`Deleting ${numToDelete} discussions...`, 5, true);
let deletedCount = 0; let failedCount = 0;
idsToDelete.forEach(id => this.setDiscussionLoading(id, true));
const currentStarred = this.getStarredDiscussions; // Get starred before loop
for (const id of idsToDelete) {
try {
await axios.post('/delete_discussion', { client_id: this.client_id, id: id });
deletedCount++; this.discussionsList = this.discussionsList.filter(d => d.id !== id);
deletedCount++;
this.discussionsList = this.discussionsList.filter(d => d.id !== id);
// Remove from starred if it was starred
if (currentStarred.includes(id)) {
this.$store.commit('removeStarredDiscussion', id);
}
if (this.currentDiscussion?.id === id) { this.selectDiscussion(null); }
} catch (error) { console.error(`Error deleting discussion ${id}:`, error); failedCount++; this.setDiscussionLoading(id, false); }
}
@ -397,9 +423,15 @@ export default defineComponent({
catch (error) { console.error("Error opening folder:", error); this.$store.state.toast.showToast(`Could not open folder: ${error.message}`, 4, false); }
},
toggleStarDiscussion(item) {
this.toggleStarDiscussion(item.id);
this.$nextTick(() => { this.$forceUpdate(); }); // May be needed for LeftPanel list re-render based on getter
// Renamed handler to avoid conflict with mapActions name
handleToggleStarDiscussion(item) {
if (item && item.id !== undefined) {
// Dispatch the action from the store
this.toggleStarDiscussion(item); // Pass the whole item as the action expects {id: ...}
} else {
console.warn("Attempted to toggle star on invalid discussion item:", item);
}
// No need for $forceUpdate, Vuex reactivity + computed prop should handle it
},
load_discussion(id, callback) {
@ -493,7 +525,7 @@ export default defineComponent({
if (this.isGenerating) { this.$store.state.toast.showToast("Please wait for the current response.", 4, false); return; }
this.$store.commit('setIsGenerating', true); this.setDiscussionLoading(this.currentDiscussion.id, true);
const emitEvent = type === 'internet' ? 'generate_msg_with_internet' : 'generate_msg';
const emitEvent = type === 'internet' ? 'generate_msg_with_internet' : 'generate_msg';
socket.emit(emitEvent, { prompt: message });
this.scrollToBottomMessages();
},
@ -557,8 +589,13 @@ export default defineComponent({
createEmptyAIMessage() { if (!this.currentDiscussion?.id) return; socket.emit('create_empty_message', { type: 1 }); this.$store.state.toast.showToast("Creating empty AI message...", 2, true); },
setDiscussionLoading(id, isLoading) {
const index = this.discussionsList.findIndex(d => d.id === id);
if(id!==undefined){
const index = this.discussionsList.findIndex(d => d.id === id);
if (index !== -1) { this.discussionsList[index].loading = isLoading; }
}
else{
console.error("discussion id is undefined")
}
},
setPageTitle(item = null) {
const baseTitle = 'L🌟LLMS WebUI'; let discussionTitle = "Welcome";
@ -570,7 +607,7 @@ export default defineComponent({
},
scrollToDiscussionElement(id) {
if (!id) return;
nextTick(() => { const el = document.getElementById(`dis-${id}`); const container = document.getElementById('leftPanelScroll'); if (el && container) { container.scrollTo({ top: el.offsetTop, behavior: 'smooth' }); } });
nextTick(() => { const el = document.getElementById(id); const container = document.getElementById('leftPanelScroll'); if (el && container) { container.scrollTo({ top: el.offsetTop, behavior: 'smooth' }); } });
},
copyToClipBoard(messageEntry) {
let content = messageEntry.message.content || ""; let result = content;

View File

@ -1,6 +1,7 @@
<template>
<transition name="slide-right">
<div v-if="showLeftPanel" class="relative flex flex-col no-scrollbar shadow-lg w-[16rem] panels-color scrollbar h-full">
<!-- Header -->
<RouterLink :to="{ name: 'discussions' }" class="flex items-center space-x-2 p-2 border-b border-blue-200 dark:border-blue-700 hover:bg-blue-50 dark:hover:bg-blue-800 transition duration-150 ease-in-out">
<div class="logo-container w-12 h-12 flex-shrink-0">
<img class="w-full h-full rounded-full object-cover logo-image border-2 border-blue-300 dark:border-blue-600 shadow-sm"
@ -21,6 +22,7 @@
</div>
</RouterLink>
<!-- Toolbar -->
<Toolbar
:loading="toolbarLoading"
:is-checkbox="isCheckbox"
@ -45,6 +47,7 @@
@show-personality-list="$emit('show-personality-list')"
/>
<!-- Search & Sort -->
<div class="w-full max-w-md mx-auto p-2 border-b border-blue-100 dark:border-blue-800">
<form @submit.prevent class="relative">
<div class="flex items-center space-x-1">
@ -72,11 +75,13 @@
</form>
</div>
<!-- Bulk Actions (Checkbox Mode) -->
<div v-if="isCheckbox" class="w-full p-2 bg-blue-100 dark:bg-blue-900 border-b border-blue-200 dark:border-blue-700">
<div class="flex flex-col space-y-1">
<p v-if="selectedDiscussions.length > 0" class="text-sm text-blue-700 dark:text-blue-200">Selected: {{ selectedDiscussions.length }}</p>
<div v-if="selectedDiscussions.length > 0" class="flex space-x-1 items-center">
<button v-if="!showConfirmation" class="svg-button text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200" title="Remove selected" type="button" @click.stop="showConfirmation = true">
<div class="flex space-x-1 items-center">
<!-- Delete -->
<button v-if="!showConfirmation && selectedDiscussions.length > 0" class="svg-button text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200" title="Remove selected" type="button" @click.stop="showConfirmation = true">
<i data-feather="trash" class="w-5 h-5"></i>
</button>
<div v-if="showConfirmation" class="flex space-x-1 items-center">
@ -87,24 +92,29 @@
<i data-feather="x" class="w-5 h-5"></i>
</button>
</div>
</div>
<div class="flex space-x-1 items-center">
<button class="svg-button text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200 rotate-90" title="Export selected to a json file" type="button" @click.stop="$emit('export-discussions-as-json', selectedDiscussions)">
<!-- Export -->
<button v-if="selectedDiscussions.length > 0" class="svg-button text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200 rotate-90" title="Export selected to a json file" type="button" @click.stop="$emit('export-discussions-as-json', selectedDiscussions)">
<i data-feather="codepen" class="w-5 h-5"></i>
</button>
<button class="svg-button text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200 rotate-90" title="Export selected to a folder" type="button" @click.stop="$emit('export-discussions-to-folder', selectedDiscussions)">
<button v-if="selectedDiscussions.length > 0" class="svg-button text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200 rotate-90" title="Export selected to a folder" type="button" @click.stop="$emit('export-discussions-to-folder', selectedDiscussions)">
<i data-feather="folder" class="w-5 h-5"></i>
</button>
<button class="svg-button text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200" title="Export selected to a markdown file" type="button" @click.stop="$emit('export-discussions-as-markdown', selectedDiscussions)">
<button v-if="selectedDiscussions.length > 0" class="svg-button text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200" title="Export selected to a markdown file" type="button" @click.stop="$emit('export-discussions-as-markdown', selectedDiscussions)">
<i data-feather="bookmark" class="w-5 h-5"></i>
</button>
<button class="svg-button text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200" title="Select/Deselect All" type="button" @click.stop="selectAllDiscussions">
<!-- Select All / Deselect All -->
<button class="svg-button text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200" :title="isAllSelected ? 'Deselect All Visible' : 'Select All Visible'" type="button" @click.stop="selectAllDiscussions">
<i :data-feather="isAllSelected ? 'minus-square' : 'check-square'" class="w-5 h-5"></i>
</button>
<!-- Star/Unstar Selected -->
<button v-if="selectedDiscussions.length > 0" class="svg-button text-yellow-500 hover:text-yellow-700 dark:text-yellow-400 dark:hover:text-yellow-200" :title="selectedDiscussions.some(d=>d.isStarred) ? 'Unstar Selected':'Star Selected'" type="button" @click.stop="toggleStarSelectedDiscussions">
<i :data-feather="selectedDiscussions.some(d=>d.isStarred) ? 'star' : 'star'" :fill="selectedDiscussions.some(d=>d.isStarred) ? 'currentColor' : 'none'" class="w-5 h-5"></i>
</button>
</div>
</div>
</div>
<!-- Discussions List -->
<div id="leftPanelScroll" class="flex flex-col flex-grow overflow-y-auto overflow-x-hidden scrollbar"
@dragover.prevent="isDragOverDiscussion = true" @dragleave="isDragOverDiscussion = false" @drop.prevent="handleDrop">
<div class="relative flex flex-col flex-grow mb-10 z-0 w-full">
@ -112,16 +122,17 @@
<div id="dis-list" :class="(filterInProgress || toolbarLoading) ? 'opacity-20 pointer-events-none' : ''" class="flex flex-col flex-grow w-full pb-10">
<TransitionGroup name="discussionsList">
<template v-for="item in groupedDiscussions" :key="item.key">
<!-- Group Header -->
<div v-if="item.type === 'header'"
class="sticky top-0 z-10 px-2 py-1 bg-gray-100 dark:bg-gray-800 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider shadow-sm flex items-center justify-between cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700"
@click="toggleSection(item.key)">
<span>{{ item.label }}</span>
<i :data-feather="item.collapsed ? 'chevron-right' : 'chevron-down'" class="w-4 h-4"></i>
</div>
<!-- Inside LeftPanel.vue's TransitionGroup -->
<!-- Discussion Item -->
<Discussion
v-if="item.type === 'discussion'"
:id="`dis-${item.data.id}`"
v-if="item.type === 'discussion' && !item.collapsed"
:id="item.data.id"
:title="item.data.title"
:selected="currentDiscussion && currentDiscussion.id === item.data.id"
:loading="item.data.loading"
@ -151,6 +162,7 @@
</div>
</div>
<!-- Footer -->
<div class="flex flex-row items-center justify-center border-t border-blue-200 dark:border-blue-700 p-1">
<div class="chat-bar text-center flex items-center justify-center w-full cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-700 rounded transition duration-150 ease-in-out" @click="$emit('show-database-selector')">
<button class="svg-button p-1">
@ -168,7 +180,7 @@
<script>
import feather from 'feather-icons';
import { nextTick } from 'vue';
import { mapState, mapGetters } from 'vuex';
import { mapState } from 'vuex'; // Removed mapGetters
import { RouterLink } from 'vue-router';
import storeLogo from '@/assets/logo.png';
import Discussion from './Discussion.vue';
@ -194,7 +206,7 @@ export default {
components: { Discussion, RouterLink, Toolbar },
props: {
showLeftPanel: Boolean,
discussionsList: Array,
discussionsList: Array, // Expects array with { id, title, created_at, loading, isStarred }
currentDiscussion: Object,
toolbarLoading: Boolean,
formattedDatabaseName: String,
@ -216,15 +228,14 @@ export default {
showConfirmation: false,
isDragOverDiscussion: false,
searchTimeout: null,
localDiscussionsState: [],
localDiscussionsState: [], // Holds { id, checkBoxValue }
sortBy: 'date',
sortOrder: 'desc',
collapsedSections: { starred: false, today: false, yesterday: true, older: true },
collapsedSections: { starred: false, today: false, yesterday: true, older: true }, // Keep track of collapsed state for each group
};
},
computed: {
...mapState(['config', 'theme_vars']),
...mapGetters(['getStarredDiscussionsSet']),
logoSrc() {
return this.config?.app_custom_logo ? `/user_infos/${this.config.app_custom_logo}` : storeLogo;
@ -238,9 +249,6 @@ export default {
appSlogan() {
return this.config?.app_custom_slogan || 'One tool to rule them all';
},
starredSet() {
return this.getStarredDiscussionsSet || new Set();
},
sortIcon() {
return this.sortOrder === 'asc' ? 'arrow-up' : 'arrow-down';
},
@ -248,42 +256,43 @@ export default {
const labels = { date: 'Date', title: 'Title' };
return labels[this.sortBy] || 'Date';
},
// Enhance the prop list with local checkbox state and parsed date
enhancedDiscussions() {
return (this.discussionsList || []).map(disc => {
const localState = this.localDiscussionsState.find(ld => ld.id === disc.id);
const creationDate = disc.created_at ? new Date(disc.created_at) : new Date(0); // Use created_at
const creationDate = disc.created_at ? new Date(disc.created_at) : new Date(0);
return {
...disc,
...disc, // Includes id, title, created_at, loading, isStarred (from prop)
checkBoxValue: localState ? localState.checkBoxValue : false,
isStarred: this.starredSet.has(String(disc.id)),
creationDate: creationDate,
creationDate: creationDate, // Parsed date for sorting/grouping
};
});
},
filteredDiscussions() {
// This is primarily used for 'Select All' logic now, filtering happens inside groupedDiscussions
// Primarily used for 'Select All' logic and determining if all *visible* items are selected
filteredDiscussions() {
if (!this.filterTitle.trim()) {
return this.enhancedDiscussions;
}
const query = this.filterTitle.toLowerCase();
return this.enhancedDiscussions.filter(item => item.title && item.title.toLowerCase().includes(query));
},
// Groups discussions by Starred, Today, Yesterday, Older, applies filtering and sorting
groupedDiscussions() {
const starred = [];
const today = [];
const yesterday = [];
const older = [];
// Filter first based on search query
// Filter first based on search query using the enhanced list
const filtered = this.enhancedDiscussions.filter(item => {
if (!this.filterTitle.trim()) return true;
const query = this.filterTitle.toLowerCase();
return item.title && item.title.toLowerCase().includes(query);
});
// Separate starred from unstarred
// Separate into groups (Starred first, then by date)
filtered.forEach(disc => {
if (disc.isStarred) {
if (disc.isStarred) { // Use isStarred from the enhanced list (which came from props)
starred.push(disc);
} else {
const creationDate = disc.creationDate;
@ -305,7 +314,6 @@ export default {
} else if (this.sortBy === 'title') {
comparison = (a.title || '').localeCompare(b.title || ''); // Ascending by title default
}
// Apply sort order direction
const orderMultiplier = (this.sortBy === 'date' && this.sortOrder === 'asc') || (this.sortBy === 'title' && this.sortOrder === 'desc') ? -1 : 1;
return comparison * orderMultiplier;
};
@ -316,51 +324,32 @@ export default {
older.sort(sortFn);
const groups = [];
// Add Starred section
if (starred.length > 0) {
groups.push({ type: 'header', label: 'Starred', key: 'starred', collapsed: this.collapsedSections.starred });
if (!this.collapsedSections.starred) {
starred.forEach(item => groups.push({ type: 'discussion', data: item, key: `disc-${item.id}` }));
}
const addGroup = (label, key, items) => {
if (items.length > 0) {
const isCollapsed = this.collapsedSections[key] || false;
groups.push({ type: 'header', label: label, key: key, collapsed: isCollapsed });
items.forEach(item => groups.push({ type: 'discussion', data: item, key: `disc-${item.id}`, collapsed: isCollapsed }));
}
}
// Add Today section
if (today.length > 0) {
groups.push({ type: 'header', label: 'Today', key: 'today', collapsed: this.collapsedSections.today });
if (!this.collapsedSections.today) {
today.forEach(item => groups.push({ type: 'discussion', data: item, key: `disc-${item.id}` }));
}
}
addGroup('Starred', 'starred', starred);
addGroup('Today', 'today', today);
addGroup('Yesterday', 'yesterday', yesterday);
addGroup('Older', 'older', older);
// Add Yesterday section
if (yesterday.length > 0) {
groups.push({ type: 'header', label: 'Yesterday', key: 'yesterday', collapsed: this.collapsedSections.yesterday });
if (!this.collapsedSections.yesterday) {
yesterday.forEach(item => groups.push({ type: 'discussion', data: item, key: `disc-${item.id}` }));
}
}
// Add Older section
if (older.length > 0) {
groups.push({ type: 'header', label: 'Older', key: 'older', collapsed: this.collapsedSections.older });
if (!this.collapsedSections.older) {
older.forEach(item => groups.push({ type: 'discussion', data: item, key: `disc-${item.id}` }));
}
}
return groups;
},
// Determines which discussions are currently selected based on local checkbox state
selectedDiscussions() {
// Selected items are based on the local checkbox state, across all filtered items
return this.filteredDiscussions.filter(item => {
// We need to filter enhancedDiscussions based on local checkbox state
return this.enhancedDiscussions.filter(item => {
const localState = this.localDiscussionsState.find(ld => ld.id === item.id);
return localState && localState.checkBoxValue;
});
},
// Determines if all *visible* (filtered) discussions are currently selected
isAllSelected() {
// Check if all items matching the current filter are selected
const targetList = this.filteredDiscussions;
const targetList = this.filteredDiscussions; // Discussions matching the current filter
if (targetList.length === 0) return false;
const selectedIds = new Set(this.selectedDiscussions.map(d => d.id));
return targetList.every(item => selectedIds.has(item.id));
@ -370,7 +359,8 @@ export default {
toggleSection(key) {
if (key in this.collapsedSections) {
this.collapsedSections[key] = !this.collapsedSections[key];
// Recompute groupedDiscussions implicitly updates the view
// Force reactivity update if needed, though computed should handle it
// this.$forceUpdate(); // Avoid if possible
this.$nextTick(() => feather.replace());
}
},
@ -379,7 +369,6 @@ export default {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.filterInProgress = false;
// Vue reactivity handles the update, feather needs refresh
this.$nextTick(() => feather.replace());
}, 300);
},
@ -397,26 +386,28 @@ export default {
},
deleteDiscussion(item) {
this.localDiscussionsState = this.localDiscussionsState.filter(d => d.id !== item.id);
this.$emit('delete-discussion', item.id);
this.$emit('delete-discussion', item.id); // Emit ID to parent
},
checkUncheckDiscussion({ id, checked }) {
const index = this.localDiscussionsState.findIndex(d => d.id === id);
if (index !== -1) {
this.localDiscussionsState[index].checkBoxValue = checked;
} else {
// Ensure item exists in discussionsList before adding to local state if needed
// Check if the item actually exists in the main list before adding local state
if (this.discussionsList.some(d => d.id === id)) {
this.localDiscussionsState.push({ id: id, checkBoxValue: checked });
} else {
console.warn("Tried to check/uncheck an item not present in discussionsList:", id);
}
}
},
selectAllDiscussions() {
const targetState = !this.isAllSelected;
const filteredIds = new Set(this.filteredDiscussions.map(d => d.id)); // Use filtered list
const filteredIds = new Set(this.filteredDiscussions.map(d => d.id)); // Get IDs of currently visible/filtered items
// Update local state for all items matching the filter
// Update local state only for visible items
this.enhancedDiscussions.forEach(item => {
if (filteredIds.has(item.id)) {
if (filteredIds.has(item.id)) { // Apply only to filtered items
const index = this.localDiscussionsState.findIndex(d => d.id === item.id);
if (index !== -1) {
this.localDiscussionsState[index].checkBoxValue = targetState;
@ -430,8 +421,9 @@ export default {
},
deleteSelectedDiscussions() {
const idsToDelete = this.selectedDiscussions.map(d => d.id);
// Clear local checkbox state for deleted items
this.localDiscussionsState = this.localDiscussionsState.filter(ld => !idsToDelete.includes(ld.id));
this.$emit('delete-selected', idsToDelete);
this.$emit('delete-selected', idsToDelete); // Emit array of IDs
this.showConfirmation = false;
this.isCheckbox = false; // Exit checkbox mode after deletion
},
@ -441,33 +433,33 @@ export default {
if (files.length === 1 && files[0].type === 'application/json') {
this.$emit('import-discussion-file', files[0]);
} else {
// Consider using a more integrated notification system if available
alert("Please drop a single JSON file to import.");
// this.$store.dispatch('showToast', { message: "Please drop a single JSON file.", type: 'warning' });
this.$store.state.toast.showToast("Please drop a single JSON file to import.", 4, false);
}
},
toggleStarDiscussion(item) {
this.$emit('toggle-star-discussion', item);
// Assuming the parent/Vuex handles the actual state change and prop update
this.$emit('toggle-star-discussion', item); // Emit the discussion item itself
},
toggleStarSelectedDiscussions() {
const selected = this.selectedDiscussions;
if (selected.length === 0) return;
// Determine if we are starring or unstarring based on the first selected item's state
const isStarring = selected.length > 0 ? !selected[0].isStarred : true; // Default to starring if unsure
// Determine target state: if ANY selected are NOT starred, we STAR all. If ALL selected ARE starred, we UNSTAR all.
const shouldStar = selected.some(item => !item.isStarred);
selected.forEach(item => {
// Only emit toggle if the item's current state doesn't match the target state
if (item.isStarred !== isStarring) {
// Emit toggle only if the item's current state needs changing
if (item.isStarred !== shouldStar) {
this.toggleStarDiscussion(item);
}
});
this.$nextTick(() => feather.replace());
},
// Syncs local checkbox state with the incoming discussions list prop
syncLocalState(newList) {
// Ensure localDiscussionsState reflects the current discussionsList, preserving checkbox values
const currentIds = new Set((newList || []).map(d => d.id));
const updatedLocalState = this.localDiscussionsState.filter(ld => currentIds.has(ld.id)); // Keep existing states for current items
// Keep state for items that still exist, discard state for removed items
const updatedLocalState = this.localDiscussionsState.filter(ld => currentIds.has(ld.id));
// Add default (unchecked) state for new items
(newList || []).forEach(disc => {
// Add state for new items if they don't exist
if (!updatedLocalState.some(ld => ld.id === disc.id)) {
updatedLocalState.push({ id: disc.id, checkBoxValue: false });
}
@ -477,61 +469,43 @@ export default {
},
watch: {
discussionsList: {
handler(newList, oldList) {
// Check if lists differ significantly before syncing to avoid unnecessary updates
if (JSON.stringify(newList) !== JSON.stringify(oldList)) {
this.syncLocalState(newList);
}
// Always refresh icons after data changes that might affect layout/content
this.$nextTick(() => feather.replace());
handler(newList) {
this.syncLocalState(newList); // Keep local checkbox state aligned
this.$nextTick(() => feather.replace()); // Refresh icons after list changes
},
immediate: true,
deep: true // Watch for changes within discussion objects if necessary (e.g., title changes externally)
immediate: true, // Sync on initial load
deep: false // Shallow watch is enough if parent guarantees object identity changes
},
isCheckbox(newVal) {
this.$nextTick(() => feather.replace());
if (!newVal) {
this.showConfirmation = false;
// Optionally clear local checkbox state when exiting checkbox mode
// Optionally clear selections when exiting checkbox mode
// this.localDiscussionsState.forEach(state => state.checkBoxValue = false);
}
},
showConfirmation() {
this.$nextTick(() => feather.replace());
},
// Watch groupedDiscussions might be too expensive, rely on reactivity + nextTick in methods
// groupedDiscussions() {
// this.$nextTick(() => feather.replace());
// }
filterTitle() {
// No need for explicit feather call here, handleSearchInput does it after debounce
},
sortBy() {
this.$nextTick(() => feather.replace());
},
sortOrder() {
this.$nextTick(() => feather.replace());
}
// Watch computed properties only if necessary, often nextTick in methods is better
// groupedDiscussions() { this.$nextTick(() => feather.replace()); },
// filterTitle() { /* handled by handleSearchInput */ },
// sortBy() { this.$nextTick(() => feather.replace()); },
// sortOrder() { this.$nextTick(() => feather.replace()); }
},
mounted() {
// Initial sync and icon replacement
this.syncLocalState(this.discussionsList);
this.syncLocalState(this.discussionsList); // Initial sync
nextTick(() => {
feather.replace();
});
},
updated() {
// Ensure icons are replaced after any reactive update
// Be cautious with this, might cause performance issues if updates are frequent.
// Often better to call feather.replace() specifically after actions that change icons.
// nextTick(() => {
// feather.replace();
// });
// Generally avoid feather.replace() here unless absolutely necessary.
// Prefer calling it specifically in methods/watchers after DOM changes.
// nextTick(() => { feather.replace(); });
}
};
</script>
<style scoped>
/* Transitions for list items */
.discussionsList-move,
.discussionsList-enter-active,
.discussionsList-leave-active {
@ -541,19 +515,16 @@ export default {
.discussionsList-enter-from,
.discussionsList-leave-to {
opacity: 0;
transform: translateX(-15px);
transform: translateX(-20px); /* Adjusted transform */
}
/* Ensure leaving items don't disrupt layout */
.discussionsList-leave-active {
position: absolute;
/* Adjust width based on actual panel width minus padding/margins if necessary */
width: calc(16rem - 1rem); /* Example: panel width 16rem, padding x 0.5rem each side */
/* Or use percentage if parent width is reliable */
/* width: 100%; */
width: calc(100% - 1rem); /* Adjust based on padding/margins inside the scroll container */
box-sizing: border-box;
}
/* Transition for the panel itself */
.slide-right-enter-active,
.slide-right-leave-active {
transition: transform 0.3s ease-out;
@ -563,4 +534,10 @@ export default {
.slide-right-leave-to {
transform: translateX(-100%);
}
/* Ensure sticky headers work well */
.sticky {
position: -webkit-sticky; /* For Safari */
position: sticky;
}
</style>

@ -1 +1 @@
Subproject commit bea9b3378c5d2dda95cead76de5b75ac3b96a192
Subproject commit 35a483aff93632e7d060a14bdc2c4e67d0e245fb

@ -1 +1 @@
Subproject commit 0f54e59d1557a8a63884175d6985b17dc9ea8659
Subproject commit 0a38ab5a135dbac1f15832d44dd15753c57d8e68