"eslint-plugin-svelte": "^3.0.0",
"fflate": "^0.8.2",
"globals": "^16.0.0",
+ "http-server": "^14.1.1",
"mdast": "^3.0.0",
"mdsvex": "^0.12.3",
"playwright": "^1.53.0",
"node": ">=4"
}
},
+ "node_modules/async": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/axe-core": {
"version": "4.10.3",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz",
"dev": true,
"license": "MIT"
},
+ "node_modules/basic-auth": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
+ "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.1.2"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/better-opn": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz",
"node": ">=8"
}
},
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
"node": ">= 0.6"
}
},
+ "node_modules/corser": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz",
+ "integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"dev": true,
"license": "MIT"
},
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/enhanced-resolve": {
"version": "5.18.2",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz",
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"dev": true,
"license": "MIT"
},
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/es-toolkit": {
"version": "1.39.7",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.7.tgz",
"node": ">=0.10.0"
}
},
+ "node_modules/eventemitter3": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/expect-type": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz",
"dev": true,
"license": "ISC"
},
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"node": ">=8"
}
},
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/hast-util-from-dom": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz",
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "he": "bin/he"
+ }
+ },
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
"node": ">=12.0.0"
}
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
+ "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-encoding": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/html-void-elements": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/http-proxy": {
+ "version": "1.18.1",
+ "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
+ "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eventemitter3": "^4.0.0",
+ "follow-redirects": "^1.0.0",
+ "requires-port": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/http-server": {
+ "version": "14.1.1",
+ "resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz",
+ "integrity": "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "basic-auth": "^2.0.1",
+ "chalk": "^4.1.2",
+ "corser": "^2.0.1",
+ "he": "^1.2.0",
+ "html-encoding-sniffer": "^3.0.0",
+ "http-proxy": "^1.18.1",
+ "mime": "^1.6.0",
+ "minimist": "^1.2.6",
+ "opener": "^1.5.1",
+ "portfinder": "^1.0.28",
+ "secure-compare": "3.0.1",
+ "union": "~0.5.0",
+ "url-join": "^4.0.1"
+ },
+ "bin": {
+ "http-server": "bin/http-server"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/mdast": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mdast/-/mdast-3.0.0.tgz",
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"node": "*"
}
},
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"tslib": "^2.0.3"
}
},
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/open": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz",
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/opener": {
+ "version": "1.5.2",
+ "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
+ "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
+ "dev": true,
+ "license": "(WTFPL OR MIT)",
+ "bin": {
+ "opener": "bin/opener-bin.js"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
"node": ">=18"
}
},
+ "node_modules/portfinder": {
+ "version": "1.0.38",
+ "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz",
+ "integrity": "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "async": "^3.2.6",
+ "debug": "^4.3.6"
+ },
+ "engines": {
+ "node": ">= 10.12"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"node": ">=6"
}
},
+ "node_modules/qs": {
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
+ "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/requires-port": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
+ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"node": ">=6"
}
},
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"dev": true,
"license": "MIT"
},
+ "node_modules/secure-compare": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz",
+ "integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"node": ">=8"
}
},
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
+ "node_modules/union": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz",
+ "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==",
+ "dev": true,
+ "dependencies": {
+ "qs": "^6.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
"node_modules/unist-util-find-after": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz",
"punycode": "^2.1.0"
}
},
+ "node_modules/url-join": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz",
+ "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"dev": true,
"license": "MIT"
},
+ "node_modules/whatwg-encoding": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz",
+ "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"eslint-plugin-svelte": "^3.0.0",
"fflate": "^0.8.2",
"globals": "^16.0.0",
+ "http-server": "^14.1.1",
"mdast": "^3.0.0",
"mdsvex": "^0.12.3",
"playwright": "^1.53.0",
export default defineConfig({
webServer: {
- command: 'npm run build && npx http-server ../public -p 8181',
- port: 8181
+ command: 'npm run build && http-server ../public -p 8181',
+ port: 8181,
+ timeout: 120000,
+ reuseExistingServer: false
},
testDir: 'e2e'
});
const processingState = useProcessingState();
+ let isCurrentConversationLoading = $derived(isLoading());
let processingDetails = $derived(processingState.getProcessingDetails());
+ let showSlotsInfo = $derived(isCurrentConversationLoading || config().keepStatsVisible);
- let showSlotsInfo = $derived(isLoading() || config().keepStatsVisible);
-
+ // Track loading state reactively by checking if conversation ID is in loading conversations array
$effect(() => {
const keepStatsVisible = config().keepStatsVisible;
- if (keepStatsVisible || isLoading()) {
+ if (keepStatsVisible || isCurrentConversationLoading) {
processingState.startMonitoring();
}
- if (!isLoading() && !keepStatsVisible) {
+ if (!isCurrentConversationLoading && !keepStatsVisible) {
setTimeout(() => {
if (!config().keepStatsVisible) {
processingState.stopMonitoring();
}
});
+ // Update processing state from stored timings
$effect(() => {
- activeConversation();
-
+ const conversation = activeConversation();
const messages = activeMessages() as DatabaseMessage[];
const keepStatsVisible = config().keepStatsVisible;
- if (keepStatsVisible) {
+ if (keepStatsVisible && conversation) {
if (messages.length === 0) {
- slotsService.clearState();
+ slotsService.clearConversationState(conversation.id);
return;
}
+ // Search backwards through messages to find most recent assistant message with timing data
+ // Using reverse iteration for performance - avoids array copy and stops at first match
let foundTimingData = false;
for (let i = messages.length - 1; i >= 0; i--) {
foundTimingData = true;
slotsService
- .updateFromTimingData({
- prompt_n: message.timings.prompt_n || 0,
- predicted_n: message.timings.predicted_n || 0,
- predicted_per_second:
- message.timings.predicted_n && message.timings.predicted_ms
- ? (message.timings.predicted_n / message.timings.predicted_ms) * 1000
- : 0,
- cache_n: message.timings.cache_n || 0
- })
+ .updateFromTimingData(
+ {
+ prompt_n: message.timings.prompt_n || 0,
+ predicted_n: message.timings.predicted_n || 0,
+ predicted_per_second:
+ message.timings.predicted_n && message.timings.predicted_ms
+ ? (message.timings.predicted_n / message.timings.predicted_ms) * 1000
+ : 0,
+ cache_n: message.timings.cache_n || 0
+ },
+ conversation.id
+ )
.catch((error) => {
console.warn('Failed to update processing state from stored timings:', error);
});
}
if (!foundTimingData) {
- slotsService.clearState();
+ slotsService.clearConversationState(conversation.id);
}
}
});
let activeErrorDialog = $derived(errorDialog());
let isServerLoading = $derived(serverLoading());
+ let isCurrentConversationLoading = $derived(isLoading());
+
async function handleDeleteConfirm() {
const conversation = activeConversation();
if (conversation) {
});
$effect(() => {
- if (isLoading() && autoScrollEnabled) {
+ if (isCurrentConversationLoading && autoScrollEnabled) {
scrollInterval = setInterval(scrollChatToBottom, AUTO_SCROLL_INTERVAL);
} else if (scrollInterval) {
clearInterval(scrollInterval);
<div class="conversation-chat-form pointer-events-auto rounded-t-3xl pb-4">
<ChatForm
- isLoading={isLoading()}
+ isLoading={isCurrentConversationLoading}
onFileRemove={handleFileRemove}
onFileUpload={handleFileUpload}
onSend={handleSendMessage}
<div in:fly={{ y: 10, duration: 250, delay: 300 }}>
<ChatForm
- isLoading={isLoading()}
+ isLoading={isCurrentConversationLoading}
onFileRemove={handleFileRemove}
onFileUpload={handleFileUpload}
onSend={handleSendMessage}
<script lang="ts">
- import { Trash2, Pencil, MoreHorizontal, Download } from '@lucide/svelte';
+ import { Trash2, Pencil, MoreHorizontal, Download, Loader2 } from '@lucide/svelte';
import { ActionDropdown } from '$lib/components/app';
- import { downloadConversation } from '$lib/stores/chat.svelte';
+ import { downloadConversation, getAllLoadingConversations } from '$lib/stores/chat.svelte';
import { onMount } from 'svelte';
interface Props {
let renderActionsDropdown = $state(false);
let dropdownOpen = $state(false);
+ let isLoading = $derived(getAllLoadingConversations().includes(conversation.id));
+
function handleEdit(event: Event) {
event.stopPropagation();
onEdit?.(conversation.id);
onmouseover={handleMouseOver}
onmouseleave={handleMouseLeave}
>
- <!-- svelte-ignore a11y_click_events_have_key_events -->
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <span class="truncate text-sm font-medium" onclick={handleMobileSidebarItemClick}>
- {conversation.name}
- </span>
+ <div class="flex min-w-0 flex-1 items-center gap-2">
+ {#if isLoading}
+ <Loader2 class="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
+ {/if}
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
+ <span class="truncate text-sm font-medium" onclick={handleMobileSidebarItemClick}>
+ {conversation.name}
+ </span>
+ </div>
{#if renderActionsDropdown}
<div class="actions flex items-center">
* - Request lifecycle management (abort, cleanup)
*/
export class ChatService {
- private abortController: AbortController | null = null;
+ private abortControllers: Map<string, AbortController> = new Map();
/**
* Sends a chat completion request to the llama.cpp server.
*/
async sendMessage(
messages: ApiChatMessageData[] | (DatabaseMessage & { extra?: DatabaseMessageExtra[] })[],
- options: SettingsChatServiceOptions = {}
+ options: SettingsChatServiceOptions = {},
+ conversationId?: string
): Promise<string | void> {
const {
stream,
const currentConfig = config();
- // Cancel any ongoing request and create a new abort controller
- this.abort();
- this.abortController = new AbortController();
+ const requestId = conversationId || 'default';
+
+ if (this.abortControllers.has(requestId)) {
+ this.abortControllers.get(requestId)?.abort();
+ }
+
+ const abortController = new AbortController();
+ this.abortControllers.set(requestId, abortController);
- // Convert database messages with attachments to API format if needed
const normalizedMessages: ApiChatMessageData[] = messages
.map((msg) => {
- // Check if this is a DatabaseMessage by checking for DatabaseMessage-specific fields
if ('id' in msg && 'convId' in msg && 'timestamp' in msg) {
- // This is a DatabaseMessage, convert it
const dbMsg = msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] };
return ChatService.convertMessageToChatServiceData(dbMsg);
} else {
- // This is already an ApiChatMessageData object
return msg as ApiChatMessageData;
}
})
.filter((msg) => {
- // Filter out empty system messages
if (msg.role === 'system') {
const content = typeof msg.content === 'string' ? msg.content : '';
return true;
});
- // Build base request body with system message injection
const processedMessages = this.injectSystemMessage(normalizedMessages);
const requestBody: ApiChatCompletionRequest = {
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
},
body: JSON.stringify(requestBody),
- signal: this.abortController.signal
+ signal: abortController.signal
});
if (!response.ok) {
- // Use the new parseErrorResponse method to handle structured errors
const error = await this.parseErrorResponse(response);
if (onError) {
onError(error);
}
if (stream) {
- return this.handleStreamResponse(
+ await this.handleStreamResponse(
response,
onChunk,
onComplete,
onError,
- options.onReasoningChunk
+ options.onReasoningChunk,
+ conversationId,
+ abortController.signal
);
+ return;
} else {
return this.handleNonStreamResponse(response, onComplete, onError);
}
onError(userFriendlyError);
}
throw userFriendlyError;
+ } finally {
+ this.abortControllers.delete(requestId);
}
}
/**
- * Handles streaming response from the chat completion API.
- * Processes server-sent events and extracts content chunks from the stream.
- *
- * @param response - The fetch Response object containing the streaming data
+ * Handles streaming response from the chat completion API
+ * @param response - The Response object from the fetch request
* @param onChunk - Optional callback invoked for each content chunk received
* @param onComplete - Optional callback invoked when the stream is complete with full response
* @param onError - Optional callback invoked if an error occurs during streaming
* @param onReasoningChunk - Optional callback invoked for each reasoning content chunk
+ * @param conversationId - Optional conversation ID for per-conversation state tracking
* @returns {Promise<void>} Promise that resolves when streaming is complete
* @throws {Error} if the stream cannot be read or parsed
*/
timings?: ChatMessageTimings
) => void,
onError?: (error: Error) => void,
- onReasoningChunk?: (chunk: string) => void
+ onReasoningChunk?: (chunk: string) => void,
+ conversationId?: string,
+ abortSignal?: AbortSignal
): Promise<void> {
const reader = response.body?.getReader();
try {
let chunk = '';
while (true) {
+ if (abortSignal?.aborted) break;
+
const { done, value } = await reader.read();
if (done) break;
+ if (abortSignal?.aborted) break;
+
chunk += decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
- chunk = lines.pop() || ''; // Save incomplete line for next read
+ chunk = lines.pop() || '';
for (const line of lines) {
+ if (abortSignal?.aborted) break;
+
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
const promptProgress = parsed.prompt_progress;
if (timings || promptProgress) {
- this.updateProcessingState(timings, promptProgress);
-
- // Store the latest timing data
+ this.updateProcessingState(timings, promptProgress, conversationId);
if (timings) {
lastTimings = timings;
}
if (content) {
hasReceivedData = true;
aggregatedContent += content;
- onChunk?.(content);
+ if (!abortSignal?.aborted) {
+ onChunk?.(content);
+ }
}
if (reasoningContent) {
hasReceivedData = true;
fullReasoningContent += reasoningContent;
- onReasoningChunk?.(reasoningContent);
+ if (!abortSignal?.aborted) {
+ onReasoningChunk?.(reasoningContent);
+ }
}
} catch (e) {
console.error('Error parsing JSON chunk:', e);
}
}
}
+
+ if (abortSignal?.aborted) break;
}
+ if (abortSignal?.aborted) return;
+
if (streamFinished) {
if (!hasReceivedData && aggregatedContent.length === 0) {
const noResponseError = new Error('No response received from server. Please try again.');
*
* @public
*/
- public abort(): void {
- if (this.abortController) {
- this.abortController.abort();
- this.abortController = null;
+ public abort(conversationId?: string): void {
+ if (conversationId) {
+ const abortController = this.abortControllers.get(conversationId);
+ if (abortController) {
+ abortController.abort();
+ this.abortControllers.delete(conversationId);
+ }
+ } else {
+ for (const controller of this.abortControllers.values()) {
+ controller.abort();
+ }
+ this.abortControllers.clear();
}
}
return error;
} catch {
- // If we can't parse the error response, return a generic error
const fallback = new Error(`Server error (${response.status}): ${response.statusText}`);
fallback.name = 'HttpError';
return fallback;
private updateProcessingState(
timings?: ChatMessageTimings,
- promptProgress?: ChatMessagePromptProgress
+ promptProgress?: ChatMessagePromptProgress,
+ conversationId?: string
): void {
- // Calculate tokens per second from timing data
const tokensPerSecond =
timings?.predicted_ms && timings?.predicted_n
? (timings.predicted_n / timings.predicted_ms) * 1000
: 0;
- // Update slots service with timing data (async but don't wait)
slotsService
- .updateFromTimingData({
- prompt_n: timings?.prompt_n || 0,
- predicted_n: timings?.predicted_n || 0,
- predicted_per_second: tokensPerSecond,
- cache_n: timings?.cache_n || 0,
- prompt_progress: promptProgress
- })
+ .updateFromTimingData(
+ {
+ prompt_n: timings?.prompt_n || 0,
+ predicted_n: timings?.predicted_n || 0,
+ predicted_per_second: tokensPerSecond,
+ cache_n: timings?.cache_n || 0,
+ prompt_progress: promptProgress
+ },
+ conversationId
+ )
.catch((error) => {
console.warn('Failed to update processing state:', error);
});
private callbacks: Set<(state: ApiProcessingState | null) => void> = new Set();
private isStreamingActive: boolean = false;
private lastKnownState: ApiProcessingState | null = null;
+ private conversationStates: Map<string, ApiProcessingState | null> = new Map();
+ private activeConversationId: string | null = null;
/**
* Start streaming session tracking
return this.isStreamingActive;
}
+ /**
+ * Set the active conversation for statistics display
+ */
+ setActiveConversation(conversationId: string | null): void {
+ this.activeConversationId = conversationId;
+ this.notifyCallbacks();
+ }
+
+ /**
+ * Update processing state for a specific conversation
+ */
+ updateConversationState(conversationId: string, state: ApiProcessingState | null): void {
+ this.conversationStates.set(conversationId, state);
+
+ if (conversationId === this.activeConversationId) {
+ this.lastKnownState = state;
+ this.notifyCallbacks();
+ }
+ }
+
+ /**
+ * Get processing state for a specific conversation
+ */
+ getConversationState(conversationId: string): ApiProcessingState | null {
+ return this.conversationStates.get(conversationId) || null;
+ }
+
+ /**
+ * Clear state for a specific conversation
+ */
+ clearConversationState(conversationId: string): void {
+ this.conversationStates.delete(conversationId);
+
+ if (conversationId === this.activeConversationId) {
+ this.lastKnownState = null;
+ this.notifyCallbacks();
+ }
+ }
+
+ /**
+ * Notify all callbacks with current state
+ */
+ private notifyCallbacks(): void {
+ const currentState = this.activeConversationId
+ ? this.conversationStates.get(this.activeConversationId) || null
+ : this.lastKnownState;
+
+ for (const callback of this.callbacks) {
+ try {
+ callback(currentState);
+ } catch (error) {
+ console.error('Error in slots service callback:', error);
+ }
+ }
+ }
+
/**
* @deprecated Polling is no longer used - timing data comes from ChatService streaming response
* This method logs a warning if called to help identify outdated usage
/**
* Updates processing state with timing data from ChatService streaming response
*/
- async updateFromTimingData(timingData: {
- prompt_n: number;
- predicted_n: number;
- predicted_per_second: number;
- cache_n: number;
- prompt_progress?: ChatMessagePromptProgress;
- }): Promise<void> {
+ async updateFromTimingData(
+ timingData: {
+ prompt_n: number;
+ predicted_n: number;
+ predicted_per_second: number;
+ cache_n: number;
+ prompt_progress?: ChatMessagePromptProgress;
+ },
+ conversationId?: string
+ ): Promise<void> {
const processingState = await this.parseCompletionTimingData(timingData);
- // Only update if we successfully parsed the state
if (processingState === null) {
console.warn('Failed to parse timing data - skipping update');
+
return;
}
- this.lastKnownState = processingState;
-
- for (const callback of this.callbacks) {
- try {
- callback(processingState);
- } catch (error) {
- console.error('Error in timing callback:', error);
- }
+ if (conversationId) {
+ this.updateConversationState(conversationId, processingState);
+ } else {
+ this.lastKnownState = processingState;
+ this.notifyCallbacks();
}
}
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
}
});
+
if (response.ok) {
const slotsData = await response.json();
if (Array.isArray(slotsData) && slotsData.length > 0) {
if (contextTotal === null) {
console.warn('No context total available - cannot calculate processing state');
+
return null;
}
/**
* Get current processing state
* Returns the last known state from timing data, or null if no data available
+ * If activeConversationId is set, returns state for that conversation
*/
async getCurrentState(): Promise<ApiProcessingState | null> {
+ if (this.activeConversationId) {
+ const conversationState = this.conversationStates.get(this.activeConversationId);
+
+ if (conversationState) {
+ return conversationState;
+ }
+ }
+
if (this.lastKnownState) {
return this.lastKnownState;
}
try {
- // Import dynamically to avoid circular dependency
const { chatStore } = await import('$lib/stores/chat.svelte');
const messages = chatStore.activeMessages;
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
+import { SvelteMap } from 'svelte/reactivity';
import type { ExportedConversations } from '$lib/types/database';
/**
errorDialogState = $state<{ type: 'timeout' | 'server'; message: string } | null>(null);
isInitialized = $state(false);
isLoading = $state(false);
+ conversationLoadingStates = new SvelteMap<string, boolean>();
+ conversationStreamingStates = new SvelteMap<string, { response: string; messageId: string }>();
titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
constructor() {
this.activeConversation = conversation;
this.activeMessages = [];
+ slotsService.setActiveConversation(conversation.id);
+
+ const isConvLoading = this.isConversationLoading(conversation.id);
+ this.isLoading = isConvLoading;
+
+ this.currentResponse = '';
+
await goto(`#/chat/${conversation.id}`);
return conversation.id;
this.activeConversation = conversation;
+ slotsService.setActiveConversation(convId);
+
+ const isConvLoading = this.isConversationLoading(convId);
+ this.isLoading = isConvLoading;
+
+ const streamingState = this.getConversationStreaming(convId);
+ this.currentResponse = streamingState?.response || '';
+
if (conversation.currNode) {
const allMessages = await DatabaseStore.getConversationMessages(convId);
this.activeMessages = filterByLeafNodeId(
return apiOptions;
}
+ /**
+ * Helper methods for per-conversation loading state management
+ */
+ private setConversationLoading(convId: string, loading: boolean): void {
+ if (loading) {
+ this.conversationLoadingStates.set(convId, true);
+ if (this.activeConversation?.id === convId) {
+ this.isLoading = true;
+ }
+ } else {
+ this.conversationLoadingStates.delete(convId);
+ if (this.activeConversation?.id === convId) {
+ this.isLoading = false;
+ }
+ }
+ }
+
+ private isConversationLoading(convId: string): boolean {
+ return this.conversationLoadingStates.get(convId) || false;
+ }
+
+ private setConversationStreaming(convId: string, response: string, messageId: string): void {
+ this.conversationStreamingStates.set(convId, { response, messageId });
+ if (this.activeConversation?.id === convId) {
+ this.currentResponse = response;
+ }
+ }
+
+ private clearConversationStreaming(convId: string): void {
+ this.conversationStreamingStates.delete(convId);
+ if (this.activeConversation?.id === convId) {
+ this.currentResponse = '';
+ }
+ }
+
+ private getConversationStreaming(
+ convId: string
+ ): { response: string; messageId: string } | undefined {
+ return this.conversationStreamingStates.get(convId);
+ }
+
/**
* Handles streaming chat completion with the AI model
* @param allMessages - All messages in the conversation
};
slotsService.startStreaming();
+ slotsService.setActiveConversation(assistantMessage.convId);
- await chatService.sendMessage(allMessages, {
- ...this.getApiOptions(),
-
- onChunk: (chunk: string) => {
- streamedContent += chunk;
- this.currentResponse = streamedContent;
+ await chatService.sendMessage(
+ allMessages,
+ {
+ ...this.getApiOptions(),
+
+ onChunk: (chunk: string) => {
+ streamedContent += chunk;
+ this.setConversationStreaming(
+ assistantMessage.convId,
+ streamedContent,
+ assistantMessage.id
+ );
- captureModelIfNeeded();
- const messageIndex = this.findMessageIndex(assistantMessage.id);
- this.updateMessageAtIndex(messageIndex, {
- content: streamedContent
- });
- },
+ captureModelIfNeeded();
+ const messageIndex = this.findMessageIndex(assistantMessage.id);
+ this.updateMessageAtIndex(messageIndex, {
+ content: streamedContent
+ });
+ },
- onReasoningChunk: (reasoningChunk: string) => {
- streamedReasoningContent += reasoningChunk;
+ onReasoningChunk: (reasoningChunk: string) => {
+ streamedReasoningContent += reasoningChunk;
- captureModelIfNeeded();
+ captureModelIfNeeded();
- const messageIndex = this.findMessageIndex(assistantMessage.id);
+ const messageIndex = this.findMessageIndex(assistantMessage.id);
- this.updateMessageAtIndex(messageIndex, { thinking: streamedReasoningContent });
- },
+ this.updateMessageAtIndex(messageIndex, { thinking: streamedReasoningContent });
+ },
- onComplete: async (
- finalContent?: string,
- reasoningContent?: string,
- timings?: ChatMessageTimings
- ) => {
- slotsService.stopStreaming();
+ onComplete: async (
+ finalContent?: string,
+ reasoningContent?: string,
+ timings?: ChatMessageTimings
+ ) => {
+ slotsService.stopStreaming();
+
+ const updateData: {
+ content: string;
+ thinking: string;
+ timings?: ChatMessageTimings;
+ model?: string;
+ } = {
+ content: finalContent || streamedContent,
+ thinking: reasoningContent || streamedReasoningContent,
+ timings: timings
+ };
- const updateData: {
- content: string;
- thinking: string;
- timings?: ChatMessageTimings;
- model?: string;
- } = {
- content: finalContent || streamedContent,
- thinking: reasoningContent || streamedReasoningContent,
- timings: timings
- };
+ const capturedModel = captureModelIfNeeded(false);
- const capturedModel = captureModelIfNeeded(false);
+ if (capturedModel) {
+ updateData.model = capturedModel;
+ }
- if (capturedModel) {
- updateData.model = capturedModel;
- }
+ await DatabaseStore.updateMessage(assistantMessage.id, updateData);
- await DatabaseStore.updateMessage(assistantMessage.id, updateData);
+ const messageIndex = this.findMessageIndex(assistantMessage.id);
- const messageIndex = this.findMessageIndex(assistantMessage.id);
+ const localUpdateData: { timings?: ChatMessageTimings; model?: string } = {
+ timings: timings
+ };
- const localUpdateData: { timings?: ChatMessageTimings; model?: string } = {
- timings: timings
- };
+ if (updateData.model) {
+ localUpdateData.model = updateData.model;
+ }
- if (updateData.model) {
- localUpdateData.model = updateData.model;
- }
+ this.updateMessageAtIndex(messageIndex, localUpdateData);
- this.updateMessageAtIndex(messageIndex, localUpdateData);
+ await DatabaseStore.updateCurrentNode(assistantMessage.convId, assistantMessage.id);
- await DatabaseStore.updateCurrentNode(this.activeConversation!.id, assistantMessage.id);
- this.activeConversation!.currNode = assistantMessage.id;
- await this.refreshActiveMessages();
+ if (this.activeConversation?.id === assistantMessage.convId) {
+ this.activeConversation.currNode = assistantMessage.id;
+ await this.refreshActiveMessages();
+ }
- if (onComplete) {
- await onComplete(streamedContent);
- }
+ if (onComplete) {
+ await onComplete(streamedContent);
+ }
- this.isLoading = false;
- this.currentResponse = '';
- },
+ this.setConversationLoading(assistantMessage.convId, false);
+ this.clearConversationStreaming(assistantMessage.convId);
+ slotsService.clearConversationState(assistantMessage.convId);
+ },
- onError: (error: Error) => {
- slotsService.stopStreaming();
+ onError: (error: Error) => {
+ slotsService.stopStreaming();
- if (error.name === 'AbortError' || error instanceof DOMException) {
- this.isLoading = false;
- this.currentResponse = '';
- return;
- }
+ if (this.isAbortError(error)) {
+ this.setConversationLoading(assistantMessage.convId, false);
+ this.clearConversationStreaming(assistantMessage.convId);
+ slotsService.clearConversationState(assistantMessage.convId);
+ return;
+ }
- console.error('Streaming error:', error);
- this.isLoading = false;
- this.currentResponse = '';
+ console.error('Streaming error:', error);
+ this.setConversationLoading(assistantMessage.convId, false);
+ this.clearConversationStreaming(assistantMessage.convId);
+ slotsService.clearConversationState(assistantMessage.convId);
- const messageIndex = this.activeMessages.findIndex(
- (m: DatabaseMessage) => m.id === assistantMessage.id
- );
+ const messageIndex = this.activeMessages.findIndex(
+ (m: DatabaseMessage) => m.id === assistantMessage.id
+ );
- if (messageIndex !== -1) {
- const [failedMessage] = this.activeMessages.splice(messageIndex, 1);
+ if (messageIndex !== -1) {
+ const [failedMessage] = this.activeMessages.splice(messageIndex, 1);
- if (failedMessage) {
- DatabaseStore.deleteMessage(failedMessage.id).catch((cleanupError) => {
- console.error('Failed to remove assistant message after error:', cleanupError);
- });
+ if (failedMessage) {
+ DatabaseStore.deleteMessage(failedMessage.id).catch((cleanupError) => {
+ console.error('Failed to remove assistant message after error:', cleanupError);
+ });
+ }
}
- }
- const dialogType = error.name === 'TimeoutError' ? 'timeout' : 'server';
+ const dialogType = error.name === 'TimeoutError' ? 'timeout' : 'server';
- this.showErrorDialog(dialogType, error.message);
+ this.showErrorDialog(dialogType, error.message);
- if (onError) {
- onError(error);
+ if (onError) {
+ onError(error);
+ }
}
- }
- });
- }
-
- private showErrorDialog(type: 'timeout' | 'server', message: string): void {
- this.errorDialogState = { type, message };
- }
-
- dismissErrorDialog(): void {
- this.errorDialogState = null;
+ },
+ assistantMessage.convId
+ );
}
/**
return error instanceof Error && (error.name === 'AbortError' || error instanceof DOMException);
}
+ private showErrorDialog(type: 'timeout' | 'server', message: string): void {
+ this.errorDialogState = { type, message };
+ }
+
+ dismissErrorDialog(): void {
+ this.errorDialogState = null;
+ }
+
/**
* Finds the index of a message in the active messages array
* @param messageId - The message ID to find
* @param extras - Optional extra data (files, attachments, etc.)
*/
async sendMessage(content: string, extras?: DatabaseMessageExtra[]): Promise<void> {
- if ((!content.trim() && (!extras || extras.length === 0)) || this.isLoading) return;
+ if (!content.trim() && (!extras || extras.length === 0)) return;
+
+ if (this.activeConversation && this.isConversationLoading(this.activeConversation.id)) {
+ console.log('Cannot send message: current conversation is already processing a message');
+ return;
+ }
let isNewConversation = false;
}
this.errorDialogState = null;
- this.isLoading = true;
- this.currentResponse = '';
+
+ this.setConversationLoading(this.activeConversation.id, true);
+ this.clearConversationStreaming(this.activeConversation.id);
let userMessage: DatabaseMessage | null = null;
throw new Error('Failed to add user message');
}
- // If this is a new conversation, update the title with the first user prompt
if (isNewConversation && content) {
const title = content.trim();
await this.updateConversationName(this.activeConversation.id, title);
}
this.activeMessages.push(assistantMessage);
- // Don't update currNode until after streaming completes to maintain proper conversation path
const conversationContext = this.activeMessages.slice(0, -1);
await this.streamChatCompletion(conversationContext, assistantMessage);
} catch (error) {
if (this.isAbortError(error)) {
- this.isLoading = false;
+ this.setConversationLoading(this.activeConversation!.id, false);
return;
}
console.error('Failed to send message:', error);
- this.isLoading = false;
+ this.setConversationLoading(this.activeConversation!.id, false);
if (!this.errorDialogState) {
if (error instanceof Error) {
const dialogType = error.name === 'TimeoutError' ? 'timeout' : 'server';
* Stops the current message generation
* Aborts ongoing requests and saves partial response if available
*/
- stopGeneration(): void {
+ async stopGeneration(): Promise<void> {
+ if (!this.activeConversation) return;
+
+ const convId = this.activeConversation.id;
+
+ await this.savePartialResponseIfNeeded(convId);
+
slotsService.stopStreaming();
- chatService.abort();
- this.savePartialResponseIfNeeded();
- this.isLoading = false;
- this.currentResponse = '';
+ chatService.abort(convId);
+
+ this.setConversationLoading(convId, false);
+ this.clearConversationStreaming(convId);
+ slotsService.clearConversationState(convId);
}
/**
slotsService.stopStreaming();
chatService.abort();
await this.savePartialResponseIfNeeded();
+
+ this.conversationLoadingStates.clear();
+ this.conversationStreamingStates.clear();
this.isLoading = false;
this.currentResponse = '';
}
* Saves partial response if generation was interrupted
* Preserves user's partial content and timing data when generation is stopped early
*/
- private async savePartialResponseIfNeeded(): Promise<void> {
- if (!this.currentResponse.trim() || !this.activeMessages.length) {
+ private async savePartialResponseIfNeeded(convId?: string): Promise<void> {
+ const conversationId = convId || this.activeConversation?.id;
+ if (!conversationId) return;
+
+ const streamingState = this.conversationStreamingStates.get(conversationId);
+ if (!streamingState || !streamingState.response.trim()) {
return;
}
- const lastMessage = this.activeMessages[this.activeMessages.length - 1];
+ const messages =
+ conversationId === this.activeConversation?.id
+ ? this.activeMessages
+ : await DatabaseStore.getConversationMessages(conversationId);
+
+ if (!messages.length) return;
+
+ const lastMessage = messages[messages.length - 1];
if (lastMessage && lastMessage.role === 'assistant') {
try {
thinking?: string;
timings?: ChatMessageTimings;
} = {
- content: this.currentResponse
+ content: streamingState.response
};
if (lastMessage.thinking?.trim()) {
prompt_n: lastKnownState.promptTokens || 0,
predicted_n: lastKnownState.tokensDecoded || 0,
cache_n: lastKnownState.cacheTokens || 0,
- // We don't have ms data from the state, but we can estimate
predicted_ms:
lastKnownState.tokensPerSecond && lastKnownState.tokensDecoded
? (lastKnownState.tokensDecoded / lastKnownState.tokensPerSecond) * 1000
this.updateMessageAtIndex(messageIndex, { content: newContent });
await DatabaseStore.updateMessage(messageId, { content: newContent });
- // If this is the first user message, update the conversation title with confirmation if needed
if (isFirstUserMessage && newContent.trim()) {
await this.updateConversationTitleWithConfirmation(
this.activeConversation.id,
this.activeMessages = this.activeMessages.slice(0, messageIndex + 1);
this.updateConversationTimestamp();
- this.isLoading = true;
- this.currentResponse = '';
+ this.setConversationLoading(this.activeConversation.id, true);
+ this.clearConversationStreaming(this.activeConversation.id);
try {
const assistantMessage = await this.createAssistantMessage();
);
} catch (regenerateError) {
console.error('Failed to regenerate response:', regenerateError);
- this.isLoading = false;
+ this.setConversationLoading(this.activeConversation!.id, false);
const messageIndex = this.findMessageIndex(messageId);
this.updateMessageAtIndex(messageIndex, { content: originalContent });
this.activeMessages = this.activeMessages.slice(0, messageIndex);
this.updateConversationTimestamp();
- this.isLoading = true;
- this.currentResponse = '';
+ this.setConversationLoading(this.activeConversation.id, true);
+ this.clearConversationStreaming(this.activeConversation.id);
try {
const parentMessageId =
await this.streamChatCompletion(conversationContext, assistantMessage);
} catch (regenerateError) {
console.error('Failed to regenerate response:', regenerateError);
- this.isLoading = false;
+ this.setConversationLoading(this.activeConversation!.id, false);
}
} catch (error) {
if (this.isAbortError(error)) return;
try {
const currentConfig = config();
- // Only ask for confirmation if the setting is enabled and callback is provided
if (currentConfig.askForTitleConfirmation && onConfirmationNeeded) {
const conversation = await DatabaseStore.getConversation(convId);
if (!conversation) return false;
}
/**
- * Clears the active conversation and resets state
+ * Clears the active conversation and messages
* Used when navigating away from chat or starting fresh
+ * Note: Does not stop ongoing streaming to allow background completion
*/
clearActiveConversation(): void {
this.activeConversation = null;
this.activeMessages = [];
- this.currentResponse = '';
this.isLoading = false;
+ this.currentResponse = '';
+ slotsService.setActiveConversation(null);
}
/** Refreshes active messages based on currNode after branch navigation */
return;
}
- this.isLoading = true;
- this.currentResponse = '';
+ this.setConversationLoading(this.activeConversation.id, true);
+ this.clearConversationStreaming(this.activeConversation.id);
const newAssistantMessage = await DatabaseStore.createMessageBranch(
{
if (this.isAbortError(error)) return;
console.error('Failed to regenerate message with branching:', error);
- this.isLoading = false;
+ this.setConversationLoading(this.activeConversation!.id, false);
}
}
if (!this.activeConversation) return;
this.errorDialogState = null;
- this.isLoading = true;
- this.currentResponse = '';
+ this.setConversationLoading(this.activeConversation.id, true);
+ this.clearConversationStreaming(this.activeConversation.id);
try {
// Get conversation path up to the user message
await this.streamChatCompletion(conversationPath, assistantMessage);
} catch (error) {
console.error('Failed to generate response:', error);
- this.isLoading = false;
+ this.setConversationLoading(this.activeConversation!.id, false);
}
}
+
+ /**
+ * Public methods for accessing per-conversation states
+ */
+ public isConversationLoadingPublic(convId: string): boolean {
+ return this.isConversationLoading(convId);
+ }
+
+ public getConversationStreamingPublic(
+ convId: string
+ ): { response: string; messageId: string } | undefined {
+ return this.getConversationStreaming(convId);
+ }
+
+ public getAllLoadingConversations(): string[] {
+ return Array.from(this.conversationLoadingStates.keys());
+ }
+
+ public getAllStreamingConversations(): string[] {
+ return Array.from(this.conversationStreamingStates.keys());
+ }
}
export const chatStore = new ChatStore();
chatStore.stopGeneration();
}
export const messages = () => chatStore.activeMessages;
+
+// Per-conversation state access
+export const isConversationLoading = (convId: string) =>
+ chatStore.isConversationLoadingPublic(convId);
+export const getConversationStreaming = (convId: string) =>
+ chatStore.getConversationStreamingPublic(convId);
+export const getAllLoadingConversations = () => chatStore.getAllLoadingConversations();
+export const getAllStreamingConversations = () => chatStore.getAllStreamingConversations();
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
- import { beforeNavigate } from '$app/navigation';
import { ChatScreen } from '$lib/components/app';
import {
chatStore,
activeConversation,
isLoading,
- stopGeneration,
- gracefulStop
+ stopGeneration
} from '$lib/stores/chat.svelte';
- import { onDestroy } from 'svelte';
let chatId = $derived(page.params.id);
let currentChatId: string | undefined = undefined;
- beforeNavigate(async ({ cancel, to }) => {
- if (isLoading()) {
- console.log(
- 'Navigation detected while streaming - aborting stream and saving partial response'
- );
-
- cancel();
-
- await gracefulStop();
-
- if (to?.url) {
- await goto(to.url.pathname + to.url.search + to.url.hash);
- }
- }
- });
-
$effect(() => {
if (chatId && chatId !== currentChatId) {
- if (isLoading()) {
- console.log('Chat switch detected while streaming - aborting stream');
- stopGeneration();
- }
-
currentChatId = chatId;
+ // Skip loading if this conversation is already active (e.g., just created)
+ if (activeConversation()?.id === chatId) {
+ return;
+ }
+
(async () => {
const success = await chatStore.loadConversation(chatId);
};
}
});
-
- onDestroy(() => {
- if (isLoading()) {
- stopGeneration();
- }
- });
</script>
<svelte:head>
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: [vitePreprocess(), mdsvex()],
+
kit: {
paths: {
relative: true
bundleStrategy: 'inline'
}
},
+
extensions: ['.svelte', '.svx']
};
}
export default defineConfig({
+ build: {
+ chunkSizeWarningLimit: 3072
+ },
+
plugins: [tailwindcss(), sveltekit(), devtoolsJson(), llamaCppBuildPlugin()],
+
test: {
projects: [
{
}
]
},
+
server: {
proxy: {
'/v1': 'http://localhost:8080',