Dev #14
@@ -67,8 +67,15 @@ Outside production, the default directory is `data/uploads`; `UPLOAD_DIR` overri
|
|||||||
it. Startup creates the directory and checks write permission, failing clearly rather
|
it. Startup creates the directory and checks write permission, failing clearly rather
|
||||||
than falling back to another location. Files use `YYYY/MM/DD/<uuid>.<format>` keys.
|
than falling back to another location. Files use `YYYY/MM/DD/<uuid>.<format>` keys.
|
||||||
Do not publish this directory as a static directory. Back up **both the SQLite database
|
Do not publish this directory as a static directory. Back up **both the SQLite database
|
||||||
and uploads directory**. JSON workspace exports contain attachment references only;
|
and uploads directory** for server recovery. The workspace's **Download workspace archive**
|
||||||
imports reconnect existing images owned by the same account, not portable image bytes.
|
button saves a `.tar.gz` containing `workspace.json` and referenced images; import that
|
||||||
|
archive on another installation to restore chats with attachments. The importer also
|
||||||
|
accepts older JSON exports, which contain attachment references only and reconnect
|
||||||
|
images only when those IDs already belong to the importing account. Archives are
|
||||||
|
limited to 250 MB uncompressed, and each image must meet the destination's upload limit.
|
||||||
|
|
||||||
|
`CHAT_LIMIT` defaults to 10 active chats per account. Archived chats remain available
|
||||||
|
without using an active slot; restoring one requires a free slot.
|
||||||
|
|
||||||
Authenticated API endpoints:
|
Authenticated API endpoints:
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -1,7 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* @file Konfiguriert ESLint-Regeln und Browser-Globals für JavaScript-Dateien.
|
||||||
|
*/
|
||||||
import js from "@eslint/js";
|
import js from "@eslint/js";
|
||||||
import globals from "globals";
|
import globals from "globals";
|
||||||
import { defineConfig } from "eslint/config";
|
import { defineConfig } from "eslint/config";
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
{ files: ["**/*.{js,mjs,cjs}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.browser } },
|
{ files: ["**/*.{js,mjs,cjs}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: {...globals.browser, ...globals.node} } },
|
||||||
]);
|
]);
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "node --watch server.js",
|
"dev": "node --watch server.js",
|
||||||
"lint": "node scripts/lint.mjs",
|
"lint": "eslint .",
|
||||||
"test": "node --test"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+20
-10
@@ -11,7 +11,7 @@
|
|||||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap"
|
||||||
rel="stylesheet">
|
rel="stylesheet">
|
||||||
<script src="/js/theme-bootstrap.js?v=20260909-account-controls"></script>
|
<script src="/js/theme-bootstrap.js?v=20260909-account-controls"></script>
|
||||||
<link rel="stylesheet" href="/css/style.css?v=20260920-web-search-composer" />
|
<link rel="stylesheet" href="/css/style.css?v=20260930-chat-usability" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link rel="stylesheet" href="/css/pages.css?v=20260909-account-controls">
|
<link rel="stylesheet" href="/css/pages.css?v=20260909-account-controls">
|
||||||
<link rel="stylesheet" href="/css/shell.css?v=20260909-account-controls">
|
<link rel="stylesheet" href="/css/shell.css?v=20260909-account-controls">
|
||||||
@@ -41,6 +41,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
<button id="chatSidebarBackdrop" class="chat-sidebar-backdrop" type="button"
|
||||||
|
aria-label="Close conversation history" hidden></button>
|
||||||
|
|
||||||
<section id="main" class="chat-main chat-main-focus" aria-label="Active conversation">
|
<section id="main" class="chat-main chat-main-focus" aria-label="Active conversation">
|
||||||
<div class="chat-heading">
|
<div class="chat-heading">
|
||||||
@@ -127,6 +129,8 @@
|
|||||||
<p id="modelHelpSummary" class="model-help-summary">Pick a
|
<p id="modelHelpSummary" class="model-help-summary">Pick a
|
||||||
model to see what it is best suited for.</p>
|
model to see what it is best suited for.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<details class="generation-advanced">
|
||||||
|
<summary>Advanced generation settings</summary>
|
||||||
<div class="generation-settings-grid">
|
<div class="generation-settings-grid">
|
||||||
<label class="field"><span>Temperature</span><input
|
<label class="field"><span>Temperature</span><input
|
||||||
id="chatTemperature" type="number" min="0" max="2"
|
id="chatTemperature" type="number" min="0" max="2"
|
||||||
@@ -144,6 +148,7 @@
|
|||||||
<button id="saveGenerationSettings" type="button">Save
|
<button id="saveGenerationSettings" type="button">Save
|
||||||
generation settings</button>
|
generation settings</button>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<details
|
<details
|
||||||
@@ -216,12 +221,12 @@
|
|||||||
data-chat-action="personaState">Roleplay state</button>
|
data-chat-action="personaState">Roleplay state</button>
|
||||||
<button id="backupWorkspace"
|
<button id="backupWorkspace"
|
||||||
class="secondary-action chat-drawer-action"
|
class="secondary-action chat-drawer-action"
|
||||||
type="button">Backup workspace</button>
|
type="button">Download workspace archive</button>
|
||||||
<button id="importWorkspace"
|
<button id="importWorkspace"
|
||||||
class="secondary-action chat-drawer-action"
|
class="secondary-action chat-drawer-action"
|
||||||
type="button">Import workspace</button>
|
type="button">Import workspace</button>
|
||||||
<input id="importWorkspaceFile" class="hidden" type="file"
|
<input id="importWorkspaceFile" class="hidden" type="file"
|
||||||
accept="application/json,.json" />
|
accept="application/json,.json,.tar.gz,application/gzip" />
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<details class="chat-drawer-section chat-drawer-danger">
|
<details class="chat-drawer-section chat-drawer-danger">
|
||||||
@@ -259,8 +264,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="messages" class="messages-panel messages-panel-focus" role="log" aria-live="polite"
|
<div class="messages-wrap">
|
||||||
aria-relevant="additions text"></div>
|
<div id="messages" class="messages-panel messages-panel-focus" role="log" aria-live="polite"
|
||||||
|
aria-relevant="additions text"></div>
|
||||||
|
<button id="jumpToLatest" class="jump-to-latest secondary-action hidden" type="button">Jump to latest</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="input-shell input-shell-focus">
|
<div class="input-shell input-shell-focus">
|
||||||
<div class="input-meta">
|
<div class="input-meta">
|
||||||
@@ -308,7 +316,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="personaModal" class="modal-overlay side-panel-overlay hidden">
|
<div id="personaModal" class="modal-overlay side-panel-overlay hidden">
|
||||||
<div class="modal-card persona-modal-card side-panel-card">
|
<div class="modal-card persona-modal-card side-panel-card" role="dialog" aria-modal="true" aria-labelledby="personaFormTitle" tabindex="-1">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="persona-modal-heading">
|
<div class="persona-modal-heading">
|
||||||
<p class="eyebrow">Persona editor</p>
|
<p class="eyebrow">Persona editor</p>
|
||||||
@@ -372,7 +380,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="roleplayStarterModal" class="modal-overlay side-panel-overlay hidden">
|
<div id="roleplayStarterModal" class="modal-overlay side-panel-overlay hidden">
|
||||||
<div class="modal-card roleplay-starter-card side-panel-card">
|
<div class="modal-card roleplay-starter-card side-panel-card" role="dialog" aria-modal="true" aria-labelledby="roleplayStarterTitle" tabindex="-1">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="persona-modal-heading">
|
<div class="persona-modal-heading">
|
||||||
<p class="eyebrow">Start roleplay</p>
|
<p class="eyebrow">Start roleplay</p>
|
||||||
@@ -441,7 +449,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="onboardingModal" class="modal-overlay hidden">
|
<div id="onboardingModal" class="modal-overlay hidden">
|
||||||
<div class="modal-card onboarding-card">
|
<div class="modal-card onboarding-card" role="dialog" aria-modal="true" aria-labelledby="onboardingTitle" aria-describedby="onboardingSubtitle" tabindex="-1">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="persona-modal-heading">
|
<div class="persona-modal-heading">
|
||||||
<p class="eyebrow">Get started</p>
|
<p class="eyebrow">Get started</p>
|
||||||
@@ -458,9 +466,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js" defer></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/15.0.12/marked.min.js"
|
||||||
|
integrity="sha512-rCQgmUulW6f6QegOvTntKKb5IAoxTpGVCdWqYjkXEpzAns6XUFs8NKVqWe+KQpctp/EoRSFSuykVputqknLYMg=="
|
||||||
|
crossorigin="anonymous" defer></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.6/dist/purify.min.js" defer></script>
|
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.6/dist/purify.min.js" defer></script>
|
||||||
<script type="module" src="/js/script.js?v=20260923-vision-provenance"></script>
|
<script type="module" src="/js/script.js?v=20260930-chat-usability"></script>
|
||||||
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
|
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ body.site-page { width:100%; max-width:none; margin:0; padding:0; background:var
|
|||||||
.chat-page .chat-card { height:calc(100dvh - var(--shell-header-height)); min-height:0; border:0; border-radius:0; box-shadow:none; }
|
.chat-page .chat-card { height:calc(100dvh - var(--shell-header-height)); min-height:0; border:0; border-radius:0; box-shadow:none; }
|
||||||
.chat-page .chat-layout { grid-template-columns:260px minmax(0,1fr); }
|
.chat-page .chat-layout { grid-template-columns:260px minmax(0,1fr); }
|
||||||
.chat-page .chat-sidebar { position:static; inset:auto; width:auto; transform:none; box-shadow:none; grid-template-rows:auto minmax(0,1fr); padding:24px 18px; gap:20px; }
|
.chat-page .chat-sidebar { position:static; inset:auto; width:auto; transform:none; box-shadow:none; grid-template-rows:auto minmax(0,1fr); padding:24px 18px; gap:20px; }
|
||||||
|
.chat-page .chat-sidebar-backdrop { display:none; }
|
||||||
.chat-page .sidebar-workspace-card { gap:16px; border:0; padding:0; }
|
.chat-page .sidebar-workspace-card { gap:16px; border:0; padding:0; }
|
||||||
.chat-page .sidebar-workspace-card h2 { margin:0; font-size:.85rem; letter-spacing:0; font-family:var(--font-body); color:var(--text-soft); }
|
.chat-page .sidebar-workspace-card h2 { margin:0; font-size:.85rem; letter-spacing:0; font-family:var(--font-body); color:var(--text-soft); }
|
||||||
.chat-page .chat-main { grid-template-rows:auto minmax(0,1fr) auto; }
|
.chat-page .chat-main { grid-template-rows:auto minmax(0,1fr) auto; }
|
||||||
@@ -44,6 +45,7 @@ body.site-page { width:100%; max-width:none; margin:0; padding:0; background:var
|
|||||||
.chat-page .chat-layout { grid-template-columns:1fr; }
|
.chat-page .chat-layout { grid-template-columns:1fr; }
|
||||||
.chat-page .chat-sidebar { position:fixed; inset:var(--shell-header-height) auto 0 0; width:min(320px,88vw); z-index:80; transform:translateX(-104%); box-shadow:var(--shadow-lg); }
|
.chat-page .chat-sidebar { position:fixed; inset:var(--shell-header-height) auto 0 0; width:min(320px,88vw); z-index:80; transform:translateX(-104%); box-shadow:var(--shadow-lg); }
|
||||||
.chat-page.sidebar-open .chat-sidebar { transform:translateX(0); }
|
.chat-page.sidebar-open .chat-sidebar { transform:translateX(0); }
|
||||||
|
.chat-page .chat-sidebar-backdrop:not([hidden]) { display:block; position:fixed; inset:var(--shell-header-height) 0 0; z-index:79; width:100%; height:auto; padding:0; border:0; border-radius:0; background:rgba(0,0,0,.45); }
|
||||||
.chat-page .sidebar-toggle { display:inline-flex; width:auto; min-width:0; padding:8px 12px; font-size:.8rem; border-radius:10px; }
|
.chat-page .sidebar-toggle { display:inline-flex; width:auto; min-width:0; padding:8px 12px; font-size:.8rem; border-radius:10px; }
|
||||||
.site-page .settings-layout { grid-template-columns:1fr; }
|
.site-page .settings-layout { grid-template-columns:1fr; }
|
||||||
.site-page .settings-nav { display:flex; flex-wrap:wrap; }
|
.site-page .settings-nav { display:flex; flex-wrap:wrap; }
|
||||||
|
|||||||
+22
-2
@@ -363,7 +363,11 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
|
|||||||
}
|
}
|
||||||
.chat-item:hover { background: var(--surface-hover); }
|
.chat-item:hover { background: var(--surface-hover); }
|
||||||
.chat-item.active { background: var(--brand-soft); border-color: color-mix(in srgb, var(--brand) 35%, var(--border)); }
|
.chat-item.active { background: var(--brand-soft); border-color: color-mix(in srgb, var(--brand) 35%, var(--border)); }
|
||||||
.chat-item-copy { min-width: 0; display: grid; gap: 2px; }
|
.chat-item-copy { min-width: 0; width: 100%; padding: 0; display: grid; gap: 2px; justify-items: start; color: inherit; background: transparent; border: 0; text-align: left; font: inherit; }
|
||||||
|
.chat-item-copy:hover:not(:disabled) { background: transparent; }
|
||||||
|
.chat-item-copy > span, .chat-item-excerpt { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.chat-item-excerpt { color: var(--text-soft); font-size: .73rem; }
|
||||||
|
.chat-item:focus-within { border-color: var(--brand); }
|
||||||
.chat-item-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.chat-item-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.chat-item-meta { color: var(--text-muted); font-size: .75rem; }
|
.chat-item-meta { color: var(--text-muted); font-size: .75rem; }
|
||||||
.chat-item-delete { min-height: 30px; padding: 5px 8px; color: var(--text-muted); background: transparent; border: 0; font-size: .72rem; }
|
.chat-item-delete { min-height: 30px; padding: 5px 8px; color: var(--text-muted); background: transparent; border: 0; font-size: .72rem; }
|
||||||
@@ -394,11 +398,14 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
|
|||||||
.chat-header-actions { display: flex; align-items: center; }
|
.chat-header-actions { display: flex; align-items: center; }
|
||||||
.overflow-trigger { min-height: 38px; padding: 8px 12px; border-radius: 999px; font-size: .8rem; }
|
.overflow-trigger { min-height: 38px; padding: 8px 12px; border-radius: 999px; font-size: .8rem; }
|
||||||
|
|
||||||
|
.messages-wrap { min-height: 0; position: relative; }
|
||||||
.messages-panel {
|
.messages-panel {
|
||||||
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 28px max(16px, calc((100% - 940px) / 2));
|
padding: 28px max(16px, calc((100% - 940px) / 2));
|
||||||
display: flex; flex-direction: column; gap: 38px; overflow-y: auto;
|
display: flex; flex-direction: column; gap: 38px; overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
.jump-to-latest { position: absolute; left: 50%; bottom: 14px; z-index: 3; transform: translateX(-50%); box-shadow: var(--shadow-md); white-space: nowrap; }
|
||||||
.messages-empty {
|
.messages-empty {
|
||||||
width: min(680px, 100%); margin: auto; padding: clamp(24px, 5vw, 44px);
|
width: min(680px, 100%); margin: auto; padding: clamp(24px, 5vw, 44px);
|
||||||
text-align: center; background: var(--surface-soft); border: 1px solid var(--border); border-radius: var(--radius-lg);
|
text-align: center; background: var(--surface-soft); border: 1px solid var(--border); border-radius: var(--radius-lg);
|
||||||
@@ -419,6 +426,14 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
|
|||||||
.msg.user .msg-actions { right: 0; left: auto; justify-content: flex-end; }
|
.msg.user .msg-actions { right: 0; left: auto; justify-content: flex-end; }
|
||||||
.msg:hover .msg-actions, .msg:focus-within .msg-actions { opacity: 1; }
|
.msg:hover .msg-actions, .msg:focus-within .msg-actions { opacity: 1; }
|
||||||
.msg-action-btn { min-height: 30px; padding: 5px 8px; color: var(--text-soft) !important; background: transparent !important; border: 1px solid var(--border); border-radius: 8px; font-size: .7rem; }
|
.msg-action-btn { min-height: 30px; padding: 5px 8px; color: var(--text-soft) !important; background: transparent !important; border: 1px solid var(--border); border-radius: 8px; font-size: .7rem; }
|
||||||
|
.msg-action-menu { position: relative; }
|
||||||
|
.msg-action-menu > summary { display: flex; align-items: center; cursor: pointer; list-style: none; }
|
||||||
|
.msg-action-menu > summary::-webkit-details-marker { display: none; }
|
||||||
|
.msg-action-menu-items { position: absolute; bottom: calc(100% + 4px); left: 0; min-width: 130px; padding: 5px; display: grid; gap: 3px; background: var(--surface-raised); border: 1px solid var(--border); border-radius: 9px; box-shadow: var(--shadow-lg); }
|
||||||
|
.msg.user .msg-action-menu-items { left: auto; right: 0; }
|
||||||
|
.msg-action-menu-items .msg-action-btn { width: 100%; text-align: left; border: 0; }
|
||||||
|
.msg-action-menu-items .msg-action-btn:hover, .msg-action-menu-items .msg-action-btn:focus-visible { background: var(--surface-hover) !important; }
|
||||||
|
.msg-action-menu[open] { z-index: 5; }
|
||||||
.msg-variant-index { align-self: center; color: var(--text-muted); font-size: .7rem; }
|
.msg-variant-index { align-self: center; color: var(--text-muted); font-size: .7rem; }
|
||||||
.typing { display: flex; gap: 5px; }
|
.typing { display: flex; gap: 5px; }
|
||||||
.typing-dot { width: 6px; height: 6px; background: var(--brand); border-radius: 50%; animation: typing 1.2s infinite; }
|
.typing-dot { width: 6px; height: 6px; background: var(--brand); border-radius: 50%; animation: typing 1.2s infinite; }
|
||||||
@@ -467,7 +482,10 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
|
|||||||
.message-images img { max-width: min(360px, 100%); max-height: 300px; object-fit: contain; border-radius: 12px; }
|
.message-images img { max-width: min(360px, 100%); max-height: 300px; object-fit: contain; border-radius: 12px; }
|
||||||
.message-images a { max-width: 100%; }
|
.message-images a { max-width: 100%; }
|
||||||
.image-drag-target { outline: 2px solid currentColor; }
|
.image-drag-target { outline: 2px solid currentColor; }
|
||||||
@media (max-width: 480px) { .input-container { grid-template-columns: 36px 36px 36px minmax(0, 1fr) 36px; gap: 3px; } }
|
@media (max-width: 480px) {
|
||||||
|
.input-container { grid-template-columns: repeat(3, 36px) minmax(0, 1fr) 44px; grid-template-areas: "message message message message send" "attach search reasoning . ."; gap: 4px; }
|
||||||
|
#attachImage, #webSearch, #reasoning { width: 36px; height: 36px; min-height: 36px; border-radius: 12px; }
|
||||||
|
}
|
||||||
#webSearch, #reasoning { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 17px; }
|
#webSearch, #reasoning { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 17px; }
|
||||||
#webSearch { grid-area: search; }
|
#webSearch { grid-area: search; }
|
||||||
#reasoning { grid-area: reasoning; }
|
#reasoning { grid-area: reasoning; }
|
||||||
@@ -483,6 +501,8 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
|
|||||||
#send.loading::after { content: "■"; font-size: .85rem; }
|
#send.loading::after { content: "■"; font-size: .85rem; }
|
||||||
.generation-settings-grid { margin-top: 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
|
.generation-settings-grid { margin-top: 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
|
||||||
.generation-settings-grid .field-wide, .generation-settings-grid button { grid-column: 1 / -1; }
|
.generation-settings-grid .field-wide, .generation-settings-grid button { grid-column: 1 / -1; }
|
||||||
|
.generation-advanced { margin-top: 6px; border-top: 1px solid var(--border); }
|
||||||
|
.generation-advanced summary { padding: 10px 0; color: var(--text-soft); cursor: pointer; font-size: .8rem; }
|
||||||
|
|
||||||
/* ---------- Chat settings drawer ---------- */
|
/* ---------- Chat settings drawer ---------- */
|
||||||
.chat-drawer {
|
.chat-drawer {
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Bündelt JSON-Anfragen und verarbeitet Ereignisse gestreamter Chat-Antworten im Browser.
|
||||||
|
*/
|
||||||
export async function request(url, data, method = "POST") {
|
export async function request(url, data, method = "POST") {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@@ -60,6 +63,7 @@ export async function stream(url, data, handlers = {}, signal = undefined) {
|
|||||||
if (done) break;
|
if (done) break;
|
||||||
buffer += decoder.decode(value, {stream: true});
|
buffer += decoder.decode(value, {stream: true});
|
||||||
const parts = buffer.split(/\r?\n\r?\n/);
|
const parts = buffer.split(/\r?\n\r?\n/);
|
||||||
|
// Das letzte SSE-Ereignis kann unvollständig sein und wird mit dem nächsten Netzwerkblock ergänzt.
|
||||||
buffer = parts.pop() || "";
|
buffer = parts.pop() || "";
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
const lines = part.split("\n");
|
const lines = part.split("\n");
|
||||||
@@ -81,6 +85,7 @@ export async function stream(url, data, handlers = {}, signal = undefined) {
|
|||||||
}
|
}
|
||||||
if (terminal) break;
|
if (terminal) break;
|
||||||
}
|
}
|
||||||
|
// Ohne explizites Abschluss- oder Fehlerereignis gilt die Verbindung als vorzeitig abgebrochen.
|
||||||
if (!terminal) throw new Error("Response was interrupted. Please try again.");
|
if (!terminal) throw new Error("Response was interrupted. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
await reader.cancel().catch(() => {});
|
await reader.cancel().catch(() => {});
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Verwaltet Bildauswahl, Uploads und Vorschauen sowie die Anzeige gespeicherter Anhänge.
|
||||||
|
*/
|
||||||
export function appendAttachments(container, attachments = []) {
|
export function appendAttachments(container, attachments = []) {
|
||||||
if (!attachments.length) return;
|
if (!attachments.length) return;
|
||||||
const gallery = document.createElement("div");
|
const gallery = document.createElement("div");
|
||||||
@@ -24,6 +27,7 @@ export function createAttachmentComposer({onChange, onError}) {
|
|||||||
const preview = document.getElementById("uploadPreviews");
|
const preview = document.getElementById("uploadPreviews");
|
||||||
const composer = document.querySelector(".input-container");
|
const composer = document.querySelector(".input-container");
|
||||||
let items = [];
|
let items = [];
|
||||||
|
const drafts = new Map();
|
||||||
let busy = false;
|
let busy = false;
|
||||||
let limits = {maxSizeMb: 10, maxFiles: 5};
|
let limits = {maxSizeMb: 10, maxFiles: 5};
|
||||||
fetch("/uploads/config").then(r => r.ok ? r.json() : limits).then(value => { limits = value; }).catch(() => {});
|
fetch("/uploads/config").then(r => r.ok ? r.json() : limits).then(value => { limits = value; }).catch(() => {});
|
||||||
@@ -70,13 +74,13 @@ export function createAttachmentComposer({onChange, onError}) {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (!response.ok) throw new Error(result.error || "Upload failed");
|
if (!response.ok) throw new Error(result.error || "Upload failed");
|
||||||
item.metadata = result;
|
item.metadata = result;
|
||||||
if (!items.includes(item)) void fetch(`/attachments/${result.id}`, {method: "DELETE"});
|
if (![items, ...drafts.values()].some(draft => draft.includes(item))) void fetch(`/attachments/${result.id}`, {method: "DELETE"});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name !== "AbortError") item.error = error.message || "Upload failed";
|
if (error.name !== "AbortError") item.error = error.message || "Upload failed";
|
||||||
}
|
}
|
||||||
item.uploading = false;
|
item.uploading = false;
|
||||||
item.controller = null;
|
item.controller = null;
|
||||||
render();
|
if (items.includes(item)) render();
|
||||||
};
|
};
|
||||||
const add = files => {
|
const add = files => {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
@@ -114,6 +118,25 @@ export function createAttachmentComposer({onChange, onError}) {
|
|||||||
}
|
}
|
||||||
items = [];
|
items = [];
|
||||||
render();
|
render();
|
||||||
|
},
|
||||||
|
saveDraft(chatId) {
|
||||||
|
if (chatId != null) drafts.set(chatId, items);
|
||||||
|
items = [];
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
restoreDraft(chatId) {
|
||||||
|
items = drafts.get(chatId) || [];
|
||||||
|
drafts.delete(chatId);
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
discardDraft(chatId) {
|
||||||
|
const saved = drafts.get(chatId) || [];
|
||||||
|
drafts.delete(chatId);
|
||||||
|
for (const item of saved) {
|
||||||
|
item.controller?.abort();
|
||||||
|
URL.revokeObjectURL(item.preview);
|
||||||
|
if (item.metadata) void fetch(`/attachments/${item.metadata.id}`, {method: "DELETE"});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Steuert das Chat-Seitenpanel einschließlich Tastaturbedienung und Fokusverwaltung.
|
||||||
|
*/
|
||||||
import {chatDrawer, chatDrawerButton, chatDrawerCloseBtn} from "./dom.js?v=20260909-account-controls";
|
import {chatDrawer, chatDrawerButton, chatDrawerCloseBtn} from "./dom.js?v=20260909-account-controls";
|
||||||
|
|
||||||
let lastFocusedElement = null;
|
let lastFocusedElement = null;
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Stellt gemeinsame Theme-Daten, Einstiegsvorschläge und die angefragte Chat-ID bereit.
|
||||||
|
*/
|
||||||
export const requestedChatId = Number(window.location.pathname.match(/^\/app\/chats\/(\d+)$/)?.[1] || new URLSearchParams(window.location.search).get("chat")) || null;
|
export const requestedChatId = Number(window.location.pathname.match(/^\/app\/chats\/(\d+)$/)?.[1] || new URLSearchParams(window.location.search).get("chat")) || null;
|
||||||
|
|
||||||
export const onboardingPrompts = {
|
export const onboardingPrompts = {
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Bündelt Referenzen auf die HTML-Elemente der Chat-Oberfläche.
|
||||||
|
*/
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
const chatDiv = $("chat");
|
const chatDiv = $("chat");
|
||||||
|
|||||||
+20
-10
@@ -1,7 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* @file Steuert die Persona-Marktplatzoberfläche mit Suche, Vorschauen und Persona-Verwaltung.
|
||||||
|
*/
|
||||||
import { get, post, put } from "./api.js?v=20260909-account-controls";
|
import { get, post, put } from "./api.js?v=20260909-account-controls";
|
||||||
import { initializeAppearance } from "./themeController.js?v=20260909-account-controls";
|
import { initializeAppearance } from "./themeController.js?v=20260909-account-controls";
|
||||||
import { clearStatus, setStatus } from "./statusNotice.js?v=20260909-account-controls";
|
import { clearStatus, setStatus } from "./statusNotice.js?v=20260909-account-controls";
|
||||||
import { populatePersonaForm, readPersonaForm } from "./personaForm.js?v=20260909-account-controls";
|
import { populatePersonaForm, readPersonaForm } from "./personaForm.js?v=20260909-account-controls";
|
||||||
|
import { openModal, closeModal, topModal } from "./modalFocus.js";
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const marketPersonaList = $("marketPersonaList");
|
const marketPersonaList = $("marketPersonaList");
|
||||||
@@ -28,7 +32,6 @@ const marketPreviewExampleDialogues = $("marketPreviewExampleDialogues");
|
|||||||
const marketPreviewIntro = $("marketPreviewIntro");
|
const marketPreviewIntro = $("marketPreviewIntro");
|
||||||
const marketPreviewUserPersonaField = $("marketPreviewUserPersonaField");
|
const marketPreviewUserPersonaField = $("marketPreviewUserPersonaField");
|
||||||
const marketPreviewUserPersonaSelect = $("marketPreviewUserPersonaSelect");
|
const marketPreviewUserPersonaSelect = $("marketPreviewUserPersonaSelect");
|
||||||
const marketPreviewScenarioField = $("marketPreviewScenarioField");
|
|
||||||
const marketPreviewScenario = $("marketPreviewScenario");
|
const marketPreviewScenario = $("marketPreviewScenario");
|
||||||
const marketPreviewStepOne = $("marketPreviewStepOne");
|
const marketPreviewStepOne = $("marketPreviewStepOne");
|
||||||
const marketPreviewStepTwo = $("marketPreviewStepTwo");
|
const marketPreviewStepTwo = $("marketPreviewStepTwo");
|
||||||
@@ -71,7 +74,6 @@ const marketPersonaFields = {
|
|||||||
|
|
||||||
const validMarketSorts = new Set(["best", "newest", "most_favorited", "most_popular", "top_rated", "alphabetical"]);
|
const validMarketSorts = new Set(["best", "newest", "most_favorited", "most_popular", "top_rated", "alphabetical"]);
|
||||||
let marketPersonasByType = { assistant: [], user: [] };
|
let marketPersonasByType = { assistant: [], user: [] };
|
||||||
let assistantPersonas = [];
|
|
||||||
let userPersonas = [];
|
let userPersonas = [];
|
||||||
let pendingMarketPersona = null;
|
let pendingMarketPersona = null;
|
||||||
let editingPersonaId = null;
|
let editingPersonaId = null;
|
||||||
@@ -87,7 +89,7 @@ function setMarketStatus(message, state = "") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeMarketPopup(value = null) {
|
function closeMarketPopup(value = null) {
|
||||||
marketPopupModal.classList.add("hidden");
|
closeModal(marketPopupModal);
|
||||||
if (marketPopupResolver) {
|
if (marketPopupResolver) {
|
||||||
const resolve = marketPopupResolver;
|
const resolve = marketPopupResolver;
|
||||||
marketPopupResolver = null;
|
marketPopupResolver = null;
|
||||||
@@ -112,7 +114,7 @@ function promptMarketPopup({
|
|||||||
marketPopupInput.placeholder = placeholder;
|
marketPopupInput.placeholder = placeholder;
|
||||||
marketPopupConfirm.textContent = confirmLabel;
|
marketPopupConfirm.textContent = confirmLabel;
|
||||||
marketPopupField.classList.remove("hidden");
|
marketPopupField.classList.remove("hidden");
|
||||||
marketPopupModal.classList.remove("hidden");
|
openModal(marketPopupModal, marketPopupInput);
|
||||||
marketPopupInput.focus();
|
marketPopupInput.focus();
|
||||||
marketPopupInput.select();
|
marketPopupInput.select();
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -238,7 +240,7 @@ function setCollapsiblePreviewText(element, value, maxLength = 220) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeMarketPreview() {
|
function closeMarketPreview() {
|
||||||
marketPreviewModal.classList.add("hidden");
|
closeModal(marketPreviewModal);
|
||||||
pendingMarketPersona = null;
|
pendingMarketPersona = null;
|
||||||
marketPreviewStep = 1;
|
marketPreviewStep = 1;
|
||||||
marketPreviewUserPersonaSelect.innerHTML = "";
|
marketPreviewUserPersonaSelect.innerHTML = "";
|
||||||
@@ -315,7 +317,7 @@ function openMarketPreview(persona) {
|
|||||||
marketPreviewConfirm.classList.remove("hidden");
|
marketPreviewConfirm.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
renderMarketPreviewStep();
|
renderMarketPreviewStep();
|
||||||
marketPreviewModal.classList.remove("hidden");
|
openModal(marketPreviewModal, isAssistant ? marketPreviewUserPersonaSelect : marketPreviewConfirm);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPersonaForm(persona = null) {
|
function openPersonaForm(persona = null) {
|
||||||
@@ -323,12 +325,11 @@ function openPersonaForm(persona = null) {
|
|||||||
marketPersonaFormTitle.textContent = persona ? "Edit AI Character" : "Create AI Character";
|
marketPersonaFormTitle.textContent = persona ? "Edit AI Character" : "Create AI Character";
|
||||||
populatePersonaForm(marketPersonaFields, persona || {});
|
populatePersonaForm(marketPersonaFields, persona || {});
|
||||||
setPersonaFormNotice("");
|
setPersonaFormNotice("");
|
||||||
marketPersonaModal.classList.remove("hidden");
|
openModal(marketPersonaModal, marketPersonaName);
|
||||||
marketPersonaName.focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePersonaForm() {
|
function closePersonaForm() {
|
||||||
marketPersonaModal.classList.add("hidden");
|
closeModal(marketPersonaModal);
|
||||||
editingPersonaId = null;
|
editingPersonaId = null;
|
||||||
setPersonaFormNotice("");
|
setPersonaFormNotice("");
|
||||||
}
|
}
|
||||||
@@ -507,7 +508,6 @@ async function reportPersona(marketId) {
|
|||||||
async function loadOwnedPersonas() {
|
async function loadOwnedPersonas() {
|
||||||
const res = await get("/personas");
|
const res = await get("/personas");
|
||||||
if (res.error) return false;
|
if (res.error) return false;
|
||||||
assistantPersonas = res.assistantPersonas || [];
|
|
||||||
userPersonas = res.userPersonas || [];
|
userPersonas = res.userPersonas || [];
|
||||||
activeUserPersonaId = res.activeUserPersonaId || null;
|
activeUserPersonaId = res.activeUserPersonaId || null;
|
||||||
return true;
|
return true;
|
||||||
@@ -605,9 +605,19 @@ marketPopupInput.addEventListener("keydown", (event) => {
|
|||||||
}
|
}
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
closeMarketPopup(null);
|
closeMarketPopup(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
document.addEventListener("keydown", event => {
|
||||||
|
if (event.key !== "Escape") return;
|
||||||
|
const modal = topModal();
|
||||||
|
if (!modal) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (modal === marketPopupModal) closeMarketPopup(null);
|
||||||
|
else if (modal === marketPersonaModal) closePersonaForm();
|
||||||
|
else if (modal === marketPreviewModal) closeMarketPreview();
|
||||||
|
});
|
||||||
marketSearchInput.addEventListener("input", applyMarketFilter);
|
marketSearchInput.addEventListener("input", applyMarketFilter);
|
||||||
if (marketAssistantSort) {
|
if (marketAssistantSort) {
|
||||||
marketAssistantSort.addEventListener("change", () => setMarketSort("assistant", marketAssistantSort.value));
|
marketAssistantSort.addEventListener("change", () => setMarketSort("assistant", marketAssistantSort.value));
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
const focusableSelector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||||
|
const stack = [];
|
||||||
|
|
||||||
|
function focusables(modal) {
|
||||||
|
return [...modal.querySelectorAll(focusableSelector)].filter(element =>
|
||||||
|
element.getClientRects().length && !element.closest('.hidden, [hidden]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openModal(modal, preferredFocus) {
|
||||||
|
const previous = document.activeElement;
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
stack.push({modal, previous});
|
||||||
|
(preferredFocus || focusables(modal)[0] || modal.querySelector('[role="dialog"]'))?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeModal(modal) {
|
||||||
|
const index = stack.findLastIndex(entry => entry.modal === modal);
|
||||||
|
if (index < 0) return;
|
||||||
|
const [{previous}] = stack.splice(index, 1);
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
if (index === stack.length) {
|
||||||
|
const target = stack.length ? focusables(stack.at(-1).modal)[0] : previous;
|
||||||
|
if (target?.isConnected) target.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function topModal() { return stack.at(-1)?.modal || null; }
|
||||||
|
|
||||||
|
document.addEventListener('keydown', event => {
|
||||||
|
if (event.key !== 'Tab' || !stack.length) return;
|
||||||
|
const modal = topModal();
|
||||||
|
const elements = focusables(modal);
|
||||||
|
if (!elements.length) { event.preventDefault(); modal.querySelector('[role="dialog"]')?.focus(); return; }
|
||||||
|
const first = elements[0], last = elements.at(-1);
|
||||||
|
if (event.shiftKey && (document.activeElement === first || !modal.contains(document.activeElement))) {
|
||||||
|
event.preventDefault(); last.focus();
|
||||||
|
} else if (!event.shiftKey && (document.activeElement === last || !modal.contains(document.activeElement))) {
|
||||||
|
event.preventDefault(); first.focus();
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Ermittelt die Modellfamilie und prüft die Unterstützung des Reasoning-Modus.
|
||||||
|
*/
|
||||||
// Conservative allowlist for models supporting the boolean think API.
|
// Conservative allowlist for models supporting the boolean think API.
|
||||||
// Unknown models remain usable for normal chat.
|
// Unknown models remain usable for normal chat.
|
||||||
const reasoningFamilies = new Set(["qwen3", "deepseek-r1", "deepseek-v3.1"]);
|
const reasoningFamilies = new Set(["qwen3", "deepseek-r1", "deepseek-v3.1"]);
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Ordnet Modellen Profilinformationen für ihre Darstellung und Auswahl in der Oberfläche zu.
|
||||||
|
*/
|
||||||
export const defaultModelProfile = {
|
export const defaultModelProfile = {
|
||||||
badge: "General",
|
badge: "General",
|
||||||
summary: "Best for general questions, drafting, and everyday conversation. Use a specialized model when you need stronger coding, reasoning, or roleplay behavior."
|
summary: "Best for general questions, drafting, and everyday conversation. Use a specialized model when you need stronger coding, reasoning, or roleplay behavior."
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Steuert öffentliche Seiten sowie Anmelde- und Registrierungsformulare mit Rückkehr in den Arbeitsbereich.
|
||||||
|
*/
|
||||||
import { get, post } from "/js/api.js?v=20260909-account-controls";
|
import { get, post } from "/js/api.js?v=20260909-account-controls";
|
||||||
|
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Überträgt Persona-Daten zwischen Formularfeldern und den verwendeten Datenobjekten.
|
||||||
|
*/
|
||||||
const personaFieldNames = ["name", "pronouns", "appearance", "background", "details", "exampleDialogues"];
|
const personaFieldNames = ["name", "pronouns", "appearance", "background", "details", "exampleDialogues"];
|
||||||
|
|
||||||
export function populatePersonaForm(fields, persona = {}) {
|
export function populatePersonaForm(fields, persona = {}) {
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Steuert die Reasoning-Auswahl und die aufklappbare Anzeige gestreamter Gedankengänge.
|
||||||
|
*/
|
||||||
import {supportsReasoning} from "./modelCapabilities.js?v=20260921-reasoning-capability";
|
import {supportsReasoning} from "./modelCapabilities.js?v=20260921-reasoning-capability";
|
||||||
|
|
||||||
export function createReasoningToggle(button) {
|
export function createReasoningToggle(button) {
|
||||||
@@ -43,6 +46,7 @@ export function updateReasoning(messageElement, thinking, streaming = false) {
|
|||||||
}
|
}
|
||||||
details.querySelector("summary").textContent = streaming ? "Reasoning · Thinking…" : "Reasoning";
|
details.querySelector("summary").textContent = streaming ? "Reasoning · Thinking…" : "Reasoning";
|
||||||
const content = details.querySelector(".msg-reasoning-content");
|
const content = details.querySelector(".msg-reasoning-content");
|
||||||
|
// Nur automatisch nach unten scrollen, wenn die Ansicht bereits nahe am Ende steht.
|
||||||
const follow = content.scrollHeight - content.scrollTop - content.clientHeight < 40;
|
const follow = content.scrollHeight - content.scrollTop - content.clientHeight < 40;
|
||||||
content.textContent = thinking;
|
content.textContent = thinking;
|
||||||
if (streaming && follow) content.scrollTop = content.scrollHeight;
|
if (streaming && follow) content.scrollTop = content.scrollHeight;
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Ersetzt eine Antwort während der Neugenerierung in ihrer Nachrichtenkarte und stellt sie bei Fehlern wieder her.
|
||||||
|
*/
|
||||||
import {stream} from "./api.js";
|
import {stream} from "./api.js";
|
||||||
import {updateReasoning, finishReasoning} from "./reasoning.js";
|
import {updateReasoning, finishReasoning} from "./reasoning.js";
|
||||||
import {appendSources} from "./webSearch.js";
|
import {appendSources} from "./webSearch.js";
|
||||||
@@ -46,6 +49,7 @@ export async function regenerateInline({card, scroller, endpoint, payload, rende
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
// Bei Fehler oder Abbruch die ursprünglichen Knoten samt Ereignishandlern wieder einsetzen.
|
||||||
if (!committed) update(() => card.replaceChildren(...original));
|
if (!committed) update(() => card.replaceChildren(...original));
|
||||||
card.removeAttribute("aria-busy");
|
card.removeAttribute("aria-busy");
|
||||||
}
|
}
|
||||||
|
|||||||
+336
-101
@@ -1,9 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* @file Steuert die Chat-Oberfläche mit Gesprächsauswahl, Nachrichteneingabe, Streaming und Persona-Verwaltung.
|
||||||
|
*/
|
||||||
import { del, get, post, put, stream } from "./api.js?v=20260909-account-controls";
|
import { del, get, post, put, stream } from "./api.js?v=20260909-account-controls";
|
||||||
import { regenerateInline } from "./regeneration.js";
|
import { regenerateInline } from "./regeneration.js";
|
||||||
import { appendSources, createWebSearchToggle } from "./webSearch.js";
|
import { appendSources, createWebSearchToggle } from "./webSearch.js";
|
||||||
import { appendAttachments, createAttachmentComposer } from "./attachments.js?v=20260923-vision-provenance";
|
import { appendAttachments, createAttachmentComposer } from "./attachments.js?v=20260930-chat-usability";
|
||||||
import { createReasoningToggle, finishReasoning, updateReasoning } from "./reasoning.js?v=20260921-reasoning-capability";
|
import { createReasoningToggle, finishReasoning, updateReasoning } from "./reasoning.js?v=20260921-reasoning-capability";
|
||||||
import { closeChatDrawer, openChatDrawer } from "./chatDrawer.js?v=20260909-account-controls";
|
import { closeChatDrawer, openChatDrawer } from "./chatDrawer.js?v=20260909-account-controls";
|
||||||
|
import { openModal, closeModal, topModal } from "./modalFocus.js";
|
||||||
import { onboardingPrompts, requestedChatId } from "./constants.js?v=20260909-account-controls";
|
import { onboardingPrompts, requestedChatId } from "./constants.js?v=20260909-account-controls";
|
||||||
import { getModelProfile } from "./modelProfiles.js?v=20260909-account-controls";
|
import { getModelProfile } from "./modelProfiles.js?v=20260909-account-controls";
|
||||||
import { applyTheme as applySharedTheme, applyThemeMode as applySharedThemeMode, readStoredAppearance } from "./themeController.js?v=20260909-account-controls";
|
import { applyTheme as applySharedTheme, applyThemeMode as applySharedThemeMode, readStoredAppearance } from "./themeController.js?v=20260909-account-controls";
|
||||||
@@ -124,6 +128,16 @@ let chatLoadingDepth = 0;
|
|||||||
let chatActivityDepth = 0;
|
let chatActivityDepth = 0;
|
||||||
let messageRetryState = new Map();
|
let messageRetryState = new Map();
|
||||||
let availableModelsById = new Map();
|
let availableModelsById = new Map();
|
||||||
|
let chatSearchResults = null;
|
||||||
|
let chatSearchError = "";
|
||||||
|
let chatSearchTimer = null;
|
||||||
|
let chatSearchRequest = 0;
|
||||||
|
let chatOpenRequest = 0;
|
||||||
|
const chatDrafts = new Map();
|
||||||
|
const jumpToLatest = document.getElementById("jumpToLatest");
|
||||||
|
let scrollFrame = null;
|
||||||
|
let renderedChatId = null;
|
||||||
|
let renderedMessages = new Map();
|
||||||
|
|
||||||
|
|
||||||
function setNotice(message = "", state = "") {
|
function setNotice(message = "", state = "") {
|
||||||
@@ -190,15 +204,33 @@ function isMobileLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeMobileSidebar() {
|
function closeMobileSidebar() {
|
||||||
if (!chatSidebar) return;
|
if (!chatSidebar || !document.body.classList.contains("sidebar-open")) return;
|
||||||
document.body.classList.remove("sidebar-open");
|
document.body.classList.remove("sidebar-open");
|
||||||
if (chatSidebarToggle) chatSidebarToggle.setAttribute("aria-expanded", "false");
|
if (chatSidebarToggle) {
|
||||||
|
chatSidebarToggle.setAttribute("aria-expanded", "false");
|
||||||
|
chatSidebarToggle.setAttribute("aria-label", "Open conversation history");
|
||||||
|
}
|
||||||
|
chatSidebar.inert = isMobileLayout();
|
||||||
|
chatSidebar.removeAttribute("aria-modal");
|
||||||
|
chatSidebar.removeAttribute("role");
|
||||||
|
const backdrop = document.getElementById("chatSidebarBackdrop");
|
||||||
|
if (backdrop) backdrop.hidden = true;
|
||||||
|
chatSidebarToggle?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openMobileSidebar() {
|
function openMobileSidebar() {
|
||||||
if (!chatSidebar) return;
|
if (!chatSidebar || !isMobileLayout()) return;
|
||||||
|
chatSidebar.inert = false;
|
||||||
|
chatSidebar.setAttribute("role", "dialog");
|
||||||
|
chatSidebar.setAttribute("aria-modal", "true");
|
||||||
document.body.classList.add("sidebar-open");
|
document.body.classList.add("sidebar-open");
|
||||||
if (chatSidebarToggle) chatSidebarToggle.setAttribute("aria-expanded", "true");
|
if (chatSidebarToggle) {
|
||||||
|
chatSidebarToggle.setAttribute("aria-expanded", "true");
|
||||||
|
chatSidebarToggle.setAttribute("aria-label", "Close conversation history");
|
||||||
|
}
|
||||||
|
const backdrop = document.getElementById("chatSidebarBackdrop");
|
||||||
|
if (backdrop) backdrop.hidden = false;
|
||||||
|
chatSearchInput?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasCompletedOnboarding() {
|
function hasCompletedOnboarding() {
|
||||||
@@ -212,7 +244,7 @@ function markOnboardingCompleted() {
|
|||||||
|
|
||||||
function closeOnboarding() {
|
function closeOnboarding() {
|
||||||
if (!onboardingModal) return;
|
if (!onboardingModal) return;
|
||||||
onboardingModal.classList.add("hidden");
|
closeModal(onboardingModal);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderOnboardingStep() {
|
function renderOnboardingStep() {
|
||||||
@@ -231,6 +263,7 @@ function renderOnboardingStep() {
|
|||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
onboardingIntent = key;
|
onboardingIntent = key;
|
||||||
renderOnboardingStep();
|
renderOnboardingStep();
|
||||||
|
onboardingChoices.querySelector(".active")?.focus();
|
||||||
});
|
});
|
||||||
onboardingChoices.appendChild(button);
|
onboardingChoices.appendChild(button);
|
||||||
});
|
});
|
||||||
@@ -268,7 +301,7 @@ function maybeShowOnboarding() {
|
|||||||
onboardingStep = 1;
|
onboardingStep = 1;
|
||||||
onboardingIntent = "ask";
|
onboardingIntent = "ask";
|
||||||
renderOnboardingStep();
|
renderOnboardingStep();
|
||||||
onboardingModal.classList.remove("hidden");
|
openModal(onboardingModal, onboardingChoices.querySelector("button"));
|
||||||
}
|
}
|
||||||
function setMessageContent(el, content) {
|
function setMessageContent(el, content) {
|
||||||
const markdownParser = window.marked;
|
const markdownParser = window.marked;
|
||||||
@@ -280,11 +313,6 @@ function setMessageContent(el, content) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function getChatById(id) { return chatSessions.find((chat) => chat.id === id); }
|
function getChatById(id) { return chatSessions.find((chat) => chat.id === id); }
|
||||||
function summarizeText(value, limit = 96) {
|
|
||||||
const normalized = String(value || "").replace(/\s+/g, " ").trim();
|
|
||||||
if (!normalized) return "";
|
|
||||||
return normalized.length > limit ? `${normalized.slice(0, limit - 3).trimEnd()}...` : normalized;
|
|
||||||
}
|
|
||||||
function getPersonaName(id, items) {
|
function getPersonaName(id, items) {
|
||||||
return items.find((persona) => persona.id === id)?.name || "";
|
return items.find((persona) => persona.id === id)?.name || "";
|
||||||
}
|
}
|
||||||
@@ -387,15 +415,22 @@ function resizeComposerInput() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scrollMessagesToBottom() {
|
function scrollMessagesToBottom() {
|
||||||
requestAnimationFrame(() => {
|
if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
|
||||||
|
scrollFrame = requestAnimationFrame(() => {
|
||||||
|
scrollFrame = null;
|
||||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||||
|
updateJumpToLatest();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNewestMessage() {
|
function updateJumpToLatest() {
|
||||||
return currentMessages.length ? currentMessages[currentMessages.length - 1] : null;
|
const distance = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight;
|
||||||
|
jumpToLatest.classList.toggle("hidden", distance < 180);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
messagesDiv.addEventListener("scroll", updateJumpToLatest, {passive: true});
|
||||||
|
jumpToLatest.addEventListener("click", scrollMessagesToBottom);
|
||||||
|
|
||||||
function getMessageIndexById(messageId) {
|
function getMessageIndexById(messageId) {
|
||||||
return currentMessages.findIndex((message) => message.id === messageId);
|
return currentMessages.findIndex((message) => message.id === messageId);
|
||||||
}
|
}
|
||||||
@@ -463,18 +498,12 @@ function syncRetryStateFromMessage(message) {
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureRetryState(message) {
|
|
||||||
if (!message?.id) return null;
|
|
||||||
let state = messageRetryState.get(message.id);
|
|
||||||
if (!state) {
|
|
||||||
state = syncRetryStateFromMessage(message);
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openCreatedChat(chat) {
|
async function openCreatedChat(chat) {
|
||||||
if (!chat?.id) throw new Error("The server did not return the newly created chat");
|
if (!chat?.id) throw new Error("The server did not return the newly created chat");
|
||||||
if (chatSearchInput) chatSearchInput.value = "";
|
if (chatSearchInput) {
|
||||||
|
chatSearchInput.value = "";
|
||||||
|
scheduleChatSearch();
|
||||||
|
}
|
||||||
const chatsResponse = await get("/chats");
|
const chatsResponse = await get("/chats");
|
||||||
if (chatsResponse.error) throw new Error(chatsResponse.error);
|
if (chatsResponse.error) throw new Error(chatsResponse.error);
|
||||||
chatSessions = chatsResponse.chats || [];
|
chatSessions = chatsResponse.chats || [];
|
||||||
@@ -542,7 +571,7 @@ async function editChatMessage(messageId, fallbackIndex = -1) {
|
|||||||
async function deleteChatMessage(messageId, fallbackIndex = -1) {
|
async function deleteChatMessage(messageId, fallbackIndex = -1) {
|
||||||
const target = await resolveMessageTarget(messageId, fallbackIndex);
|
const target = await resolveMessageTarget(messageId, fallbackIndex);
|
||||||
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
|
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
|
||||||
const { index, message } = target;
|
const { message } = target;
|
||||||
const confirmed = await confirmPopup({
|
const confirmed = await confirmPopup({
|
||||||
eyebrow: message.role === "user" ? "Your message" : "Assistant message",
|
eyebrow: message.role === "user" ? "Your message" : "Assistant message",
|
||||||
title: "Delete message",
|
title: "Delete message",
|
||||||
@@ -702,6 +731,44 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
|
|||||||
header.textContent = isMessageUser ? "You" : (activeChat?.assistant_persona_name || "Assistant");
|
header.textContent = isMessageUser ? "You" : (activeChat?.assistant_persona_name || "Assistant");
|
||||||
setMessageContent(body, message.content);
|
setMessageContent(body, message.content);
|
||||||
|
|
||||||
|
const copyBtn = document.createElement("button");
|
||||||
|
copyBtn.type = "button";
|
||||||
|
copyBtn.className = "msg-action-btn";
|
||||||
|
copyBtn.textContent = "Copy";
|
||||||
|
copyBtn.setAttribute("aria-label", `Copy ${isMessageUser ? "your" : "assistant"} message`);
|
||||||
|
copyBtn.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(message.content || "");
|
||||||
|
setNotice("Message copied.", "success");
|
||||||
|
} catch {
|
||||||
|
setNotice("Unable to copy message.", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
actions.appendChild(copyBtn);
|
||||||
|
|
||||||
|
if (message.deliveryStatus === "failed" && isMessageUser) {
|
||||||
|
const retryFailedBtn = document.createElement("button");
|
||||||
|
retryFailedBtn.type = "button";
|
||||||
|
retryFailedBtn.className = "msg-action-btn";
|
||||||
|
retryFailedBtn.dataset.messageAction = "retry-failed";
|
||||||
|
retryFailedBtn.textContent = "Retry";
|
||||||
|
retryFailedBtn.disabled = isProcessing;
|
||||||
|
retryFailedBtn.addEventListener("click", () => { void sendMessage({ retryMessage: message }); });
|
||||||
|
actions.appendChild(retryFailedBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
const menu = document.createElement("details");
|
||||||
|
menu.className = "msg-action-menu";
|
||||||
|
const menuTrigger = document.createElement("summary");
|
||||||
|
menuTrigger.className = "msg-action-btn";
|
||||||
|
menuTrigger.textContent = "More";
|
||||||
|
menuTrigger.setAttribute("aria-label", "More message actions");
|
||||||
|
const menuItems = document.createElement("div");
|
||||||
|
menuItems.className = "msg-action-menu-items";
|
||||||
|
menuItems.addEventListener("click", () => { menu.open = false; });
|
||||||
|
menu.append(menuTrigger, menuItems);
|
||||||
|
if (message.id) actions.appendChild(menu);
|
||||||
|
|
||||||
const editBtn = document.createElement("button");
|
const editBtn = document.createElement("button");
|
||||||
editBtn.type = "button";
|
editBtn.type = "button";
|
||||||
editBtn.className = "msg-action-btn";
|
editBtn.className = "msg-action-btn";
|
||||||
@@ -710,7 +777,7 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
|
|||||||
editBtn.addEventListener("click", () => {
|
editBtn.addEventListener("click", () => {
|
||||||
void editChatMessage(message.id, options.messageIndex ?? -1);
|
void editChatMessage(message.id, options.messageIndex ?? -1);
|
||||||
});
|
});
|
||||||
actions.appendChild(editBtn);
|
menuItems.appendChild(editBtn);
|
||||||
|
|
||||||
const deleteBtn = document.createElement("button");
|
const deleteBtn = document.createElement("button");
|
||||||
deleteBtn.type = "button";
|
deleteBtn.type = "button";
|
||||||
@@ -720,14 +787,14 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
|
|||||||
deleteBtn.addEventListener("click", () => {
|
deleteBtn.addEventListener("click", () => {
|
||||||
void deleteChatMessage(message.id, options.messageIndex ?? -1);
|
void deleteChatMessage(message.id, options.messageIndex ?? -1);
|
||||||
});
|
});
|
||||||
actions.appendChild(deleteBtn);
|
menuItems.appendChild(deleteBtn);
|
||||||
|
|
||||||
const branchBtn = document.createElement("button");
|
const branchBtn = document.createElement("button");
|
||||||
branchBtn.type = "button";
|
branchBtn.type = "button";
|
||||||
branchBtn.className = "msg-action-btn";
|
branchBtn.className = "msg-action-btn";
|
||||||
branchBtn.textContent = "Branch here";
|
branchBtn.textContent = "Branch here";
|
||||||
branchBtn.addEventListener("click", () => { void branchFromMessage(message.id); });
|
branchBtn.addEventListener("click", () => { void branchFromMessage(message.id); });
|
||||||
actions.appendChild(branchBtn);
|
menuItems.appendChild(branchBtn);
|
||||||
|
|
||||||
if (message.role === "bot") {
|
if (message.role === "bot") {
|
||||||
const retriesUsed = Math.max(0, Number(message.versionCount || 1) - 1);
|
const retriesUsed = Math.max(0, Number(message.versionCount || 1) - 1);
|
||||||
@@ -781,7 +848,7 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
|
|||||||
if (!isMessageUser) appendSources(msgDiv, message.sources);
|
if (!isMessageUser) appendSources(msgDiv, message.sources);
|
||||||
if (message.deliveryStatus === "failed" && message.errorMessage) {
|
if (message.deliveryStatus === "failed" && message.errorMessage) {
|
||||||
const error = document.createElement("p");
|
const error = document.createElement("p");
|
||||||
error.className = "status";
|
error.className = "status error";
|
||||||
error.textContent = message.errorMessage;
|
error.textContent = message.errorMessage;
|
||||||
msgDiv.appendChild(error);
|
msgDiv.appendChild(error);
|
||||||
}
|
}
|
||||||
@@ -797,21 +864,46 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderMessages() {
|
function renderMessages() {
|
||||||
messagesDiv.innerHTML = "";
|
if (scrollFrame !== null) { cancelAnimationFrame(scrollFrame); scrollFrame = null; }
|
||||||
|
const sameChat = renderedChatId === activeChatId;
|
||||||
|
const wasReadingEarlier = sameChat && messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight >= 100;
|
||||||
|
const previousScrollTop = messagesDiv.scrollTop;
|
||||||
const chat = getChatById(activeChatId);
|
const chat = getChatById(activeChatId);
|
||||||
if (!currentMessages.length && (!chat || chat.assistant_persona_id)) {
|
if (!currentMessages.length && (!chat || chat.assistant_persona_id)) {
|
||||||
|
messagesDiv.replaceChildren();
|
||||||
renderMessageEmptyState();
|
renderMessageEmptyState();
|
||||||
|
renderedMessages = new Map();
|
||||||
} else {
|
} else {
|
||||||
currentMessages.forEach((msg, index) => addMessage(msg, msg.role === "user", false, {
|
const fragment = document.createDocumentFragment();
|
||||||
messageIndex: index,
|
const nextRendered = new Map();
|
||||||
isNewest: index === currentMessages.length - 1
|
currentMessages.forEach((msg, index) => {
|
||||||
}));
|
const key = msg.id ? String(msg.id) : null;
|
||||||
|
const snapshot = JSON.stringify(msg);
|
||||||
|
const previous = sameChat && key ? renderedMessages.get(key) : null;
|
||||||
|
const card = previous?.snapshot === snapshot && previous.node.isConnected
|
||||||
|
? previous.node
|
||||||
|
: addMessage(msg, msg.role === "user", false, {
|
||||||
|
messageIndex: index,
|
||||||
|
isNewest: index === currentMessages.length - 1,
|
||||||
|
detached: true
|
||||||
|
});
|
||||||
|
fragment.append(card);
|
||||||
|
if (key) nextRendered.set(key, {snapshot, node: card});
|
||||||
|
});
|
||||||
|
messagesDiv.replaceChildren(fragment);
|
||||||
|
renderedMessages = nextRendered;
|
||||||
}
|
}
|
||||||
|
renderedChatId = activeChatId;
|
||||||
renderDrawerSummary();
|
renderDrawerSummary();
|
||||||
updateChatActionState();
|
updateChatActionState();
|
||||||
updateComposerPlaceholder();
|
updateComposerPlaceholder();
|
||||||
if (currentMessages.length) {
|
if (wasReadingEarlier) {
|
||||||
|
messagesDiv.scrollTop = previousScrollTop;
|
||||||
|
updateJumpToLatest();
|
||||||
|
} else if (currentMessages.length) {
|
||||||
scrollMessagesToBottom();
|
scrollMessagesToBottom();
|
||||||
|
} else {
|
||||||
|
updateJumpToLatest();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1046,19 +1138,22 @@ function maybeShowPersonaNudge() {
|
|||||||
|
|
||||||
function renderChatList() {
|
function renderChatList() {
|
||||||
const query = chatSearchInput.value.trim().toLowerCase();
|
const query = chatSearchInput.value.trim().toLowerCase();
|
||||||
const filtered = chatSessions.filter((chat) => !query
|
const source = query ? (chatSearchResults ?? []) : chatSessions;
|
||||||
|
const filtered = source.filter((chat) => !query
|
||||||
|| chat.title.toLowerCase().includes(query)
|
|| chat.title.toLowerCase().includes(query)
|
||||||
|| String(chat.matched_content || "").toLowerCase().includes(query));
|
|| String(chat.matched_content || "").toLowerCase().includes(query));
|
||||||
chatList.innerHTML = "";
|
chatList.innerHTML = "";
|
||||||
if (!filtered.length) {
|
if (!filtered.length) {
|
||||||
const empty = document.createElement("p");
|
const empty = document.createElement("p");
|
||||||
empty.className = "status";
|
empty.className = "status";
|
||||||
empty.textContent = query ? "No chats match this search." : "No chats yet. Start a new one.";
|
empty.textContent = query && chatSearchError ? chatSearchError
|
||||||
|
: query && chatSearchResults === null ? "Searching chats..."
|
||||||
|
: query ? "No chats match this search." : "No chats yet. Start a new one.";
|
||||||
chatList.appendChild(empty);
|
chatList.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const folderGroups = filtered
|
const folderGroups = filtered
|
||||||
.filter((chat) => chat.folder_name && !chat.archived_at)
|
.filter((chat) => chat.folder_name && !chat.is_pinned && !chat.archived_at)
|
||||||
.reduce((map, chat) => {
|
.reduce((map, chat) => {
|
||||||
const key = String(chat.folder_name).trim();
|
const key = String(chat.folder_name).trim();
|
||||||
const items = map.get(key) || [];
|
const items = map.get(key) || [];
|
||||||
@@ -1083,13 +1178,13 @@ function renderChatList() {
|
|||||||
list.setAttribute("role", "list");
|
list.setAttribute("role", "list");
|
||||||
section.append(heading, list);
|
section.append(heading, list);
|
||||||
group.items.forEach((chat) => {
|
group.items.forEach((chat) => {
|
||||||
const item = document.createElement("div"), content = document.createElement("div"), title = document.createElement("span"), meta = document.createElement("small"), remove = document.createElement("button");
|
const item = document.createElement("div"), content = document.createElement("button"), title = document.createElement("span"), meta = document.createElement("small"), remove = document.createElement("button");
|
||||||
item.className = `chat-item${chat.id === activeChatId ? " active" : ""}`;
|
item.className = `chat-item${chat.id === activeChatId ? " active" : ""}`;
|
||||||
item.setAttribute("role", "button");
|
item.setAttribute("role", "listitem");
|
||||||
item.tabIndex = 0;
|
content.type = "button";
|
||||||
item.setAttribute("aria-label", `Open chat ${chat.title}`);
|
content.className = "chat-item-copy secondary-action";
|
||||||
if (chat.id === activeChatId) item.setAttribute("aria-current", "page");
|
content.setAttribute("aria-label", `Open chat ${chat.title}`);
|
||||||
content.className = "chat-item-copy";
|
if (chat.id === activeChatId) content.setAttribute("aria-current", "page");
|
||||||
title.textContent = chat.title;
|
title.textContent = chat.title;
|
||||||
meta.className = "chat-item-meta";
|
meta.className = "chat-item-meta";
|
||||||
const chatType = chat.assistant_persona_id
|
const chatType = chat.assistant_persona_id
|
||||||
@@ -1102,19 +1197,23 @@ function renderChatList() {
|
|||||||
remove.setAttribute("aria-label", `Delete chat ${chat.title}`);
|
remove.setAttribute("aria-label", `Delete chat ${chat.title}`);
|
||||||
remove.addEventListener("click", async (event) => {
|
remove.addEventListener("click", async (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
closeMobileSidebar();
|
||||||
await deleteChat(chat.id);
|
await deleteChat(chat.id);
|
||||||
});
|
});
|
||||||
content.append(title, meta);
|
content.append(title, meta);
|
||||||
|
if (query && chat.matched_content) {
|
||||||
|
const excerpt = document.createElement("small");
|
||||||
|
const normalized = chat.matched_content.replace(/\s+/g, " ");
|
||||||
|
const matchAt = normalized.toLowerCase().indexOf(query);
|
||||||
|
const start = Math.max(0, matchAt - 35);
|
||||||
|
excerpt.className = "chat-item-excerpt";
|
||||||
|
excerpt.textContent = `${start ? "…" : ""}${normalized.slice(start, start + 110)}${normalized.length > start + 110 ? "…" : ""}`;
|
||||||
|
content.append(excerpt);
|
||||||
|
}
|
||||||
item.append(content, remove);
|
item.append(content, remove);
|
||||||
item.addEventListener("click", () => {
|
content.addEventListener("click", () => {
|
||||||
closeMobileSidebar();
|
closeMobileSidebar();
|
||||||
setActiveChat(chat.id);
|
void setActiveChat(chat.id, {matchMessageId: query ? chat.matched_message_id : null});
|
||||||
});
|
|
||||||
item.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key !== "Enter" && event.key !== " ") return;
|
|
||||||
event.preventDefault();
|
|
||||||
closeMobileSidebar();
|
|
||||||
void setActiveChat(chat.id);
|
|
||||||
});
|
});
|
||||||
list.appendChild(item);
|
list.appendChild(item);
|
||||||
});
|
});
|
||||||
@@ -1131,7 +1230,7 @@ function updateActivePersonaStatus(items, activeId, statusEl, emptyText, prefix)
|
|||||||
function openPersonaForm(persona = null, personaType = "assistant") {
|
function openPersonaForm(persona = null, personaType = "assistant") {
|
||||||
closeChatDrawer();
|
closeChatDrawer();
|
||||||
closePopup(false);
|
closePopup(false);
|
||||||
personaModal.classList.remove("hidden");
|
openModal(personaModal, personaNameInput);
|
||||||
if (persona) {
|
if (persona) {
|
||||||
editingPersonaId = persona.id; editingPersonaType = persona.persona_type || "assistant";
|
editingPersonaId = persona.id; editingPersonaType = persona.persona_type || "assistant";
|
||||||
personaFormTitle.textContent = `Edit ${editingPersonaType === "user" ? "your persona" : "AI Character"}`;
|
personaFormTitle.textContent = `Edit ${editingPersonaType === "user" ? "your persona" : "AI Character"}`;
|
||||||
@@ -1151,7 +1250,7 @@ function openPersonaForm(persona = null, personaType = "assistant") {
|
|||||||
personaNameInput.focus();
|
personaNameInput.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePersonaForm() { personaModal.classList.add("hidden"); editingPersonaId = null; editingPersonaType = "assistant"; personaTypeSelect.disabled = false; setPersonaFormNotice(""); }
|
function closePersonaForm() { closeModal(personaModal); editingPersonaId = null; editingPersonaType = "assistant"; personaTypeSelect.disabled = false; setPersonaFormNotice(""); }
|
||||||
function populateRoleplayStarter() {
|
function populateRoleplayStarter() {
|
||||||
if (!roleplayCharacterSelect || !roleplayUserPersonaSelect) return;
|
if (!roleplayCharacterSelect || !roleplayUserPersonaSelect) return;
|
||||||
roleplayCharacterSelect.innerHTML = "";
|
roleplayCharacterSelect.innerHTML = "";
|
||||||
@@ -1200,12 +1299,11 @@ function openRoleplayStarter(characterId = null) {
|
|||||||
populateRoleplayStarter();
|
populateRoleplayStarter();
|
||||||
if (roleplayScenarioInput) roleplayScenarioInput.value = "";
|
if (roleplayScenarioInput) roleplayScenarioInput.value = "";
|
||||||
setRoleplayStarterNotice("");
|
setRoleplayStarterNotice("");
|
||||||
roleplayStarterModal.classList.remove("hidden");
|
openModal(roleplayStarterModal, roleplayCharacterSelect);
|
||||||
if (roleplayCharacterSelect.options.length) roleplayCharacterSelect.focus();
|
|
||||||
}
|
}
|
||||||
function closeRoleplayStarter() {
|
function closeRoleplayStarter() {
|
||||||
if (!roleplayStarterModal) return;
|
if (!roleplayStarterModal) return;
|
||||||
roleplayStarterModal.classList.add("hidden");
|
closeModal(roleplayStarterModal);
|
||||||
roleplayPresetCharacterId = null;
|
roleplayPresetCharacterId = null;
|
||||||
roleplayStarterStep = 1;
|
roleplayStarterStep = 1;
|
||||||
roleplayStarterConfirm.disabled = false;
|
roleplayStarterConfirm.disabled = false;
|
||||||
@@ -1234,7 +1332,7 @@ function finishPopup(result) {
|
|||||||
}
|
}
|
||||||
function closePopup(resolveValue = null) {
|
function closePopup(resolveValue = null) {
|
||||||
if (!popupModal || popupModal.classList.contains("hidden")) return;
|
if (!popupModal || popupModal.classList.contains("hidden")) return;
|
||||||
popupModal.classList.add("hidden");
|
closeModal(popupModal);
|
||||||
popupField.classList.add("hidden");
|
popupField.classList.add("hidden");
|
||||||
popupInput.value = "";
|
popupInput.value = "";
|
||||||
popupConfirmBtn.disabled = false;
|
popupConfirmBtn.disabled = false;
|
||||||
@@ -1262,7 +1360,7 @@ function openPopup(options) {
|
|||||||
} else {
|
} else {
|
||||||
popupInput.value = "";
|
popupInput.value = "";
|
||||||
}
|
}
|
||||||
popupModal.classList.remove("hidden");
|
openModal(popupModal, options.mode === "prompt" ? popupInput : popupConfirmBtn);
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
popupResolver = resolve;
|
popupResolver = resolve;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -1405,6 +1503,9 @@ function setLoadingState(loading, overlayOptions = null) {
|
|||||||
messagesDiv.querySelectorAll('[data-message-action="regenerate"]').forEach((button) => {
|
messagesDiv.querySelectorAll('[data-message-action="regenerate"]').forEach((button) => {
|
||||||
button.disabled = loading || button.dataset.retryLimitReached === "true";
|
button.disabled = loading || button.dataset.retryLimitReached === "true";
|
||||||
});
|
});
|
||||||
|
messagesDiv.querySelectorAll('[data-message-action="retry-failed"]').forEach((button) => {
|
||||||
|
button.disabled = loading;
|
||||||
|
});
|
||||||
if (loading) {
|
if (loading) {
|
||||||
msgInput.placeholder = "Processing response...";
|
msgInput.placeholder = "Processing response...";
|
||||||
if (overlayOptions === false) {
|
if (overlayOptions === false) {
|
||||||
@@ -1427,8 +1528,21 @@ async function stopGeneration() {
|
|||||||
setNotice(res.stopped ? "Stopping generation..." : "Generation already finished.");
|
setNotice(res.stopped ? "Stopping generation..." : "Generation already finished.");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setActiveChat(id, {preserveComposer = false} = {}) {
|
async function setActiveChat(id, {preserveComposer = false, matchMessageId = null} = {}) {
|
||||||
if (!preserveComposer && id !== activeChatId) attachmentComposer.discard();
|
const openRequest = ++chatOpenRequest;
|
||||||
|
if (!preserveComposer && id !== activeChatId) {
|
||||||
|
if (activeChatId != null) {
|
||||||
|
chatDrafts.set(activeChatId, msgInput.value);
|
||||||
|
attachmentComposer.saveDraft(activeChatId);
|
||||||
|
msgInput.value = chatDrafts.get(id) || "";
|
||||||
|
attachmentComposer.restoreDraft(id);
|
||||||
|
resizeComposerInput();
|
||||||
|
} else if (!msgInput.value && !attachmentComposer.pending && !attachmentComposer.attachments.length) {
|
||||||
|
msgInput.value = chatDrafts.get(id) || "";
|
||||||
|
attachmentComposer.restoreDraft(id);
|
||||||
|
resizeComposerInput();
|
||||||
|
}
|
||||||
|
}
|
||||||
if (isMobileLayout()) closeMobileSidebar();
|
if (isMobileLayout()) closeMobileSidebar();
|
||||||
setChatLoading(true, "Opening chat...");
|
setChatLoading(true, "Opening chat...");
|
||||||
setChatActivity(true, {
|
setChatActivity(true, {
|
||||||
@@ -1440,16 +1554,27 @@ async function setActiveChat(id, {preserveComposer = false} = {}) {
|
|||||||
history.replaceState(null, "", `/app/chats/${id}`);
|
history.replaceState(null, "", `/app/chats/${id}`);
|
||||||
messageRetryState = new Map();
|
messageRetryState = new Map();
|
||||||
updateWorkspaceCopy(); renderChatList(); updateChatActionState();
|
updateWorkspaceCopy(); renderChatList(); updateChatActionState();
|
||||||
const res = await get(`/chats/${id}/messages`);
|
let res = await get(`/chats/${id}/messages`);
|
||||||
|
if (openRequest !== chatOpenRequest) { setChatLoading(false); setChatActivity(false); return; }
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
setChatLoading(false);
|
setChatLoading(false);
|
||||||
setChatActivity(false);
|
setChatActivity(false);
|
||||||
return setNotice(res.error, "error");
|
return setNotice(res.error, "error");
|
||||||
}
|
}
|
||||||
|
if (matchMessageId && !res.messages?.some(message => message.id === matchMessageId)) {
|
||||||
|
const activated = await post(`/chats/${id}/branches/${matchMessageId}/activate`, {includeDescendants: true});
|
||||||
|
if (!activated.error) res = await get(`/chats/${id}/messages`);
|
||||||
|
if (openRequest !== chatOpenRequest) { setChatLoading(false); setChatActivity(false); return; }
|
||||||
|
}
|
||||||
const existing = getChatById(id);
|
const existing = getChatById(id);
|
||||||
if (res.chat && existing) Object.assign(existing, res.chat);
|
if (res.chat && existing) Object.assign(existing, res.chat);
|
||||||
populateGenerationSettings(res.chat || existing);
|
populateGenerationSettings(res.chat || existing);
|
||||||
currentMessages = res.messages || []; renderMessages();
|
currentMessages = res.messages || []; renderMessages();
|
||||||
|
if (matchMessageId) requestAnimationFrame(() => {
|
||||||
|
const target = [...messagesDiv.querySelectorAll("[data-message-id]")]
|
||||||
|
.find(node => Number(node.dataset.messageId) === Number(matchMessageId));
|
||||||
|
if (target) { target.scrollIntoView({block: "center"}); updateJumpToLatest(); }
|
||||||
|
});
|
||||||
updateSendState();
|
updateSendState();
|
||||||
setChatLoading(false);
|
setChatLoading(false);
|
||||||
setChatActivity(false);
|
setChatActivity(false);
|
||||||
@@ -1457,13 +1582,17 @@ async function setActiveChat(id, {preserveComposer = false} = {}) {
|
|||||||
|
|
||||||
async function loadChatSessions() {
|
async function loadChatSessions() {
|
||||||
setChatLoading(true, "Loading chats...");
|
setChatLoading(true, "Loading chats...");
|
||||||
const query = chatSearchInput?.value.trim();
|
const res = await get("/chats");
|
||||||
const res = query ? await get(`/chats/search?q=${encodeURIComponent(query)}`) : await get("/chats");
|
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
setChatLoading(false);
|
setChatLoading(false);
|
||||||
return setNotice(res.error, "error");
|
return setNotice(res.error, "error");
|
||||||
}
|
}
|
||||||
chatSessions = res.chats || []; renderChatList();
|
chatSessions = res.chats || []; renderChatList();
|
||||||
|
if (activeChatId && getChatById(activeChatId)) {
|
||||||
|
updateWorkspaceCopy();
|
||||||
|
setChatLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (location.pathname.endsWith("/new") && !activeChatId) { currentMessages = []; renderMessages(); updateWorkspaceCopy(); updateSendState(); setChatLoading(false); return; }
|
if (location.pathname.endsWith("/new") && !activeChatId) { currentMessages = []; renderMessages(); updateWorkspaceCopy(); updateSendState(); setChatLoading(false); return; }
|
||||||
if (requestedChatId && !getChatById(requestedChatId) && !activeChatId) { setChatLoading(false); return setNotice("Conversation not found. Choose a chat from the sidebar or start a new one.", "error"); }
|
if (requestedChatId && !getChatById(requestedChatId) && !activeChatId) { setChatLoading(false); return setNotice("Conversation not found. Choose a chat from the sidebar or start a new one.", "error"); }
|
||||||
if (chatSessions.length) {
|
if (chatSessions.length) {
|
||||||
@@ -1480,8 +1609,34 @@ async function loadChatSessions() {
|
|||||||
return setNotice("Pick a starting action to begin.", "success");
|
return setNotice("Pick a starting action to begin.", "success");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function searchChatSessions(query, requestId) {
|
||||||
|
const res = await get(`/chats/search?q=${encodeURIComponent(query)}`);
|
||||||
|
if (requestId !== chatSearchRequest || chatSearchInput.value.trim() !== query) return;
|
||||||
|
if (res.error) {
|
||||||
|
chatSearchError = "Unable to search chats.";
|
||||||
|
renderChatList();
|
||||||
|
setNotice(res.error, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chatSearchResults = res.chats || [];
|
||||||
|
renderChatList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleChatSearch() {
|
||||||
|
clearTimeout(chatSearchTimer);
|
||||||
|
const requestId = ++chatSearchRequest;
|
||||||
|
const query = chatSearchInput.value.trim();
|
||||||
|
chatSearchResults = null;
|
||||||
|
chatSearchError = "";
|
||||||
|
if (!query) {
|
||||||
|
renderChatList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderChatList();
|
||||||
|
chatSearchTimer = setTimeout(() => { void searchChatSessions(query, requestId); }, 300);
|
||||||
|
}
|
||||||
|
|
||||||
async function createNewChat({preserveComposer = false} = {}) {
|
async function createNewChat({preserveComposer = false} = {}) {
|
||||||
if (!preserveComposer) attachmentComposer.discard();
|
|
||||||
setChatLoading(true, "Creating chat...");
|
setChatLoading(true, "Creating chat...");
|
||||||
setChatActivity(true, {
|
setChatActivity(true, {
|
||||||
eyebrow: "Creating chat",
|
eyebrow: "Creating chat",
|
||||||
@@ -1629,18 +1784,18 @@ function exportCurrentChat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function backupWorkspace() {
|
async function backupWorkspace() {
|
||||||
const response = await fetch("/exports/workspace");
|
const response = await fetch("/exports/workspace/archive");
|
||||||
const data = await response.json();
|
if (!response.ok) return setNotice("Workspace archive could not be created.", "error");
|
||||||
const blob = new Blob([JSON.stringify(data.workspace, null, 2)], { type: "application/json;charset=utf-8" });
|
const blob = await response.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = `workspace-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
link.download = `workspace-backup-${new Date().toISOString().slice(0, 10)}.tar.gz`;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
setNotice("Workspace backup downloaded.", "success");
|
setNotice("Workspace archive downloaded with images.", "success");
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatImportPreview(preview, warnings = []) {
|
function formatImportPreview(preview, warnings = []) {
|
||||||
@@ -1657,6 +1812,31 @@ function formatImportPreview(preview, warnings = []) {
|
|||||||
async function importWorkspaceFromFile(file) {
|
async function importWorkspaceFromFile(file) {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setChatLoading(true, "Reading workspace backup...");
|
setChatLoading(true, "Reading workspace backup...");
|
||||||
|
const isArchive = file.name.toLowerCase().endsWith(".tar.gz");
|
||||||
|
if (isArchive) {
|
||||||
|
const submit = async (path, allowDuplicates = false) => {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("archive", file);
|
||||||
|
if (allowDuplicates) body.append("allowDuplicates", "true");
|
||||||
|
const response = await fetch(path, {method: "POST", body});
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const preview = await submit("/imports/workspace/archive/preview");
|
||||||
|
setChatLoading(false);
|
||||||
|
if (preview.error || preview.ok === false) return setNotice((preview.errors || []).slice(0, 4).join("; ") || preview.error || "Invalid archive.", "error");
|
||||||
|
const confirmed = await confirmPopup({eyebrow: "Workspace import", title: "Review import",
|
||||||
|
description: formatImportPreview(preview.preview || {}, preview.warnings || []), confirmLabel: "Import"});
|
||||||
|
if (!confirmed) return;
|
||||||
|
setChatLoading(true, "Importing workspace and images...");
|
||||||
|
const result = await submit("/imports/workspace/archive", Boolean(preview.preview?.duplicates?.length));
|
||||||
|
if (result.error) return setNotice(result.error, "error");
|
||||||
|
await Promise.all([loadPersonas(), loadChatSessions()]);
|
||||||
|
setNotice(`Workspace imported: ${result.imported.chats} chats with their images.`, "success");
|
||||||
|
} catch { setNotice("Workspace archive could not be imported.", "error"); }
|
||||||
|
finally { setChatLoading(false); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
let workspace;
|
let workspace;
|
||||||
try {
|
try {
|
||||||
workspace = JSON.parse(await file.text());
|
workspace = JSON.parse(await file.text());
|
||||||
@@ -1801,7 +1981,9 @@ async function deleteChat(id) {
|
|||||||
return setNotice(res.error, "error");
|
return setNotice(res.error, "error");
|
||||||
}
|
}
|
||||||
chatSessions = chatSessions.filter((item) => item.id !== id);
|
chatSessions = chatSessions.filter((item) => item.id !== id);
|
||||||
if (activeChatId === id) { activeChatId = null; currentMessages = []; renderMessages(); }
|
chatDrafts.delete(id);
|
||||||
|
attachmentComposer.discardDraft(id);
|
||||||
|
if (activeChatId === id) { attachmentComposer.discard(); msgInput.value = ""; activeChatId = null; currentMessages = []; renderMessages(); }
|
||||||
messageRetryState = new Map();
|
messageRetryState = new Map();
|
||||||
await loadSummary(); renderChatList();
|
await loadSummary(); renderChatList();
|
||||||
if (chatSessions.length) await setActiveChat(chatSessions[0].id); else await createNewChat();
|
if (chatSessions.length) await setActiveChat(chatSessions[0].id); else await createNewChat();
|
||||||
@@ -1846,15 +2028,21 @@ async function checkSession() {
|
|||||||
// Starters are offered on workspace home.
|
// Starters are offered on workspace home.
|
||||||
const starter = new URLSearchParams(location.search).get("prompt");
|
const starter = new URLSearchParams(location.search).get("prompt");
|
||||||
if (starter) { msgInput.value = starter; resizeComposerInput(); updateSendState(); }
|
if (starter) { msgInput.value = starter; resizeComposerInput(); updateSendState(); }
|
||||||
msgInput.focus(); return true;
|
msgInput.focus();
|
||||||
|
if (!starter && !activeChatId) maybeShowOnboarding();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage({ retryMessage = null } = {}) {
|
||||||
if (isProcessing) return;
|
if (isProcessing) return;
|
||||||
if (attachmentComposer.pending) return;
|
if (attachmentComposer.pending) return;
|
||||||
const attachments = attachmentComposer.attachments;
|
const attachments = retryMessage?.attachments || attachmentComposer.attachments;
|
||||||
const message = msgInput.value.trim() || (attachments.length ? "Describe the attached images." : "");
|
const message = retryMessage?.content || msgInput.value.trim() || (attachments.length ? "Describe the attached images." : "");
|
||||||
if (!message) return;
|
if (!message) return;
|
||||||
|
if (retryMessage && !msgInput.value.trim()) {
|
||||||
|
msgInput.value = message;
|
||||||
|
resizeComposerInput();
|
||||||
|
}
|
||||||
const webSearch = webSearchToggle.enabled;
|
const webSearch = webSearchToggle.enabled;
|
||||||
const reasoning = reasoningToggle.enabled;
|
const reasoning = reasoningToggle.enabled;
|
||||||
if (!activeChatId) await createNewChat({preserveComposer: true});
|
if (!activeChatId) await createNewChat({preserveComposer: true});
|
||||||
@@ -1865,25 +2053,31 @@ async function sendMessage() {
|
|||||||
currentChat.title = autoTitle; updateWorkspaceCopy(); renderChatList(); await put(`/chats/${activeChatId}`, { title: autoTitle });
|
currentChat.title = autoTitle; updateWorkspaceCopy(); renderChatList(); await put(`/chats/${activeChatId}`, { title: autoTitle });
|
||||||
}
|
}
|
||||||
const userMessage = { role: "user", content: message, attachments };
|
const userMessage = { role: "user", content: message, attachments };
|
||||||
currentMessages.push(userMessage); addMessage(userMessage, true); updateChatActionState();
|
currentMessages.push(userMessage);
|
||||||
msgInput.value = "";
|
const userCard = addMessage(userMessage, true);
|
||||||
resizeComposerInput();
|
updateChatActionState();
|
||||||
setLoadingState(true, false); setNotice("Generating reply...");
|
setLoadingState(true, false); setNotice("Generating reply...");
|
||||||
const loadingMsg = addMessage({ role: "bot", content: "" }, false, true, { isNewest: true });
|
const loadingMsg = addMessage({ role: "bot", content: "" }, false, true, { isNewest: true });
|
||||||
const loadingBody = loadingMsg.querySelector(".msg-content");
|
const loadingBody = loadingMsg.querySelector(".msg-content");
|
||||||
const loadingShell = loadingMsg.querySelector(".loading-shell");
|
const loadingShell = loadingMsg.querySelector(".loading-shell");
|
||||||
const loadingLabel = loadingMsg.querySelector(".loading-copy strong");
|
const loadingLabel = loadingMsg.querySelector(".loading-copy strong");
|
||||||
loadingLabel.textContent = "Preparing response...";
|
loadingLabel.textContent = "Preparing response...";
|
||||||
|
const showSendFailure = (errorMessage) => {
|
||||||
|
if (!msgInput.value.trim()) {
|
||||||
|
msgInput.value = message;
|
||||||
|
resizeComposerInput();
|
||||||
|
}
|
||||||
|
userMessage.deliveryStatus = "failed";
|
||||||
|
userMessage.errorMessage = errorMessage;
|
||||||
|
userCard.replaceWith(addMessage(userMessage, true, false, { detached: true }));
|
||||||
|
setNotice(errorMessage, "error");
|
||||||
|
};
|
||||||
|
let completedReply = null;
|
||||||
try {
|
try {
|
||||||
let streamError = null;
|
let streamError = null;
|
||||||
await stream("/chat/stream", { message, attachments: attachments.map(item => item.id), model: modelSelect.value, chatId: activeChatId, webSearch, reasoning }, {
|
await stream("/chat/stream", { message, attachments: attachments.map(item => item.id), model: modelSelect.value, chatId: activeChatId, webSearch, reasoning }, {
|
||||||
onStatus: ({ status, sourceCount, searchState }) => {
|
onStatus: ({ status, sourceCount, searchState }) => {
|
||||||
if (status === "accepted") {
|
if (status === "accepted") return;
|
||||||
attachmentComposer.clear();
|
|
||||||
webSearchToggle.reset();
|
|
||||||
reasoningToggle.reset();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loadingLabel.textContent = status === "analyzing" ? "Analyzing images..." : status === "searching" ? "Searching the web..."
|
loadingLabel.textContent = status === "analyzing" ? "Analyzing images..." : status === "searching" ? "Searching the web..."
|
||||||
: searchState === "no_relevant_results" ? "No sufficiently relevant results. Generating response..."
|
: searchState === "no_relevant_results" ? "No sufficiently relevant results. Generating response..."
|
||||||
: sourceCount === 0 ? "No web results found. Generating response..." : "Generating response...";
|
: sourceCount === 0 ? "No web results found. Generating response..." : "Generating response...";
|
||||||
@@ -1906,7 +2100,10 @@ async function sendMessage() {
|
|||||||
}
|
}
|
||||||
if (follow) scrollMessagesToBottom();
|
if (follow) scrollMessagesToBottom();
|
||||||
},
|
},
|
||||||
onDone: () => { webSearchToggle.reset(); reasoningToggle.reset(); finishReasoning(loadingMsg); },
|
onDone: ({ reply }) => {
|
||||||
|
completedReply = reply;
|
||||||
|
webSearchToggle.reset(); reasoningToggle.reset(); finishReasoning(loadingMsg);
|
||||||
|
},
|
||||||
onError: ({ error }) => {
|
onError: ({ error }) => {
|
||||||
streamError = error || "Failed to generate reply.";
|
streamError = error || "Failed to generate reply.";
|
||||||
}
|
}
|
||||||
@@ -1915,14 +2112,16 @@ async function sendMessage() {
|
|||||||
const previousScrollTop = messagesDiv.scrollTop;
|
const previousScrollTop = messagesDiv.scrollTop;
|
||||||
if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg);
|
if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg);
|
||||||
if (streamError) {
|
if (streamError) {
|
||||||
if (webSearch) {
|
showSendFailure(streamError);
|
||||||
msgInput.value = message;
|
|
||||||
resizeComposerInput();
|
|
||||||
}
|
|
||||||
addMessage(`[Error: ${streamError}]`);
|
|
||||||
setNotice(streamError, "error");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (msgInput.value.trim() === message) {
|
||||||
|
msgInput.value = "";
|
||||||
|
resizeComposerInput();
|
||||||
|
}
|
||||||
|
const composerAttachments = attachmentComposer.attachments;
|
||||||
|
if (composerAttachments.length === attachments.length
|
||||||
|
&& composerAttachments.every((item, index) => item.id === attachments[index].id)) attachmentComposer.clear();
|
||||||
await setActiveChat(activeChatId);
|
await setActiveChat(activeChatId);
|
||||||
await loadSummary();
|
await loadSummary();
|
||||||
markOnboardingCompleted();
|
markOnboardingCompleted();
|
||||||
@@ -1931,12 +2130,16 @@ async function sendMessage() {
|
|||||||
setNotice("Reply received.", "success");
|
setNotice("Reply received.", "success");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg);
|
if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg);
|
||||||
if (webSearch) {
|
|
||||||
msgInput.value = message;
|
|
||||||
resizeComposerInput();
|
|
||||||
}
|
|
||||||
const errorMessage = error?.message || "Network error while sending message.";
|
const errorMessage = error?.message || "Network error while sending message.";
|
||||||
addMessage(`[Error: ${errorMessage}]`); setNotice(errorMessage, "error");
|
if (completedReply !== null) {
|
||||||
|
if (userCard.isConnected) {
|
||||||
|
currentMessages.push({ role: "bot", content: completedReply });
|
||||||
|
addMessage(completedReply);
|
||||||
|
}
|
||||||
|
setNotice(`Reply sent, but the conversation could not be refreshed: ${errorMessage}`, "error");
|
||||||
|
} else {
|
||||||
|
showSendFailure(errorMessage);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingState(false); msgInput.focus(); updateChatActionState();
|
setLoadingState(false); msgInput.focus(); updateChatActionState();
|
||||||
}
|
}
|
||||||
@@ -1993,7 +2196,7 @@ function bindChatDrawerActions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sendBtn.addEventListener("click", () => { void (isProcessing ? stopGeneration() : sendMessage()); });
|
sendBtn.addEventListener("click", () => { void (isProcessing ? stopGeneration() : sendMessage()); });
|
||||||
msgInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); void sendMessage(); } });
|
msgInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); void sendMessage(); } });
|
||||||
msgInput.addEventListener("input", function () {
|
msgInput.addEventListener("input", function () {
|
||||||
resizeComposerInput();
|
resizeComposerInput();
|
||||||
updateSendState();
|
updateSendState();
|
||||||
@@ -2016,7 +2219,7 @@ if (importWorkspaceBtn) importWorkspaceBtn.addEventListener("click", () => {
|
|||||||
if (importWorkspaceFileInput) importWorkspaceFileInput.addEventListener("change", () => {
|
if (importWorkspaceFileInput) importWorkspaceFileInput.addEventListener("change", () => {
|
||||||
void importWorkspaceFromFile(importWorkspaceFileInput.files?.[0]);
|
void importWorkspaceFromFile(importWorkspaceFileInput.files?.[0]);
|
||||||
});
|
});
|
||||||
chatSearchInput.addEventListener("input", () => { void loadChatSessions(); });
|
chatSearchInput.addEventListener("input", scheduleChatSearch);
|
||||||
modelSelect.addEventListener("change", () => {
|
modelSelect.addEventListener("change", () => {
|
||||||
renderModelSection();
|
renderModelSection();
|
||||||
void saveSelectedModel();
|
void saveSelectedModel();
|
||||||
@@ -2026,7 +2229,7 @@ if (headerModelButton) headerModelButton.addEventListener("click", (event) => {
|
|||||||
openModelSettings();
|
openModelSettings();
|
||||||
});
|
});
|
||||||
saveGenerationSettingsBtn.addEventListener("click", () => { void saveGenerationSettings(); });
|
saveGenerationSettingsBtn.addEventListener("click", () => { void saveGenerationSettings(); });
|
||||||
roleplayNewPersonaBtn.addEventListener("click", () => openPersonaForm(null, "assistant"));
|
roleplayNewPersonaBtn.addEventListener("click", () => { closeRoleplayStarter(); openPersonaForm(null, "assistant"); });
|
||||||
clearUserPersonaBtn.addEventListener("click", () => {
|
clearUserPersonaBtn.addEventListener("click", () => {
|
||||||
closeChatDrawer();
|
closeChatDrawer();
|
||||||
void clearPersona("user");
|
void clearPersona("user");
|
||||||
@@ -2069,9 +2272,11 @@ popupInput.addEventListener("keydown", (event) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
bindChatDrawerActions();
|
bindChatDrawerActions();
|
||||||
|
if (chatSidebar) chatSidebar.inert = isMobileLayout();
|
||||||
if (chatSidebar) {
|
if (chatSidebar) {
|
||||||
chatSidebar.addEventListener("click", (event) => event.stopPropagation());
|
chatSidebar.addEventListener("click", (event) => event.stopPropagation());
|
||||||
}
|
}
|
||||||
|
document.getElementById("chatSidebarBackdrop")?.addEventListener("click", closeMobileSidebar);
|
||||||
if (chatSidebarToggle) {
|
if (chatSidebarToggle) {
|
||||||
chatSidebarToggle.addEventListener("click", (event) => {
|
chatSidebarToggle.addEventListener("click", (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -2086,12 +2291,14 @@ if (onboardingContinue) {
|
|||||||
onboardingContinue.addEventListener("click", () => {
|
onboardingContinue.addEventListener("click", () => {
|
||||||
onboardingStep = 2;
|
onboardingStep = 2;
|
||||||
renderOnboardingStep();
|
renderOnboardingStep();
|
||||||
|
onboardingChoices.querySelector("button")?.focus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (onboardingBack) {
|
if (onboardingBack) {
|
||||||
onboardingBack.addEventListener("click", () => {
|
onboardingBack.addEventListener("click", () => {
|
||||||
onboardingStep = 1;
|
onboardingStep = 1;
|
||||||
renderOnboardingStep();
|
renderOnboardingStep();
|
||||||
|
(onboardingChoices.querySelector(".active") || onboardingChoices.querySelector("button"))?.focus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (onboardingSkip) {
|
if (onboardingSkip) {
|
||||||
@@ -2102,14 +2309,42 @@ if (onboardingModal) {
|
|||||||
if (event.target === onboardingModal) markOnboardingCompleted();
|
if (event.target === onboardingModal) markOnboardingCompleted();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
document.addEventListener("click", () => {
|
document.addEventListener("click", (event) => {
|
||||||
|
if (!event.target.closest(".msg-action-menu")) {
|
||||||
|
document.querySelectorAll(".msg-action-menu[open]").forEach((menu) => { menu.open = false; });
|
||||||
|
}
|
||||||
closeMobileSidebar();
|
closeMobileSidebar();
|
||||||
});
|
});
|
||||||
window.addEventListener("resize", () => {
|
window.addEventListener("resize", () => {
|
||||||
if (!isMobileLayout()) closeMobileSidebar();
|
if (!isMobileLayout()) closeMobileSidebar();
|
||||||
|
if (chatSidebar) chatSidebar.inert = isMobileLayout() && !document.body.classList.contains("sidebar-open");
|
||||||
});
|
});
|
||||||
document.addEventListener("keydown", (event) => {
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Tab" && isMobileLayout() && document.body.classList.contains("sidebar-open")) {
|
||||||
|
const focusables = [...chatSidebar.querySelectorAll('button:not([disabled]), input:not([disabled]), [tabindex="0"]')]
|
||||||
|
.filter((element) => !element.hidden && !element.closest("[hidden]"));
|
||||||
|
const first = focusables[0], last = focusables.at(-1);
|
||||||
|
if (event.shiftKey && (document.activeElement === first || !chatSidebar.contains(document.activeElement))) {
|
||||||
|
event.preventDefault(); last?.focus();
|
||||||
|
} else if (!event.shiftKey && (document.activeElement === last || !chatSidebar.contains(document.activeElement))) {
|
||||||
|
event.preventDefault(); first?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
|
const activeModal = topModal();
|
||||||
|
if (activeModal) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (activeModal === popupModal) closePopup(false);
|
||||||
|
else if (activeModal === personaModal) closePersonaForm();
|
||||||
|
else if (activeModal === roleplayStarterModal) closeRoleplayStarter();
|
||||||
|
else if (activeModal === onboardingModal) closeOnboarding();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const openMenu = document.querySelector(".msg-action-menu[open]");
|
||||||
|
if (openMenu) {
|
||||||
|
openMenu.open = false;
|
||||||
|
openMenu.querySelector("summary")?.focus();
|
||||||
|
}
|
||||||
closeMobileSidebar();
|
closeMobileSidebar();
|
||||||
closeRoleplayStarter();
|
closeRoleplayStarter();
|
||||||
closePersonaForm();
|
closePersonaForm();
|
||||||
|
|||||||
+19
-9
@@ -1,4 +1,7 @@
|
|||||||
import { del, get, post, put } from "./api.js?v=20260909-account-controls";
|
/**
|
||||||
|
* @file Steuert die Einstellungsoberfläche für Darstellung, Konto, Personas und Diagnosen.
|
||||||
|
*/
|
||||||
|
import { del, get, post, put, request } from "./api.js?v=20260909-account-controls";
|
||||||
import {
|
import {
|
||||||
applyTheme as applySharedTheme,
|
applyTheme as applySharedTheme,
|
||||||
applyThemeMode as applySharedThemeMode,
|
applyThemeMode as applySharedThemeMode,
|
||||||
@@ -6,6 +9,7 @@ import {
|
|||||||
} from "./themeController.js?v=20260909-account-controls";
|
} from "./themeController.js?v=20260909-account-controls";
|
||||||
import { clearStatus, setStatus } from "./statusNotice.js?v=20260909-account-controls";
|
import { clearStatus, setStatus } from "./statusNotice.js?v=20260909-account-controls";
|
||||||
import { populatePersonaForm, readPersonaForm } from "./personaForm.js?v=20260909-account-controls";
|
import { populatePersonaForm, readPersonaForm } from "./personaForm.js?v=20260909-account-controls";
|
||||||
|
import { openModal, closeModal, topModal } from "./modalFocus.js";
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const settingsNotice = $("settingsNotice");
|
const settingsNotice = $("settingsNotice");
|
||||||
@@ -62,7 +66,6 @@ const settingsPopupConfirm = $("settingsPopupConfirm");
|
|||||||
const settingsPopupTitle = $("settingsPopupTitle");
|
const settingsPopupTitle = $("settingsPopupTitle");
|
||||||
const settingsPopupEyebrow = $("settingsPopupEyebrow");
|
const settingsPopupEyebrow = $("settingsPopupEyebrow");
|
||||||
const settingsPopupDescription = $("settingsPopupDescription");
|
const settingsPopupDescription = $("settingsPopupDescription");
|
||||||
const settingsPopupField = $("settingsPopupField");
|
|
||||||
const settingsPopupInputLabel = $("settingsPopupInputLabel");
|
const settingsPopupInputLabel = $("settingsPopupInputLabel");
|
||||||
const settingsPopupInput = $("settingsPopupInput");
|
const settingsPopupInput = $("settingsPopupInput");
|
||||||
const settingsPersonaFields = {
|
const settingsPersonaFields = {
|
||||||
@@ -94,7 +97,7 @@ function setPersonaNotice(message = "", state = "") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeSettingsPopup(value = null) {
|
function closeSettingsPopup(value = null) {
|
||||||
settingsPopupModal.classList.add("hidden");
|
closeModal(settingsPopupModal);
|
||||||
if (settingsPopupResolver) {
|
if (settingsPopupResolver) {
|
||||||
const resolve = settingsPopupResolver;
|
const resolve = settingsPopupResolver;
|
||||||
settingsPopupResolver = null;
|
settingsPopupResolver = null;
|
||||||
@@ -118,7 +121,7 @@ function promptSettingsPopup({
|
|||||||
settingsPopupInput.value = value;
|
settingsPopupInput.value = value;
|
||||||
settingsPopupInput.placeholder = placeholder;
|
settingsPopupInput.placeholder = placeholder;
|
||||||
settingsPopupConfirm.textContent = confirmLabel;
|
settingsPopupConfirm.textContent = confirmLabel;
|
||||||
settingsPopupModal.classList.remove("hidden");
|
openModal(settingsPopupModal, settingsPopupInput);
|
||||||
settingsPopupInput.focus();
|
settingsPopupInput.focus();
|
||||||
settingsPopupInput.select();
|
settingsPopupInput.select();
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -230,12 +233,11 @@ function openPersonaModal(persona = null, personaType = "assistant") {
|
|||||||
populatePersonaForm(settingsPersonaFields, persona || {});
|
populatePersonaForm(settingsPersonaFields, persona || {});
|
||||||
syncPersonaExamplesField();
|
syncPersonaExamplesField();
|
||||||
setPersonaNotice("");
|
setPersonaNotice("");
|
||||||
settingsPersonaModal.classList.remove("hidden");
|
openModal(settingsPersonaModal, settingsPersonaName);
|
||||||
settingsPersonaName.focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePersonaModal() {
|
function closePersonaModal() {
|
||||||
settingsPersonaModal.classList.add("hidden");
|
closeModal(settingsPersonaModal);
|
||||||
editingPersonaId = null;
|
editingPersonaId = null;
|
||||||
setPersonaNotice("");
|
setPersonaNotice("");
|
||||||
}
|
}
|
||||||
@@ -497,9 +499,8 @@ usernameForm.addEventListener("submit", async (event) => {
|
|||||||
setNotice(res.error, "error");
|
setNotice(res.error, "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
currentUsernameInput.value = res.username;
|
|
||||||
newUsernameInput.value = res.username;
|
|
||||||
usernamePasswordInput.value = "";
|
usernamePasswordInput.value = "";
|
||||||
|
if (!await loadProfile()) return;
|
||||||
setNotice("Username updated.", "success");
|
setNotice("Username updated.", "success");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -570,9 +571,18 @@ settingsPopupInput.addEventListener("keydown", (event) => {
|
|||||||
}
|
}
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
closeSettingsPopup(null);
|
closeSettingsPopup(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
document.addEventListener("keydown", event => {
|
||||||
|
if (event.key !== "Escape") return;
|
||||||
|
const modal = topModal();
|
||||||
|
if (!modal) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (modal === settingsPopupModal) closeSettingsPopup(null);
|
||||||
|
else if (modal === settingsPersonaModal) closePersonaModal();
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener("load", async () => {
|
window.addEventListener("load", async () => {
|
||||||
initializeAppearance({ title: "Elsewhere Settings", themeSelect: settingsThemeSelect, modeSelect: themeModeSelect });
|
initializeAppearance({ title: "Elsewhere Settings", themeSelect: settingsThemeSelect, modeSelect: themeModeSelect });
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Zeigt Statusmeldungen an und setzt deren Inhalt und Sichtbarkeit zurück.
|
||||||
|
*/
|
||||||
export function setStatus(element, message = "", state = "", baseClass = "status") {
|
export function setStatus(element, message = "", state = "", baseClass = "status") {
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
element.textContent = message;
|
element.textContent = message;
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Übernimmt das gespeicherte Theme frühzeitig in das Dokument, bevor die übrige Oberfläche initialisiert wird.
|
||||||
|
*/
|
||||||
(() => {
|
(() => {
|
||||||
const themes = new Set(["fakegpt", "fraud", "germini", "slopilot", "beta-ai", "confusity"]);
|
const themes = new Set(["fakegpt", "fraud", "germini", "slopilot", "beta-ai", "confusity"]);
|
||||||
const storedTheme = localStorage.getItem("krishd-theme");
|
const storedTheme = localStorage.getItem("krishd-theme");
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Normalisiert, speichert und synchronisiert Theme und Hell-Dunkel-Modus der Oberfläche.
|
||||||
|
*/
|
||||||
import {themes} from "./constants.js?v=20260909-account-controls";
|
import {themes} from "./constants.js?v=20260909-account-controls";
|
||||||
|
|
||||||
export const THEME_STORAGE_KEY = "krishd-theme";
|
export const THEME_STORAGE_KEY = "krishd-theme";
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Steuert die Websuche-Auswahl und rendert Quellenlinks zu Chat-Antworten.
|
||||||
|
*/
|
||||||
/** @typedef {{title: string, url: string, snippet: string}} WebSource */
|
/** @typedef {{title: string, url: string, snippet: string}} WebSource */
|
||||||
/** @typedef {{message: string, model: string, chatId: number, webSearch?: boolean, reasoning?: boolean}} ChatRequest */
|
/** @typedef {{message: string, model: string, chatId: number, webSearch?: boolean, reasoning?: boolean}} ChatRequest */
|
||||||
/** @typedef {{role: string, content: string, thinking?: string, sources?: WebSource[]}} ChatMessage */
|
/** @typedef {{role: string, content: string, thinking?: string, sources?: WebSource[]}} ChatMessage */
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import {post} from './api.js?v=20260909-account-controls';
|
/**
|
||||||
|
* @file Verbindet die Abmeldeknöpfe des gemeinsamen Seitenkopfs mit der Sitzungsverwaltung.
|
||||||
|
*/
|
||||||
|
import {post} from './api.js?v=20260909-account-controls';
|
||||||
|
|
||||||
// Every page gets this control from the same server-rendered header.
|
// Every page gets this control from the same server-rendered header.
|
||||||
for (const button of document.querySelectorAll('[data-sign-out]')) {
|
for (const button of document.querySelectorAll('[data-sign-out]')) {
|
||||||
|
|||||||
+2
-2
@@ -102,7 +102,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div id="marketPreviewModal" class="modal-overlay hidden">
|
<div id="marketPreviewModal" class="modal-overlay hidden">
|
||||||
<div class="modal-card market-preview-card">
|
<div class="modal-card market-preview-card" role="dialog" aria-modal="true" aria-labelledby="marketPreviewTitle" tabindex="-1">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Persona preview</p>
|
<p class="eyebrow">Persona preview</p>
|
||||||
@@ -162,7 +162,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="marketPersonaModal" class="modal-overlay hidden">
|
<div id="marketPersonaModal" class="modal-overlay hidden">
|
||||||
<div class="modal-card persona-modal-card">
|
<div class="modal-card persona-modal-card" role="dialog" aria-modal="true" aria-labelledby="marketPersonaFormTitle" tabindex="-1">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="persona-modal-heading">
|
<div class="persona-modal-heading">
|
||||||
<p class="eyebrow">AI Character editor</p>
|
<p class="eyebrow">AI Character editor</p>
|
||||||
|
|||||||
@@ -232,7 +232,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div id="settingsPersonaModal" class="modal-overlay hidden">
|
<div id="settingsPersonaModal" class="modal-overlay hidden">
|
||||||
<div class="modal-card persona-modal-card">
|
<div class="modal-card persona-modal-card" role="dialog" aria-modal="true" aria-labelledby="settingsPersonaFormTitle" tabindex="-1">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="persona-modal-heading">
|
<div class="persona-modal-heading">
|
||||||
<p class="eyebrow">Persona editor</p>
|
<p class="eyebrow">Persona editor</p>
|
||||||
@@ -317,7 +317,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="/js/settings.js?v=20260923-asset-paths"></script>
|
<script type="module" src="/js/settings.js?v=20260930-account-fixes"></script>
|
||||||
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
|
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Startet den HTTP-Server der Chat-Anwendung.
|
||||||
|
*/
|
||||||
import {startServer} from "./src/app.js";
|
import {startServer} from "./src/app.js";
|
||||||
|
|
||||||
startServer();
|
startServer();
|
||||||
|
|||||||
+4
-1
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Verbindet Express, Sitzungen, Datenbank und Routen und verwaltet den Server-Lebenszyklus.
|
||||||
|
*/
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import {randomUUID} from "crypto";
|
import {randomUUID} from "crypto";
|
||||||
import {performance} from "perf_hooks";
|
import {performance} from "perf_hooks";
|
||||||
@@ -306,7 +309,7 @@ export function createApp(options = {}) {
|
|||||||
app.use(createUploadsRouter(attachmentService, config));
|
app.use(createUploadsRouter(attachmentService, config));
|
||||||
if (config.visionDebug) app.use(createVisionDebugRouter({visionDebugService, repositories}));
|
if (config.visionDebug) app.use(createVisionDebugRouter({visionDebugService, repositories}));
|
||||||
app.use(createSettingsRouter({repositories}));
|
app.use(createSettingsRouter({repositories}));
|
||||||
app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}));
|
app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters, attachmentService}));
|
||||||
app.use(createPersonasRouter({repositories, chatService, modelService, config}));
|
app.use(createPersonasRouter({repositories, chatService, modelService, config}));
|
||||||
app.use(createSystemRouter({modelService, config}));
|
app.use(createSystemRouter({modelService, config}));
|
||||||
app.use(notFoundHandler);
|
app.use(notFoundHandler);
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Erstellt die Anwendungskonfiguration aus Umgebungsvariablen, Standardwerten und expliziten Vorgaben.
|
||||||
|
*/
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
const bool = (value, fallback = false) => {
|
const bool = (value, fallback = false) => {
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Definiert Tabellen für Bildanhänge und Analyse-Caches und bereitet Anhangsmetadaten auf.
|
||||||
|
*/
|
||||||
export function initializeAttachments(db) {
|
export function initializeAttachments(db) {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS attachments (
|
CREATE TABLE IF NOT EXISTS attachments (
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Öffnet die SQLite-Datenbank und initialisiert Tabellen sowie Migrationen.
|
||||||
|
*/
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
import {initializeSchema, runMigrations} from "./schema.js";
|
import {initializeSchema, runMigrations} from "./schema.js";
|
||||||
|
|||||||
+19
-7
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Bündelt Datenbankzugriffe auf Konten, Chats, Nachrichten, Personas und den Marktplatz.
|
||||||
|
*/
|
||||||
import {attachmentMetadata} from "./attachments.js";
|
import {attachmentMetadata} from "./attachments.js";
|
||||||
|
|
||||||
const parseJsonArray = (value) => {
|
const parseJsonArray = (value) => {
|
||||||
@@ -84,7 +87,7 @@ export function createRepositories(db, config) {
|
|||||||
WHERE cs.username = ?
|
WHERE cs.username = ?
|
||||||
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
|
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
|
||||||
`),
|
`),
|
||||||
countChatSessions: db.prepare("SELECT COUNT(*) as count FROM chat_sessions WHERE username = ?"),
|
countChatSessions: db.prepare("SELECT COUNT(*) as count FROM chat_sessions WHERE username = ? AND archived_at IS NULL"),
|
||||||
getChatSession: db.prepare(`
|
getChatSession: db.prepare(`
|
||||||
SELECT cs.id,
|
SELECT cs.id,
|
||||||
CASE
|
CASE
|
||||||
@@ -378,7 +381,7 @@ export function createRepositories(db, config) {
|
|||||||
deleteChatMessage: db.prepare("DELETE FROM chat_messages WHERE id = ? AND chat_id = ?"),
|
deleteChatMessage: db.prepare("DELETE FROM chat_messages WHERE id = ? AND chat_id = ?"),
|
||||||
deleteChatMessages: db.prepare("DELETE FROM chat_messages WHERE chat_id = ?"),
|
deleteChatMessages: db.prepare("DELETE FROM chat_messages WHERE chat_id = ?"),
|
||||||
searchChats: db.prepare(`
|
searchChats: db.prepare(`
|
||||||
SELECT DISTINCT cs.id,
|
SELECT cs.id,
|
||||||
CASE
|
CASE
|
||||||
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
|
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
|
||||||
ELSE cs.title
|
ELSE cs.title
|
||||||
@@ -405,18 +408,23 @@ export function createRepositories(db, config) {
|
|||||||
cs.system_instruction,
|
cs.system_instruction,
|
||||||
cs.created_at,
|
cs.created_at,
|
||||||
cs.updated_at,
|
cs.updated_at,
|
||||||
|
cm.id AS matched_message_id,
|
||||||
cm.content AS matched_content
|
cm.content AS matched_content
|
||||||
FROM chat_sessions cs
|
FROM chat_sessions cs
|
||||||
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
|
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
|
||||||
LEFT JOIN personas up ON up.id = cs.user_persona_id
|
LEFT JOIN personas up ON up.id = cs.user_persona_id
|
||||||
LEFT JOIN chat_messages cm ON cm.chat_id = cs.id
|
LEFT JOIN chat_messages cm ON cm.id = (
|
||||||
|
SELECT m.id FROM chat_messages m
|
||||||
|
WHERE m.chat_id = cs.id AND LOWER(m.content) LIKE ?
|
||||||
|
ORDER BY m.id DESC LIMIT 1
|
||||||
|
)
|
||||||
WHERE cs.username = ?
|
WHERE cs.username = ?
|
||||||
AND (
|
AND (
|
||||||
LOWER(CASE
|
LOWER(CASE
|
||||||
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
|
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
|
||||||
ELSE cs.title
|
ELSE cs.title
|
||||||
END) LIKE ?
|
END) LIKE ?
|
||||||
OR LOWER(COALESCE(cm.content, '')) LIKE ?
|
OR cm.id IS NOT NULL
|
||||||
)
|
)
|
||||||
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
|
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
|
||||||
`),
|
`),
|
||||||
@@ -711,6 +719,9 @@ export function createRepositories(db, config) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renameUserTransaction = db.transaction((currentUsername, nextUsername) => {
|
const renameUserTransaction = db.transaction((currentUsername, nextUsername) => {
|
||||||
|
// Existing username references do not all cascade on update. Check them at commit,
|
||||||
|
// after every owned row has been moved to the new username.
|
||||||
|
db.pragma("defer_foreign_keys = ON");
|
||||||
statements.updateUsername.run(nextUsername, currentUsername);
|
statements.updateUsername.run(nextUsername, currentUsername);
|
||||||
statements.updateChatSessionsUsername.run(nextUsername, currentUsername);
|
statements.updateChatSessionsUsername.run(nextUsername, currentUsername);
|
||||||
statements.updatePersonasUsername.run(nextUsername, currentUsername);
|
statements.updatePersonasUsername.run(nextUsername, currentUsername);
|
||||||
@@ -876,9 +887,10 @@ export function createRepositories(db, config) {
|
|||||||
.run(result.lastInsertRowid, version.content, version.thinking, JSON.stringify(version.sources), version.modelName, version.createdAt);
|
.run(result.lastInsertRowid, version.content, version.thinking, JSON.stringify(version.sources), version.modelName, version.createdAt);
|
||||||
}
|
}
|
||||||
for (const [position, attachment] of (message.attachments || []).slice(0, config.uploadMaxFiles || 5).entries()) {
|
for (const [position, attachment] of (message.attachments || []).slice(0, config.uploadMaxFiles || 5).entries()) {
|
||||||
// JSON backups contain references, not files. Only restore existing, owned images.
|
// JSON imports reconnect owned images; archive imports also reconnect newly restored images.
|
||||||
db.prepare("INSERT OR IGNORE INTO message_attachments(message_id,attachment_id,position) SELECT ?,id,? FROM attachments WHERE id=? AND username=? AND status='attached'")
|
db.prepare("INSERT OR IGNORE INTO message_attachments(message_id,attachment_id,position) SELECT ?,id,? FROM attachments WHERE id=? AND username=? AND status IN ('temporary','attached')")
|
||||||
.run(result.lastInsertRowid, position, attachment.id, username);
|
.run(result.lastInsertRowid, position, attachment.id, username);
|
||||||
|
db.prepare("UPDATE attachments SET status='attached' WHERE id=? AND username=?").run(attachment.id, username);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
statements.updateChatGenerationSettings.run(
|
statements.updateChatGenerationSettings.run(
|
||||||
@@ -942,7 +954,7 @@ export function createRepositories(db, config) {
|
|||||||
listChats: (username) => statements.listChatSessions.all(username),
|
listChats: (username) => statements.listChatSessions.all(username),
|
||||||
searchChats(username, query) {
|
searchChats(username, query) {
|
||||||
const like = `%${String(query || "").trim().toLowerCase()}%`;
|
const like = `%${String(query || "").trim().toLowerCase()}%`;
|
||||||
return statements.searchChats.all(username, like, like);
|
return statements.searchChats.all(like, username, like);
|
||||||
},
|
},
|
||||||
countChats: (username) => statements.countChatSessions.get(username).count,
|
countChats: (username) => statements.countChatSessions.get(username).count,
|
||||||
getChat: (chatId, username) => statements.getChatSession.get(chatId, username),
|
getChat: (chatId, username) => statements.getChatSession.get(chatId, username),
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Definiert das Datenbankschema und migriert bestehende Daten auf die benötigte Struktur.
|
||||||
|
*/
|
||||||
export function initializeSchema(db) {
|
export function initializeSchema(db) {
|
||||||
db.pragma("journal_mode = WAL");
|
db.pragma("journal_mode = WAL");
|
||||||
db.pragma("foreign_keys = ON");
|
db.pragma("foreign_keys = ON");
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Schützt Routen vor Zugriffen ohne angemeldete Sitzung.
|
||||||
|
*/
|
||||||
export function requireLogin(req, res, next) {
|
export function requireLogin(req, res, next) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
return res.status(403).json({error: "Not logged in"});
|
return res.status(403).json({error: "Not logged in"});
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Begrenzt Anfragen je Schlüssel in einem gleitenden Zeitfenster und entfernt abgelaufene Einträge.
|
||||||
|
*/
|
||||||
function pruneBucket(bucket, windowMs, now) {
|
function pruneBucket(bucket, windowMs, now) {
|
||||||
const cutoff = now - windowMs;
|
const cutoff = now - windowMs;
|
||||||
while (bucket.length && bucket[0] <= cutoff) {
|
while (bucket.length && bucket[0] <= cutoff) {
|
||||||
@@ -21,6 +24,7 @@ export function createRateLimiter({windowMs, max, keyGenerator, message, cleanup
|
|||||||
|
|
||||||
if (cleanupIntervalMs > 0) {
|
if (cleanupIntervalMs > 0) {
|
||||||
cleanupTimer = setInterval(cleanup, cleanupIntervalMs);
|
cleanupTimer = setInterval(cleanup, cleanupIntervalMs);
|
||||||
|
// Der Aufräumtimer allein soll den Node-Prozess beim Herunterfahren nicht offen halten.
|
||||||
cleanupTimer.unref();
|
cleanupTimer.unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Erstellt die konfigurierten Anfragebegrenzungen für Anmeldung und Chat-Nutzung.
|
||||||
|
*/
|
||||||
import {createRateLimiter} from "./rateLimit.js";
|
import {createRateLimiter} from "./rateLimit.js";
|
||||||
|
|
||||||
export function createAuthRateLimiters(config) {
|
export function createAuthRateLimiters(config) {
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Prüft Anfrageinhalte mit einem übergebenen Validator und stellt die validierten Daten für Routen bereit.
|
||||||
|
*/
|
||||||
export function validateBody(validator) {
|
export function validateBody(validator) {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
const result = validator(req.body || {}, req);
|
const result = validator(req.body || {}, req);
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Erzeugt den gemeinsamen HTML-Seitenkopf mit Navigation und Kontosteuerung.
|
||||||
|
*/
|
||||||
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({
|
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({
|
||||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||||
})[character]);
|
})[character]);
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Verarbeitet Registrierung, Anmeldung, Abmeldung und die Abfrage der aktuellen Sitzung.
|
||||||
|
*/
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
import {validateBody} from "../middleware/validate.js";
|
import {validateBody} from "../middleware/validate.js";
|
||||||
|
|||||||
+82
-3
@@ -1,7 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* @file Stellt Chat-Routen für Nachrichten, Streaming, Antwortvarianten, Verzweigungen und Einstellungen bereit.
|
||||||
|
*/
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import multer from "multer";
|
||||||
import {requireLogin} from "../middleware/auth.js";
|
import {requireLogin} from "../middleware/auth.js";
|
||||||
import {validateBody} from "../middleware/validate.js";
|
import {validateBody} from "../middleware/validate.js";
|
||||||
import {validateWorkspaceImport} from "../services/workspaceImportService.js";
|
import {validateWorkspaceImport} from "../services/workspaceImportService.js";
|
||||||
|
import {createWorkspaceArchive, parseWorkspaceArchive, MAX_ARCHIVE_BYTES} from "../services/workspaceArchive.js";
|
||||||
|
|
||||||
const createChatValidator = (body) => ({
|
const createChatValidator = (body) => ({
|
||||||
value: {
|
value: {
|
||||||
@@ -102,9 +107,29 @@ const preferredModelValidator = (body) => {
|
|||||||
return {value: {model}};
|
return {value: {model}};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}) {
|
export function createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters, attachmentService}) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(requireLogin);
|
router.use(requireLogin);
|
||||||
|
const receiveArchive = multer({storage: multer.memoryStorage(), limits: {fileSize: MAX_ARCHIVE_BYTES, files: 1, parts: 2}}).single("archive");
|
||||||
|
const readArchive = (req, res, next) => receiveArchive(req, res, error => {
|
||||||
|
if (error) return res.status(error.code === "LIMIT_FILE_SIZE" ? 413 : 400).json({error: "Invalid or oversized workspace archive"});
|
||||||
|
try {
|
||||||
|
if (!req.file) throw new Error("Choose a workspace archive");
|
||||||
|
req.archive = parseWorkspaceArchive(req.file.buffer);
|
||||||
|
next();
|
||||||
|
} catch (failure) { res.status(400).json({error: failure.message}); }
|
||||||
|
});
|
||||||
|
const archiveImages = (workspace, files) => {
|
||||||
|
const images = new Map();
|
||||||
|
for (const chat of workspace.chats) for (const message of chat.messages) for (const attachment of message.attachments) {
|
||||||
|
if (images.has(attachment.id)) continue;
|
||||||
|
const matches = [...files.entries()].filter(([name]) => name.startsWith(`images/${attachment.id}.`));
|
||||||
|
if (matches.length !== 1) throw new Error(`Image ${attachment.id} is missing from archive`);
|
||||||
|
const [name, buffer] = matches[0];
|
||||||
|
images.set(attachment.id, {buffer, size: buffer.length, originalname: name.split("/").at(-1)});
|
||||||
|
}
|
||||||
|
return images;
|
||||||
|
};
|
||||||
router.use(["/chats/:id/messages/by-index/:index", "/chats/:id/messages/:messageId"], (req, res, next) => {
|
router.use(["/chats/:id/messages/by-index/:index", "/chats/:id/messages/:messageId"], (req, res, next) => {
|
||||||
const chatId = Number(req.params.id);
|
const chatId = Number(req.params.id);
|
||||||
const messageId = req.params.messageId ? Number(req.params.messageId) : chatService.getChatMessageByIndex(chatId, req.params.index)?.id;
|
const messageId = req.params.messageId ? Number(req.params.messageId) : chatService.getChatMessageByIndex(chatId, req.params.index)?.id;
|
||||||
@@ -141,14 +166,14 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
|||||||
return res.json({chat, existing: Boolean(existingChat)});
|
return res.json({chat, existing: Boolean(existingChat)});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message === "CHAT_LIMIT_REACHED") {
|
if (error.message === "CHAT_LIMIT_REACHED") {
|
||||||
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
|
return res.status(400).json({error: `Maximum of ${config.chatLimit} active chats reached. Archive or delete an active chat to create a new one.`});
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (repositories.countChats(user) >= config.chatLimit) {
|
if (repositories.countChats(user) >= config.chatLimit) {
|
||||||
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
|
return res.status(400).json({error: `Maximum of ${config.chatLimit} active chats reached. Archive or delete an active chat to create a new one.`});
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({chat: repositories.createChat(user, title), existing: false});
|
return res.json({chat: repositories.createChat(user, title), existing: false});
|
||||||
@@ -168,6 +193,9 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
|||||||
const chatId = Number(req.params.id);
|
const chatId = Number(req.params.id);
|
||||||
const chat = repositories.getChat(chatId, req.session.user);
|
const chat = repositories.getChat(chatId, req.session.user);
|
||||||
if (!chat) return res.status(404).json({error: "Chat not found"});
|
if (!chat) return res.status(404).json({error: "Chat not found"});
|
||||||
|
if (chat.archived_at && !req.validatedBody.archived && repositories.countChats(req.session.user) >= config.chatLimit) {
|
||||||
|
return res.status(400).json({error: `Maximum of ${config.chatLimit} active chats reached. Archive another chat before restoring this one.`});
|
||||||
|
}
|
||||||
repositories.updateChatOrganization(chatId, req.session.user, {
|
repositories.updateChatOrganization(chatId, req.session.user, {
|
||||||
folderName: req.validatedBody.folderName,
|
folderName: req.validatedBody.folderName,
|
||||||
isPinned: req.validatedBody.isPinned,
|
isPinned: req.validatedBody.isPinned,
|
||||||
@@ -599,6 +627,57 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.get("/exports/workspace", (req, res) => res.json({workspace: repositories.exportWorkspace(req.session.user)}));
|
router.get("/exports/workspace", (req, res) => res.json({workspace: repositories.exportWorkspace(req.session.user)}));
|
||||||
|
router.get("/exports/workspace/archive", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const workspace = repositories.exportWorkspace(req.session.user);
|
||||||
|
const images = new Map();
|
||||||
|
for (const chat of workspace.chats) for (const message of chat.messages) for (const attachment of message.attachments) {
|
||||||
|
if (images.has(attachment.id)) continue;
|
||||||
|
const row = attachmentService.owned(attachment.id, req.session.user);
|
||||||
|
images.set(attachment.id, [`images/${row.id}.${row.mime_type.split("/")[1]}`, await attachmentService.storage.read(row.storage_key)]);
|
||||||
|
}
|
||||||
|
const archive = createWorkspaceArchive(workspace, [...images.values()]);
|
||||||
|
res.set({"Content-Type": "application/gzip", "Content-Disposition": 'attachment; filename="workspace-backup.tar.gz"', "Cache-Control": "private, no-store"});
|
||||||
|
return res.send(archive);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(error.message.includes("250 MB") ? 413 : 500).json({error: "Workspace archive could not be created"});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.post("/imports/workspace/archive/preview", readArchive, (req, res) => {
|
||||||
|
const validation = validateWorkspaceImport(req.archive.workspace, repositories.exportWorkspace(req.session.user));
|
||||||
|
if (validation.ok) {
|
||||||
|
try { archiveImages(validation.workspace, req.archive.images); }
|
||||||
|
catch (error) { validation.ok = false; validation.errors.push(error.message); }
|
||||||
|
}
|
||||||
|
return res.status(validation.ok ? 200 : 400).json({ok: validation.ok, errors: validation.errors, warnings: validation.warnings, preview: validation.preview});
|
||||||
|
});
|
||||||
|
router.post("/imports/workspace/archive", readArchive, async (req, res) => {
|
||||||
|
const validation = validateWorkspaceImport(req.archive.workspace, repositories.exportWorkspace(req.session.user));
|
||||||
|
if (!validation.ok) return res.status(400).json({error: "Workspace import validation failed", errors: validation.errors});
|
||||||
|
if (validation.preview.duplicates.length && req.body?.allowDuplicates !== "true") {
|
||||||
|
return res.status(409).json({error: "Workspace import contains duplicate records", warnings: validation.warnings, preview: validation.preview});
|
||||||
|
}
|
||||||
|
let images;
|
||||||
|
try { images = archiveImages(validation.workspace, req.archive.images); }
|
||||||
|
catch (error) { return res.status(400).json({error: error.message}); }
|
||||||
|
const restored = [];
|
||||||
|
try {
|
||||||
|
const ids = new Map();
|
||||||
|
for (const [oldId, file] of images) {
|
||||||
|
const uploaded = await attachmentService.upload(file, req.session.user, {forImport: true});
|
||||||
|
restored.push(uploaded.id);
|
||||||
|
ids.set(oldId, uploaded.id);
|
||||||
|
}
|
||||||
|
for (const chat of validation.workspace.chats) for (const message of chat.messages) {
|
||||||
|
message.attachments = message.attachments.map(attachment => ({id: ids.get(attachment.id)}));
|
||||||
|
}
|
||||||
|
const imported = repositories.importWorkspace(req.session.user, validation.workspace);
|
||||||
|
return res.json({imported, preview: validation.preview});
|
||||||
|
} catch (error) {
|
||||||
|
for (const id of restored) await attachmentService.remove(id, req.session.user).catch(() => {});
|
||||||
|
return res.status(error.status || 400).json({error: error.message || "Workspace archive import failed"});
|
||||||
|
}
|
||||||
|
});
|
||||||
router.post("/imports/workspace/preview", (req, res) => {
|
router.post("/imports/workspace/preview", (req, res) => {
|
||||||
const validation = validateWorkspaceImport(req.body?.workspace, repositories.exportWorkspace(req.session.user));
|
const validation = validateWorkspaceImport(req.body?.workspace, repositories.exportWorkspace(req.session.user));
|
||||||
return res.status(validation.ok ? 200 : 400).json({
|
return res.status(validation.ok ? 200 : 400).json({
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Liefert öffentliche Seiten und geschützte Ansichten des Arbeitsbereichs aus.
|
||||||
|
*/
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import {readFile} from "node:fs/promises";
|
import {readFile} from "node:fs/promises";
|
||||||
import {renderSiteHeader} from "../pageLayout.js";
|
import {renderSiteHeader} from "../pageLayout.js";
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Verwaltet eigene Personas sowie Veröffentlichung, Suche und Interaktionen auf dem Persona-Marktplatz.
|
||||||
|
*/
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import {requireLogin} from "../middleware/auth.js";
|
import {requireLogin} from "../middleware/auth.js";
|
||||||
import {validateBody} from "../middleware/validate.js";
|
import {validateBody} from "../middleware/validate.js";
|
||||||
@@ -118,7 +121,7 @@ export function createPersonasRouter({repositories, chatService, modelService, c
|
|||||||
return res.json({chat: chatService.getOrCreatePersonaChat(user, persona, userPersonaId, null, null)});
|
return res.json({chat: chatService.getOrCreatePersonaChat(user, persona, userPersonaId, null, null)});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message === "CHAT_LIMIT_REACHED") {
|
if (error.message === "CHAT_LIMIT_REACHED") {
|
||||||
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
|
return res.status(400).json({error: `Maximum of ${config.chatLimit} active chats reached. Archive or delete an active chat to create a new one.`});
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -242,7 +245,7 @@ export function createPersonasRouter({repositories, chatService, modelService, c
|
|||||||
chat = chatService.getOrCreatePersonaChat(user, persona, userPersonaId, scenarioPrompt, sceneSummary);
|
chat = chatService.getOrCreatePersonaChat(user, persona, userPersonaId, scenarioPrompt, sceneSummary);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message === "CHAT_LIMIT_REACHED") {
|
if (error.message === "CHAT_LIMIT_REACHED") {
|
||||||
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
|
return res.status(400).json({error: `Maximum of ${config.chatLimit} active chats reached. Archive or delete an active chat to create a new one.`});
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -297,7 +300,7 @@ export function createPersonasRouter({repositories, chatService, modelService, c
|
|||||||
chat = chatService.getOrCreatePersonaChat(user, assistantPersona, userPersonaId, scenarioPrompt, sceneSummary);
|
chat = chatService.getOrCreatePersonaChat(user, assistantPersona, userPersonaId, scenarioPrompt, sceneSummary);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message === "CHAT_LIMIT_REACHED") {
|
if (error.message === "CHAT_LIMIT_REACHED") {
|
||||||
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
|
return res.status(400).json({error: `Maximum of ${config.chatLimit} active chats reached. Archive or delete an active chat to create a new one.`});
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Verarbeitet Benutzer- und Kontoeinstellungen sowie Export und Import des Arbeitsbereichs.
|
||||||
|
*/
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
import {requireLogin} from "../middleware/auth.js";
|
import {requireLogin} from "../middleware/auth.js";
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Stellt Modellinformationen und administrative Diagnosedaten bereit.
|
||||||
|
*/
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import {requireLogin} from "../middleware/auth.js";
|
import {requireLogin} from "../middleware/auth.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Verarbeitet das Hochladen, Abrufen und Entfernen von Bildanhängen mit Zugriffskontrolle.
|
||||||
|
*/
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import {requireLogin} from "../middleware/auth.js";
|
import {requireLogin} from "../middleware/auth.js";
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* @file Liefert angemeldeten Benutzern die für sie zugänglichen Diagnosen der Bildverarbeitung.
|
||||||
|
*/
|
||||||
|
import express from "express";
|
||||||
|
import {requireLogin} from "../middleware/auth.js";
|
||||||
|
|
||||||
|
export function createVisionDebugRouter({visionDebugService, repositories}) {
|
||||||
|
const router = express.Router();
|
||||||
|
router.use(requireLogin);
|
||||||
|
router.get("/debug/vision/latest", (req, res) => {
|
||||||
|
const diagnostic = visionDebugService.latest(req.session.user, repositories);
|
||||||
|
return diagnostic ? res.json({diagnostic}) : res.status(404).json({error: "Vision diagnostic not found"});
|
||||||
|
});
|
||||||
|
router.get("/debug/vision/:requestId", (req, res) => {
|
||||||
|
const diagnostic = visionDebugService.get(req.params.requestId, req.session.user, repositories);
|
||||||
|
return diagnostic ? res.json({diagnostic}) : res.status(404).json({error: "Vision diagnostic not found"});
|
||||||
|
});
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Validiert und speichert Bildanhänge und bereitet zwischengespeicherte Bildanalysen für den Chat vor.
|
||||||
|
*/
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import {createHash, randomUUID} from "node:crypto";
|
import {createHash, randomUUID} from "node:crypto";
|
||||||
import {attachmentMetadata} from "../db/attachments.js";
|
import {attachmentMetadata} from "../db/attachments.js";
|
||||||
@@ -159,7 +162,7 @@ export function createAttachmentService(db, config, modelService, visionDebugSer
|
|||||||
db.prepare("DELETE FROM attachments WHERE id=?").run(id);
|
db.prepare("DELETE FROM attachments WHERE id=?").run(id);
|
||||||
return cleanup();
|
return cleanup();
|
||||||
},
|
},
|
||||||
async upload(file, user) {
|
async upload(file, user, {forImport = false} = {}) {
|
||||||
await cleanup();
|
await cleanup();
|
||||||
if (!file?.buffer?.length) throw new AttachmentError("Choose a non-empty PNG, JPEG or WebP image");
|
if (!file?.buffer?.length) throw new AttachmentError("Choose a non-empty PNG, JPEG or WebP image");
|
||||||
if (file.size > config.uploadMaxSizeMb * 1024 * 1024) throw new AttachmentError("Image is too large", 413);
|
if (file.size > config.uploadMaxSizeMb * 1024 * 1024) throw new AttachmentError("Image is too large", 413);
|
||||||
@@ -175,9 +178,9 @@ export function createAttachmentService(db, config, modelService, visionDebugSer
|
|||||||
try {
|
try {
|
||||||
// Bound abandoned uploads per account as well as per submitted message.
|
// Bound abandoned uploads per account as well as per submitted message.
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
if (db.prepare("SELECT COUNT(*) AS n FROM attachments WHERE username=? AND status='temporary'").get(user).n >= config.uploadMaxFiles * 4) throw new AttachmentError("Too many pending images. Remove unused uploads first.");
|
if (!forImport && db.prepare("SELECT COUNT(*) AS n FROM attachments WHERE username=? AND status='temporary'").get(user).n >= config.uploadMaxFiles * 4) throw new AttachmentError("Too many pending images. Remove unused uploads first.");
|
||||||
db.prepare("INSERT INTO attachments(id,username,original_name,storage_key,mime_type,size_bytes,width,height) VALUES(?,?,?,?,?,?,?,?)").run(id, user,
|
db.prepare("INSERT INTO attachments(id,username,original_name,storage_key,mime_type,size_bytes,width,height) VALUES(?,?,?,?,?,?,?,?)").run(id, user,
|
||||||
String(file.originalname || "image").replace(/[\\/\x00-\x1f]/g, "_").slice(0, 255), storageKey, `image/${info.format}`, file.size, info.width, info.height);
|
[...String(file.originalname || "image").replace(/[\\/]/g, "_")].map(character => character.charCodeAt(0) < 32 ? "_" : character).join("").slice(0, 255), storageKey, `image/${info.format}`, file.size, info.width, info.height);
|
||||||
})();
|
})();
|
||||||
} catch (error) { await storage.delete(storageKey); throw error; }
|
} catch (error) { await storage.delete(storageKey); throw error; }
|
||||||
log("attachmentUploadSuccess", {id, size: file.size, mime: `image/${info.format}`});
|
log("attachmentUploadSuccess", {id, size: file.size, mime: `image/${info.format}`});
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Normalisiert Nachrichten und erstellt Kontexttexte für Erinnerungen, Zusammenfassungen und Personas.
|
||||||
|
*/
|
||||||
import {normalizeSources} from "./webSearchService.js";
|
import {normalizeSources} from "./webSearchService.js";
|
||||||
|
|
||||||
export function parseSources(value) {
|
export function parseSources(value) {
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Koordiniert Chat-Antworten, Modellkontext, Websuche, Anhänge, Antwortvarianten und Gesprächsverzweigungen.
|
||||||
|
*/
|
||||||
import {
|
import {
|
||||||
buildPersonaPrompt,
|
buildPersonaPrompt,
|
||||||
buildRoleplayDirectionPrompt,
|
buildRoleplayDirectionPrompt,
|
||||||
@@ -95,15 +98,18 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
|
|
||||||
const withChatGenerationLock = (chatId, operation) => {
|
const withChatGenerationLock = (chatId, operation) => {
|
||||||
const previous = chatGenerationQueues.get(chatId) || Promise.resolve();
|
const previous = chatGenerationQueues.get(chatId) || Promise.resolve();
|
||||||
|
// Ein fehlgeschlagener Vorgänger darf die Warteschlange dieses Chats nicht blockieren.
|
||||||
const current = previous.catch(() => undefined).then(operation);
|
const current = previous.catch(() => undefined).then(operation);
|
||||||
chatGenerationQueues.set(chatId, current);
|
chatGenerationQueues.set(chatId, current);
|
||||||
return current.finally(() => {
|
return current.finally(() => {
|
||||||
|
// Nur den eigenen Eintrag entfernen: Inzwischen könnte bereits ein Folgeauftrag warten.
|
||||||
if (chatGenerationQueues.get(chatId) === current) chatGenerationQueues.delete(chatId);
|
if (chatGenerationQueues.get(chatId) === current) chatGenerationQueues.delete(chatId);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const withActiveGeneration = async (chatId, externalSignal, operation) => {
|
const withActiveGeneration = async (chatId, externalSignal, operation) => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
// Verbindungsabbruch und manuelles Stoppen wirken auf denselben Generierungsauftrag.
|
||||||
const abortFromCaller = () => controller.abort(externalSignal?.reason);
|
const abortFromCaller = () => controller.abort(externalSignal?.reason);
|
||||||
if (externalSignal?.aborted) abortFromCaller();
|
if (externalSignal?.aborted) abortFromCaller();
|
||||||
else externalSignal?.addEventListener("abort", abortFromCaller, { once: true });
|
else externalSignal?.addEventListener("abort", abortFromCaller, { once: true });
|
||||||
@@ -157,6 +163,7 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
const allMessages = repositories.listActiveBranchMessages(chatId)
|
const allMessages = repositories.listActiveBranchMessages(chatId)
|
||||||
.filter((message) => !message.delivery_status || message.delivery_status === "complete");
|
.filter((message) => !message.delivery_status || message.delivery_status === "complete");
|
||||||
const keepRecent = Math.max(0, config.chatHistoryLimit);
|
const keepRecent = Math.max(0, config.chatHistoryLimit);
|
||||||
|
// Die neuesten Nachrichten bleiben vollständig im Kontext; nur ältere werden zusammengefasst.
|
||||||
const cutoffIndex = allMessages.length - keepRecent - 1;
|
const cutoffIndex = allMessages.length - keepRecent - 1;
|
||||||
if (cutoffIndex < 0) return;
|
if (cutoffIndex < 0) return;
|
||||||
const cutoffMessage = allMessages[cutoffIndex];
|
const cutoffMessage = allMessages[cutoffIndex];
|
||||||
@@ -577,7 +584,7 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
|
|
||||||
const cloneChatThroughMessage = ({ user, sourceChat, messageId = null }) => {
|
const cloneChatThroughMessage = ({ user, sourceChat, messageId = null }) => {
|
||||||
if (repositories.countChats(user) >= config.chatLimit) {
|
if (repositories.countChats(user) >= config.chatLimit) {
|
||||||
return { error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`, status: 400 };
|
return { error: `Maximum of ${config.chatLimit} active chats reached. Archive or delete an active chat to create a new one.`, status: 400 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceMessages = messageId == null
|
const sourceMessages = messageId == null
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Speichert, liest und löscht hochgeladene Dateien in einem lokalen Speicherverzeichnis.
|
||||||
|
*/
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {randomUUID} from "node:crypto";
|
import {randomUUID} from "node:crypto";
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Kommuniziert mit Ollama und verwaltet Antwortstreams, Zeitlimits, Modellstatus und Modell-Caches.
|
||||||
|
*/
|
||||||
import { supportsReasoning } from "../../public/js/modelCapabilities.js";
|
import { supportsReasoning } from "../../public/js/modelCapabilities.js";
|
||||||
import { AttachmentError } from "./attachmentService.js";
|
import { AttachmentError } from "./attachmentService.js";
|
||||||
|
|
||||||
@@ -147,6 +150,7 @@ export function createModelService(config, overrides = {}) {
|
|||||||
touch();
|
touch();
|
||||||
buffer += decoder.decode(value, { stream: true });
|
buffer += decoder.decode(value, { stream: true });
|
||||||
const lines = buffer.split("\n");
|
const lines = buffer.split("\n");
|
||||||
|
// Netzwerkblöcke können mitten in einer JSON-Zeile enden; den Rest für den nächsten Block behalten.
|
||||||
buffer = lines.pop() || "";
|
buffer = lines.pop() || "";
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
@@ -160,6 +164,7 @@ export function createModelService(config, overrides = {}) {
|
|||||||
if (!completed && buffer.trim()) {
|
if (!completed && buffer.trim()) {
|
||||||
consume(buffer.trim());
|
consume(buffer.trim());
|
||||||
}
|
}
|
||||||
|
// Erst das Abschlussereignis bestätigt eine vollständige Antwort; ein Stream-Ende allein genügt nicht.
|
||||||
if (!completed) throw new ModelServiceError("MODEL_INTERRUPTED", "Model response was interrupted.", 502);
|
if (!completed) throw new ModelServiceError("MODEL_INTERRUPTED", "Model response was interrupted.", 502);
|
||||||
onRawReply?.(fullReply);
|
onRawReply?.(fullReply);
|
||||||
return fullReply.trim();
|
return fullReply.trim();
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Validiert Persona-Daten und erstellt Rollenspielanweisungen sowie Prüfungen für die Rollenverteilung.
|
||||||
|
*/
|
||||||
const PERSONA_FIELD_LIMITS = {
|
const PERSONA_FIELD_LIMITS = {
|
||||||
name: 200,
|
name: 200,
|
||||||
pronouns: 40,
|
pronouns: 40,
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Definiert Anweisungen, Antwortschema und Normalisierung für Bildanalysen und deren Nutzung im Chat.
|
||||||
|
*/
|
||||||
// Bump whenever the perception prompt, schema, or interpretation contract changes.
|
// Bump whenever the perception prompt, schema, or interpretation contract changes.
|
||||||
export const VISION_PROMPT_VERSION = 4;
|
export const VISION_PROMPT_VERSION = 4;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* @file Sammelt begrenzte Diagnosedaten zur Bildverarbeitung und kontrolliert deren benutzerbezogenen Zugriff.
|
||||||
|
*/
|
||||||
|
const RAW_VISION_LIMIT = 12000;
|
||||||
|
const EVIDENCE_LIMIT = 100000;
|
||||||
|
const ANSWER_LIMIT = 30000;
|
||||||
|
const REASONING_LIMIT = 30000;
|
||||||
|
const MAX_RECORDS = 100;
|
||||||
|
const MAX_AGE_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const bounded = (value, limit) => typeof value === "string" ? value.slice(0, limit) : value;
|
||||||
|
const clone = value => value == null ? value : structuredClone(value);
|
||||||
|
const exposed = record => {
|
||||||
|
const value = clone(record);
|
||||||
|
delete value.user;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createVisionDebugService(config) {
|
||||||
|
const enabled = config.visionDebug === true;
|
||||||
|
const records = new Map();
|
||||||
|
|
||||||
|
const prune = () => {
|
||||||
|
const cutoff = Date.now() - MAX_AGE_MS;
|
||||||
|
for (const [id, record] of records) {
|
||||||
|
if (Date.parse(record.updatedAt) < cutoff) records.delete(id);
|
||||||
|
}
|
||||||
|
while (records.size > MAX_RECORDS) records.delete(records.keys().next().value);
|
||||||
|
};
|
||||||
|
const update = (requestId, operation) => {
|
||||||
|
if (!enabled || !requestId) return null;
|
||||||
|
const record = records.get(requestId);
|
||||||
|
if (!record) return null;
|
||||||
|
operation(record);
|
||||||
|
record.updatedAt = new Date().toISOString();
|
||||||
|
return record;
|
||||||
|
};
|
||||||
|
const image = (record, attachmentId) => record.images.find(item => item.attachmentId === attachmentId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
begin({requestId, user, chatId, triggeringMessageId, userQuestion, selectedModel, reasoningRequested, webSearchRequested}) {
|
||||||
|
if (!enabled || !requestId) return;
|
||||||
|
prune();
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
records.delete(requestId);
|
||||||
|
records.set(requestId, {
|
||||||
|
requestId, user, chatId, triggeringMessageId,
|
||||||
|
createdAt: timestamp, updatedAt: timestamp,
|
||||||
|
request: {userQuestion, selectedModel, reasoningRequested: reasoningRequested === true, webSearchRequested: webSearchRequested === true},
|
||||||
|
images: [],
|
||||||
|
qwen: null,
|
||||||
|
search: {
|
||||||
|
requested: webSearchRequested === true,
|
||||||
|
needed: null,
|
||||||
|
explicitSearchIntent: null,
|
||||||
|
state: webSearchRequested === true ? "pending" : "disabled",
|
||||||
|
visualReference: "",
|
||||||
|
queries: [],
|
||||||
|
candidateCount: 0,
|
||||||
|
selectedCount: 0,
|
||||||
|
selectedSources: []
|
||||||
|
},
|
||||||
|
visibleReasoning: null,
|
||||||
|
finalAnswer: null,
|
||||||
|
error: null,
|
||||||
|
experimentNote: "For controlled Web Search/Reasoning comparisons, reuse this attachment and exact question. Re-uploading identical bytes creates a new attachment ID and vision cache entry."
|
||||||
|
});
|
||||||
|
prune();
|
||||||
|
},
|
||||||
|
recordImage(requestId, value) {
|
||||||
|
update(requestId, record => {
|
||||||
|
const existing = image(record, value.attachmentId);
|
||||||
|
const next = {
|
||||||
|
requestId,
|
||||||
|
chatId: record.chatId,
|
||||||
|
triggeringMessageId: record.triggeringMessageId,
|
||||||
|
attachmentId: value.attachmentId,
|
||||||
|
sourceMessageId: value.sourceMessageId,
|
||||||
|
position: value.position,
|
||||||
|
scope: value.scope,
|
||||||
|
label: value.label,
|
||||||
|
originalFilename: value.originalFilename,
|
||||||
|
mimeType: value.mimeType,
|
||||||
|
width: value.width,
|
||||||
|
height: value.height,
|
||||||
|
sizeBytes: value.sizeBytes,
|
||||||
|
imageSha256: null,
|
||||||
|
visionModel: value.visionModel,
|
||||||
|
visionPromptVersion: value.visionPromptVersion,
|
||||||
|
normalizedQuestion: value.normalizedQuestion,
|
||||||
|
cacheHit: null,
|
||||||
|
cacheIdentity: null,
|
||||||
|
rawVisionResponse: null,
|
||||||
|
normalizedVisionAnalysis: null
|
||||||
|
};
|
||||||
|
if (existing) Object.assign(existing, next); else record.images.push(next);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
recordCache(requestId, attachmentId, {cacheHit, cacheIdentity, normalizedVisionAnalysis}) {
|
||||||
|
update(requestId, record => {
|
||||||
|
const entry = image(record, attachmentId);
|
||||||
|
if (!entry) return;
|
||||||
|
entry.cacheHit = cacheHit === true;
|
||||||
|
entry.cacheIdentity = clone(cacheIdentity);
|
||||||
|
entry.rawVisionResponse = null;
|
||||||
|
if (normalizedVisionAnalysis !== undefined) entry.normalizedVisionAnalysis = clone(normalizedVisionAnalysis);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
recordVisionRun(requestId, attachmentId, {imageSha256, rawVisionResponse, normalizedVisionAnalysis}) {
|
||||||
|
update(requestId, record => {
|
||||||
|
const entry = image(record, attachmentId);
|
||||||
|
if (!entry) return;
|
||||||
|
entry.cacheHit = false;
|
||||||
|
entry.imageSha256 = imageSha256 || null;
|
||||||
|
entry.rawVisionResponse = bounded(String(rawVisionResponse ?? ""), RAW_VISION_LIMIT);
|
||||||
|
entry.normalizedVisionAnalysis = clone(normalizedVisionAnalysis);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
recordQwen(requestId, value) {
|
||||||
|
update(requestId, record => {
|
||||||
|
record.qwen = {
|
||||||
|
imageEvidence: bounded(value.imageEvidence, EVIDENCE_LIMIT),
|
||||||
|
visionGuidanceSha256: value.visionGuidanceSha256,
|
||||||
|
visionEvidenceIncluded: value.visionEvidenceIncluded === true,
|
||||||
|
imageCount: value.imageCount,
|
||||||
|
currentImageLabels: clone(value.currentImageLabels),
|
||||||
|
model: value.model,
|
||||||
|
think: value.think === true,
|
||||||
|
stream: value.stream === true,
|
||||||
|
temperature: value.temperature ?? null,
|
||||||
|
numCtx: value.numCtx ?? null,
|
||||||
|
numPredict: value.numPredict ?? null,
|
||||||
|
keepAlive: value.keepAlive
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
recordSearch(requestId, value) {
|
||||||
|
update(requestId, record => {
|
||||||
|
record.search = {
|
||||||
|
...record.search,
|
||||||
|
requested: value.requested === true,
|
||||||
|
needed: value.needed ?? record.search.needed,
|
||||||
|
explicitSearchIntent: value.explicitSearchIntent ?? record.search.explicitSearchIntent,
|
||||||
|
state: value.state ?? record.search.state,
|
||||||
|
visualReference: bounded(value.visualReference ?? record.search.visualReference, 1200),
|
||||||
|
queries: Array.isArray(value.queries) ? value.queries.map(item => bounded(String(item), 500)) : record.search.queries,
|
||||||
|
candidateCount: value.candidateCount ?? record.search.candidateCount,
|
||||||
|
selectedCount: value.selectedCount ?? record.search.selectedCount,
|
||||||
|
selectedSources: Array.isArray(value.selectedSources) ? value.selectedSources.slice(0, 5).map((source, index) => ({
|
||||||
|
index: index + 1,
|
||||||
|
title: bounded(String(source.title || ""), 300),
|
||||||
|
url: bounded(String(source.url || ""), 2048),
|
||||||
|
snippet: bounded(String(source.snippet || ""), 500)
|
||||||
|
})) : record.search.selectedSources
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
complete(requestId, {finalAnswer, visibleReasoning}) {
|
||||||
|
update(requestId, record => {
|
||||||
|
record.finalAnswer = bounded(String(finalAnswer ?? ""), ANSWER_LIMIT);
|
||||||
|
record.visibleReasoning = visibleReasoning ? bounded(String(visibleReasoning), REASONING_LIMIT) : null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
fail(requestId, error) {
|
||||||
|
update(requestId, record => { record.error = bounded(String(error?.message || error || "Request failed"), 1000); });
|
||||||
|
},
|
||||||
|
get(requestId, user, repositories) {
|
||||||
|
if (!enabled) return null;
|
||||||
|
prune();
|
||||||
|
const record = records.get(requestId);
|
||||||
|
if (!record || record.user !== user || !repositories.getChat(record.chatId, user)) return null;
|
||||||
|
return exposed(record);
|
||||||
|
},
|
||||||
|
latest(user, repositories) {
|
||||||
|
if (!enabled) return null;
|
||||||
|
prune();
|
||||||
|
const values = [...records.values()].reverse();
|
||||||
|
const record = values.find(item => item.user === user && repositories.getChat(item.chatId, user));
|
||||||
|
return record ? exposed(record) : null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Entscheidet über Websuchen, fragt SearXNG ab und bereitet relevante Quellen als Modellkontext auf.
|
||||||
|
*/
|
||||||
/** @typedef {{title: string, url: string, snippet: string, score?: number, engine?: string, category?: string, publishedDate?: string}} RawSearchResult */
|
/** @typedef {{title: string, url: string, snippet: string, score?: number, engine?: string, category?: string, publishedDate?: string}} RawSearchResult */
|
||||||
/** @typedef {{title: string, url: string, snippet: string}} SearchResult */
|
/** @typedef {{title: string, url: string, snippet: string}} SearchResult */
|
||||||
/** @typedef {{signal?: AbortSignal, limit?: number, category?: string}} SearchOptions */
|
/** @typedef {{signal?: AbortSignal, limit?: number, category?: string}} SearchOptions */
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/** Portable workspace archive: a gzip-compressed ustar file with JSON and images. */
|
||||||
|
import {gzipSync, gunzipSync} from "node:zlib";
|
||||||
|
|
||||||
|
export const MAX_ARCHIVE_BYTES = 250 * 1024 * 1024;
|
||||||
|
|
||||||
|
function header(name, size) {
|
||||||
|
const block = Buffer.alloc(512);
|
||||||
|
block.write(name, 0, 100, "ascii");
|
||||||
|
block.write("0000644\0", 100, "ascii");
|
||||||
|
block.write("0000000\0", 108, "ascii");
|
||||||
|
block.write("0000000\0", 116, "ascii");
|
||||||
|
block.write(size.toString(8).padStart(11, "0") + "\0", 124, "ascii");
|
||||||
|
block.write(Math.floor(Date.now() / 1000).toString(8).padStart(11, "0") + "\0", 136, "ascii");
|
||||||
|
block.fill(32, 148, 156);
|
||||||
|
block.write("0", 156, "ascii");
|
||||||
|
block.write("ustar\0", 257, "ascii");
|
||||||
|
block.write("00", 263, "ascii");
|
||||||
|
const checksum = block.reduce((sum, byte) => sum + byte, 0);
|
||||||
|
block.write(checksum.toString(8).padStart(6, "0") + "\0 ", 148, "ascii");
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkspaceArchive(workspace, images) {
|
||||||
|
const files = [["workspace.json", Buffer.from(JSON.stringify(workspace, null, 2))], ...images];
|
||||||
|
const chunks = [];
|
||||||
|
let total = 1024;
|
||||||
|
for (const [name, body] of files) {
|
||||||
|
if (!/^(workspace\.json|images\/[a-f0-9-]{36}\.(png|jpeg|webp))$/.test(name)) throw new Error("Invalid archive entry");
|
||||||
|
total += 512 + Math.ceil(body.length / 512) * 512;
|
||||||
|
if (total > MAX_ARCHIVE_BYTES) throw new Error("Workspace archive exceeds 250 MB");
|
||||||
|
chunks.push(header(name, body.length), body, Buffer.alloc((512 - body.length % 512) % 512));
|
||||||
|
}
|
||||||
|
chunks.push(Buffer.alloc(1024));
|
||||||
|
return gzipSync(Buffer.concat(chunks, total), {level: 1});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWorkspaceArchive(buffer) {
|
||||||
|
if (!Buffer.isBuffer(buffer) || buffer.length > MAX_ARCHIVE_BYTES) throw new Error("Archive exceeds 250 MB");
|
||||||
|
const tar = gunzipSync(buffer, {maxOutputLength: MAX_ARCHIVE_BYTES});
|
||||||
|
const files = new Map();
|
||||||
|
for (let offset = 0; offset + 512 <= tar.length;) {
|
||||||
|
const block = tar.subarray(offset, offset + 512);
|
||||||
|
if (block.every(byte => byte === 0)) break;
|
||||||
|
const name = block.toString("ascii", 0, 100).split("\0")[0];
|
||||||
|
if (!/^(workspace\.json|images\/[a-f0-9-]{36}\.(png|jpeg|webp))$/.test(name) || files.has(name) || block.toString("ascii", 156, 157) !== "0") {
|
||||||
|
throw new Error("Invalid archive entry");
|
||||||
|
}
|
||||||
|
const recorded = Number.parseInt(block.toString("ascii", 148, 156).trim(), 8);
|
||||||
|
const copy = Buffer.from(block); copy.fill(32, 148, 156);
|
||||||
|
if (recorded !== copy.reduce((sum, byte) => sum + byte, 0)) throw new Error("Invalid archive checksum");
|
||||||
|
const size = Number.parseInt(block.toString("ascii", 124, 136).replaceAll("\0", "").trim(), 8);
|
||||||
|
if (!Number.isSafeInteger(size) || size < 0 || offset + 512 + size > tar.length) throw new Error("Invalid archive size");
|
||||||
|
files.set(name, tar.subarray(offset + 512, offset + 512 + size));
|
||||||
|
offset += 512 + Math.ceil(size / 512) * 512;
|
||||||
|
}
|
||||||
|
if (!files.has("workspace.json")) throw new Error("Archive has no workspace.json");
|
||||||
|
let workspace;
|
||||||
|
try { workspace = JSON.parse(files.get("workspace.json").toString("utf8")); }
|
||||||
|
catch { throw new Error("Archive has invalid workspace.json"); }
|
||||||
|
return {workspace, images: files};
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Prüft und normalisiert importierte Arbeitsbereiche mit Personas, Chats und Nachrichtenbeziehungen.
|
||||||
|
*/
|
||||||
import {parseSources} from "./chatMessageHelpers.js";
|
import {parseSources} from "./chatMessageHelpers.js";
|
||||||
|
|
||||||
const VALID_PERSONA_TYPES = new Set(["assistant", "user"]);
|
const VALID_PERSONA_TYPES = new Set(["assistant", "user"]);
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Prüft Anmeldung, Kontoverwaltung, Chats, Personas und den Server-Lebenszyklus der Anwendung.
|
||||||
|
*/
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
@@ -249,6 +252,48 @@ test("account export, logout-all, and deletion controls work", async () => {
|
|||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("username and password changes keep account data and sessions consistent", async t => {
|
||||||
|
const server = await startTestServer();
|
||||||
|
t.after(() => server.close());
|
||||||
|
const jar = new CookieJar();
|
||||||
|
const otherSession = new CookieJar();
|
||||||
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "accountowner", password: "old-password"}});
|
||||||
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "alreadytaken", password: "password123"}});
|
||||||
|
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "accountowner", password: "old-password"}, jar})).status, 200);
|
||||||
|
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "accountowner", password: "old-password"}, jar: otherSession})).status, 200);
|
||||||
|
const chat = await request(server.baseUrl, "/chats", {method: "POST", body: {title: "Keep this chat"}, jar});
|
||||||
|
assert.equal(chat.status, 200);
|
||||||
|
const persona = await request(server.baseUrl, "/personas", {method: "POST", body: {name: "Keeper", personaType: "user"}, jar});
|
||||||
|
assert.equal(persona.status, 200);
|
||||||
|
const db = server.app.locals.db;
|
||||||
|
const attachmentId = "12345678-1234-1234-1234-123456789abc";
|
||||||
|
db.prepare("INSERT INTO attachments (id, username, original_name, storage_key, mime_type, size_bytes, width, height) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
|
||||||
|
.run(attachmentId, "accountowner", "picture.png", "rename-test-image", "image/png", 1, 1, 1);
|
||||||
|
|
||||||
|
assert.equal((await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "newowner", password: "wrong"}, jar})).status, 403);
|
||||||
|
assert.equal((await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "alreadytaken", password: "old-password"}, jar})).status, 400);
|
||||||
|
const renamed = await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "newowner", password: "old-password"}, jar});
|
||||||
|
assert.equal(renamed.status, 200);
|
||||||
|
assert.equal(renamed.json.username, "newowner");
|
||||||
|
assert.equal((await request(server.baseUrl, "/settings/profile", {jar})).json.username, "newowner");
|
||||||
|
assert.equal((await request(server.baseUrl, "/session", {jar})).json.user, "newowner");
|
||||||
|
assert.equal((await request(server.baseUrl, "/session", {jar: otherSession})).json.user, null);
|
||||||
|
assert.equal((await request(server.baseUrl, "/chats", {jar})).json.chats[0].id, chat.json.chat.id);
|
||||||
|
assert.equal((await request(server.baseUrl, "/personas", {jar})).json.userPersonas[0].id, persona.json.persona.id);
|
||||||
|
assert.equal(db.prepare("SELECT username FROM attachments WHERE id = ?").get(attachmentId).username, "newowner");
|
||||||
|
assert.deepEqual(db.pragma("foreign_key_check"), []);
|
||||||
|
|
||||||
|
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "newowner", password: "old-password"}, jar: otherSession})).status, 200);
|
||||||
|
assert.equal((await request(server.baseUrl, "/settings/password", {method: "PUT", body: {currentPassword: "wrong", newPassword: "new-password"}, jar})).status, 403);
|
||||||
|
assert.equal((await request(server.baseUrl, "/settings/password", {method: "PUT", body: {currentPassword: "old-password", newPassword: "new-password"}, jar})).status, 200);
|
||||||
|
assert.equal((await request(server.baseUrl, "/settings/profile", {jar})).status, 200);
|
||||||
|
assert.equal((await request(server.baseUrl, "/session", {jar: otherSession})).json.user, null);
|
||||||
|
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "newowner", password: "old-password"}})).status, 403);
|
||||||
|
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "newowner", password: "new-password"}})).status, 200);
|
||||||
|
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "accountowner", password: "new-password"}})).status, 400);
|
||||||
|
assert.equal((await request(server.baseUrl, "/settings/export", {jar})).json.account.username, "newowner");
|
||||||
|
});
|
||||||
|
|
||||||
test("admin diagnostics require admin and return model status", async () => {
|
test("admin diagnostics require admin and return model status", async () => {
|
||||||
const server = await startTestServer({
|
const server = await startTestServer({
|
||||||
config: {
|
config: {
|
||||||
@@ -769,9 +814,18 @@ test("chat search, organization, and workspace export work", async () => {
|
|||||||
jar
|
jar
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await request(server.baseUrl, "/chat", {
|
||||||
|
method: "POST",
|
||||||
|
body: {chatId, message: "another keyword passage", model: "mistral:latest"},
|
||||||
|
jar
|
||||||
|
});
|
||||||
|
|
||||||
const search = await request(server.baseUrl, "/chats/search?q=keyword", {jar});
|
const search = await request(server.baseUrl, "/chats/search?q=keyword", {jar});
|
||||||
assert.equal(search.status, 200);
|
assert.equal(search.status, 200);
|
||||||
assert.equal(search.json.chats.length, 1);
|
assert.equal(search.json.chats.length, 1);
|
||||||
|
assert.equal(search.json.chats[0].matched_content, "another keyword passage");
|
||||||
|
const messages = await request(server.baseUrl, `/chats/${chatId}/messages`, {jar});
|
||||||
|
assert.equal(search.json.chats[0].matched_message_id, messages.json.messages.find(message => message.content === "another keyword passage").id);
|
||||||
|
|
||||||
const organize = await request(server.baseUrl, `/chats/${chatId}/organization`, {
|
const organize = await request(server.baseUrl, `/chats/${chatId}/organization`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -833,6 +887,30 @@ test("chat search, organization, and workspace export work", async () => {
|
|||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("archiving frees an active chat slot and restoring respects the limit", async () => {
|
||||||
|
const server = await startTestServer({config: {chatLimit: 1}});
|
||||||
|
try {
|
||||||
|
const jar = new CookieJar();
|
||||||
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "archiver", password: "password123"}});
|
||||||
|
await request(server.baseUrl, "/login", {method: "POST", body: {username: "archiver", password: "password123"}, jar});
|
||||||
|
const first = await request(server.baseUrl, "/chats", {method: "POST", body: {title: "First"}, jar});
|
||||||
|
assert.equal(first.status, 200);
|
||||||
|
assert.equal((await request(server.baseUrl, "/chats", {method: "POST", body: {title: "Blocked"}, jar})).status, 400);
|
||||||
|
assert.equal((await request(server.baseUrl, `/chats/${first.json.chat.id}/organization`, {
|
||||||
|
method: "PUT", body: {archived: true}, jar
|
||||||
|
})).status, 200);
|
||||||
|
const second = await request(server.baseUrl, "/chats", {method: "POST", body: {title: "Second"}, jar});
|
||||||
|
assert.equal(second.status, 200);
|
||||||
|
assert.equal((await request(server.baseUrl, `/chats/${first.json.chat.id}/organization`, {
|
||||||
|
method: "PUT", body: {archived: false}, jar
|
||||||
|
})).status, 400);
|
||||||
|
await request(server.baseUrl, `/chats/${second.json.chat.id}/organization`, {method: "PUT", body: {archived: true}, jar});
|
||||||
|
assert.equal((await request(server.baseUrl, `/chats/${first.json.chat.id}/organization`, {
|
||||||
|
method: "PUT", body: {archived: false}, jar
|
||||||
|
})).status, 200);
|
||||||
|
} finally { await server.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
test("persona clone, version restore, and market feedback endpoints work", async () => {
|
test("persona clone, version restore, and market feedback endpoints work", async () => {
|
||||||
const server = await startTestServer();
|
const server = await startTestServer();
|
||||||
const jar = new CookieJar();
|
const jar = new CookieJar();
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Prüft Anhangsvorschauen, Upload-Zustände, Wiederholungen und das Verwerfen von Entwürfen.
|
||||||
|
*/
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { appendAttachments, createAttachmentComposer } from "../public/js/attachments.js";
|
import { appendAttachments, createAttachmentComposer } from "../public/js/attachments.js";
|
||||||
@@ -94,3 +97,32 @@ test("discarding a chat draft aborts upload, deletes temporary files, and serial
|
|||||||
composer.clear();
|
composer.clear();
|
||||||
assert.deepEqual(deletes, [`/attachments/${temporaryId}`], "Sent attachments are retained");
|
assert.deepEqual(deletes, [`/attachments/${temporaryId}`], "Sent attachments are retained");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("attachments stay with their chat while uploads finish after switching", async t => {
|
||||||
|
const originalDocument = globalThis.document, originalFetch = globalThis.fetch;
|
||||||
|
t.after(() => { globalThis.document = originalDocument; globalThis.fetch = originalFetch; });
|
||||||
|
const elements = Object.fromEntries(["attachImage", "imagePicker", "uploadPreviews", "composer"].map(id => [id, new Element()]));
|
||||||
|
globalThis.document = {getElementById: id => elements[id], querySelector: () => elements.composer, createElement: () => new Element()};
|
||||||
|
let completeUpload;
|
||||||
|
const deletes = [];
|
||||||
|
globalThis.fetch = (url) => {
|
||||||
|
if (url === "/uploads/config") return Promise.resolve(Response.json({maxFiles: 5, maxSizeMb: 10}));
|
||||||
|
if (url === "/uploads") return new Promise(resolve => { completeUpload = resolve; });
|
||||||
|
deletes.push(url);
|
||||||
|
return Promise.resolve(Response.json({success: true}));
|
||||||
|
};
|
||||||
|
const composer = createAttachmentComposer({onChange() {}, onError: message => assert.fail(message)});
|
||||||
|
elements.imagePicker.files = [new File(["fixture"], "draft.png", {type: "image/png"})];
|
||||||
|
elements.imagePicker.onchange();
|
||||||
|
composer.saveDraft(1);
|
||||||
|
composer.restoreDraft(2);
|
||||||
|
assert.equal(composer.attachments.length, 0);
|
||||||
|
const metadata = {id: "12345678-1234-1234-1234-123456789abc"};
|
||||||
|
completeUpload(Response.json(metadata));
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
assert.deepEqual(deletes, []);
|
||||||
|
composer.saveDraft(2);
|
||||||
|
composer.restoreDraft(1);
|
||||||
|
assert.deepEqual(composer.attachments, [metadata]);
|
||||||
|
composer.discard();
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Prüft Uploads, Zugriffsrechte und den Lebenszyklus von Bildanhängen sowie ihre Nutzung im Modellkontext.
|
||||||
|
*/
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
@@ -129,6 +132,37 @@ test("account rename retains ownership and account deletion removes pending file
|
|||||||
await assert.rejects(fs.access(path.join(s.directory, key)));
|
await assert.rejects(fs.access(path.join(s.directory, key)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("workspace archive restores image bytes for another account", async t => {
|
||||||
|
const s = await setup(t);
|
||||||
|
const bytes = await s.image("png");
|
||||||
|
const image = await (await s.upload(bytes)).json();
|
||||||
|
const chatId = await s.chat();
|
||||||
|
assert.equal((await s.api("/chat", {chatId, message: "What is in this image?", attachments: [image.id]})).status, 200);
|
||||||
|
const exported = await s.api("/exports/workspace/archive");
|
||||||
|
assert.equal(exported.status, 200);
|
||||||
|
assert.match(exported.headers.get("content-disposition"), /workspace-backup\.tar\.gz/);
|
||||||
|
const archive = await exported.blob();
|
||||||
|
const destination = await s.register("destination");
|
||||||
|
const form = () => {
|
||||||
|
const data = new FormData();
|
||||||
|
data.append("archive", archive, "workspace.tar.gz");
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
const base = new URL("/", exported.url).origin;
|
||||||
|
const preview = await fetch(base + "/imports/workspace/archive/preview", {method: "POST", headers: {Cookie: destination}, body: form()});
|
||||||
|
assert.equal(preview.status, 200);
|
||||||
|
assert.equal((await preview.json()).preview.chats, 1);
|
||||||
|
const imported = await fetch(base + "/imports/workspace/archive", {method: "POST", headers: {Cookie: destination}, body: form()});
|
||||||
|
assert.equal(imported.status, 200);
|
||||||
|
const workspace = await (await s.api("/exports/workspace", null, "GET", destination)).json();
|
||||||
|
const restored = workspace.workspace.chats[0].messages[0].attachments[0];
|
||||||
|
assert.notEqual(restored.id, image.id);
|
||||||
|
const download = await s.api(`/attachments/${restored.id}`, null, "GET", destination);
|
||||||
|
assert.equal(download.status, 200);
|
||||||
|
assert.deepEqual(Buffer.from(await download.arrayBuffer()), bytes);
|
||||||
|
assert.equal((await s.api(`/attachments/${restored.id}`)).status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
test("images feed dedicated vision, cache, streamed reasoning, reload and shared branch lifecycle", async t => {
|
test("images feed dedicated vision, cache, streamed reasoning, reload and shared branch lifecycle", async t => {
|
||||||
const s = await setup(t);
|
const s = await setup(t);
|
||||||
const uploads = [];
|
const uploads = [];
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Prüft Konfigurationsvorgaben und Sicherheitsanforderungen für Test- und Produktionsbetrieb.
|
||||||
|
*/
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import {createConfig} from "../src/config.js";
|
import {createConfig} from "../src/config.js";
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Prüft Antwortstreaming, Zeitlimits, Protokollfehler und Abbrüche bei getrennten Verbindungen.
|
||||||
|
*/
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Prüft Reasoning-Auswahl, getrennte Gedankenstreams und deren Speicherung sowie Darstellung.
|
||||||
|
*/
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Prüft Antwortvarianten und den Erhalt bestehender Antworten bei fehlgeschlagener Neugenerierung.
|
||||||
|
*/
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import {createServer} from "node:http";
|
import {createServer} from "node:http";
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Prüft die Neugenerierung innerhalb einer Nachrichtenkarte mit Scrollverhalten und Wiederherstellung.
|
||||||
|
*/
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import {readFile} from "node:fs/promises";
|
import {readFile} from "node:fs/promises";
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Prüft Schema, Anweisungen, Normalisierung und Fehlerbehandlung der Bildanalyse.
|
||||||
|
*/
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import {parseVisionAnalysis} from "../src/services/attachmentService.js";
|
import {parseVisionAnalysis} from "../src/services/attachmentService.js";
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* @file Prüft Zugriffsschutz und Diagnoseaufzeichnungen für Bildanalysen, Caches und Modellanfragen.
|
||||||
|
*/
|
||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import {createServer} from "node:http";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import {createApp} from "../src/app.js";
|
||||||
|
import {VISION_PROMPT_VERSION} from "../src/services/visionAnalysis.js";
|
||||||
|
|
||||||
|
async function listen(t, handler) {
|
||||||
|
const server = createServer(handler);
|
||||||
|
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
t.after(() => new Promise(resolve => { server.closeAllConnections(); server.close(resolve); }));
|
||||||
|
return {server, url: `http://127.0.0.1:${server.address().port}`};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup(t, visionDebug = true) {
|
||||||
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "elsewhere-vision-debug-"));
|
||||||
|
const requests = [];
|
||||||
|
const searchQueries = [];
|
||||||
|
const rawVisionResponse = ` \n${JSON.stringify({
|
||||||
|
scene: "A penguin stands on dark rocks.",
|
||||||
|
subjects: [{type: "bird", description: "Black crown and white throat", location: "center"}],
|
||||||
|
visualDetails: ["A thin black line runs directly beneath the chin."],
|
||||||
|
visibleText: [], spatialRelationships: [], colorsAndAppearance: ["black and white plumage"],
|
||||||
|
recognizedElements: ["penguin"], notableFeatures: ["thin black line beneath chin"], uncertainties: []
|
||||||
|
})}\n `;
|
||||||
|
const search = await listen(t, (req, res) => {
|
||||||
|
searchQueries.push(new URL(req.url, "http://localhost").searchParams.get("q"));
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({results: [{title: "Penguin identification guide", url: "https://example.com/penguins",
|
||||||
|
content: "A penguin identification guide compares crown, throat, bill, and chin markings."}]}));
|
||||||
|
});
|
||||||
|
const app = createApp({config: {
|
||||||
|
dbPath: ":memory:", uploadDir: directory, testMode: true, sessionSecret: "vision-debug-tests",
|
||||||
|
visionDebug, visionModel: "qwen2.5vl:7b", chatSummaryUpdateEveryMessages: 0,
|
||||||
|
searxngUrl: search.url, eventLoopLagMonitorEnabled: false,
|
||||||
|
slowRequestLoggingEnabled: false, structuredRequestLoggingEnabled: false
|
||||||
|
}, modelOverrides: {fetch: async (_url, options) => {
|
||||||
|
const body = JSON.parse(options.body);
|
||||||
|
requests.push(body);
|
||||||
|
const chunks = body.model === "qwen2.5vl:7b"
|
||||||
|
? [{message: {content: rawVisionResponse}, done: true}]
|
||||||
|
: [...(body.think ? [{message: {thinking: "Compare the supplied visible evidence."}}] : []),
|
||||||
|
{message: {content: "Final penguin answer."}, done: true}];
|
||||||
|
return new Response(chunks.map(chunk => JSON.stringify(chunk)).join("\n"));
|
||||||
|
}}});
|
||||||
|
const backend = await listen(t, app);
|
||||||
|
t.after(async () => { await app.locals.close(); await fs.rm(directory, {recursive: true, force: true}); });
|
||||||
|
const register = async username => {
|
||||||
|
const response = await fetch(backend.url + "/register", {method: "POST", headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify({username, password: "test-password"})});
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
return response.headers.get("set-cookie").split(";")[0];
|
||||||
|
};
|
||||||
|
const request = (url, {cookie = "", method = "GET", body, headers = {}} = {}) => fetch(backend.url + url, {
|
||||||
|
method, headers: {...headers, ...(cookie ? {Cookie: cookie} : {}), ...(body ? {"Content-Type": "application/json"} : {})},
|
||||||
|
body: body ? JSON.stringify(body) : undefined
|
||||||
|
});
|
||||||
|
const upload = async (cookie, bytes, filename = "penguin.png") => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("image", new Blob([bytes], {type: "image/png"}), filename);
|
||||||
|
const response = await fetch(backend.url + "/uploads", {method: "POST", headers: {Cookie: cookie}, body: form});
|
||||||
|
assert.equal(response.status, 201);
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
const createChat = async cookie => {
|
||||||
|
const response = await request("/chats", {cookie, method: "POST", body: {title: "Vision debug"}});
|
||||||
|
return (await response.json()).chat.id;
|
||||||
|
};
|
||||||
|
return {app, backend, register, request, upload, createChat, requests, searchQueries, rawVisionResponse};
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDiagnostic = async (s, cookie, requestId) => {
|
||||||
|
const response = await s.request(`/debug/vision/${requestId}`, {cookie});
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
return (await response.json()).diagnostic;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("vision diagnostics routes are absent when disabled and protected when enabled", async t => {
|
||||||
|
const disabled = await setup(t, false);
|
||||||
|
assert.equal(disabled.app.locals.config.visionDebug, false);
|
||||||
|
const disabledUser = await disabled.register("debug-disabled");
|
||||||
|
assert.equal((await disabled.request("/debug/vision/latest", {cookie: disabledUser})).status, 404);
|
||||||
|
|
||||||
|
const enabled = await setup(t, true);
|
||||||
|
assert.equal((await enabled.request("/debug/vision/latest")).status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("vision diagnostics capture misses, hits, Qwen evidence, search, reasoning and ownership safely", async t => {
|
||||||
|
const s = await setup(t, true);
|
||||||
|
const owner = await s.register("debug-owner");
|
||||||
|
const other = await s.register("debug-other");
|
||||||
|
const chatId = await s.createChat(owner);
|
||||||
|
const bytes = await sharp({create: {width: 24, height: 12, channels: 3, background: "black"}}).png().toBuffer();
|
||||||
|
const attachment = await s.upload(owner, bytes);
|
||||||
|
const question = "Search the web to identify this penguin.";
|
||||||
|
|
||||||
|
const first = await s.request("/chat", {cookie: owner, method: "POST", body: {
|
||||||
|
chatId, message: question, attachments: [attachment.id], model: "qwen3:14b", webSearch: false, reasoning: false
|
||||||
|
}});
|
||||||
|
assert.equal(first.status, 200);
|
||||||
|
const firstRequestId = first.headers.get("x-request-id");
|
||||||
|
const miss = await getDiagnostic(s, owner, firstRequestId);
|
||||||
|
assert.equal((await s.request(`/debug/vision/${firstRequestId}`, {cookie: other})).status, 404);
|
||||||
|
assert.equal(miss.requestId, firstRequestId);
|
||||||
|
assert.equal(miss.chatId, chatId);
|
||||||
|
assert.equal(miss.user, undefined);
|
||||||
|
assert.equal(miss.images.length, 1);
|
||||||
|
const image = miss.images[0];
|
||||||
|
assert.equal(image.attachmentId, attachment.id);
|
||||||
|
assert.equal(image.position, 0);
|
||||||
|
assert.equal(image.scope, "current");
|
||||||
|
assert.equal(image.label, "Current image 1");
|
||||||
|
assert.equal(image.originalFilename, "penguin.png");
|
||||||
|
assert.equal(image.mimeType, "image/png");
|
||||||
|
assert.equal(image.width, 24); assert.equal(image.height, 12); assert.equal(image.sizeBytes, bytes.length);
|
||||||
|
assert.equal(image.visionModel, "qwen2.5vl:7b");
|
||||||
|
assert.equal(image.visionPromptVersion, VISION_PROMPT_VERSION);
|
||||||
|
assert.equal(image.normalizedQuestion, question);
|
||||||
|
assert.equal(image.cacheHit, false);
|
||||||
|
assert.match(image.imageSha256, /^[a-f0-9]{64}$/);
|
||||||
|
assert.equal(image.rawVisionResponse, s.rawVisionResponse);
|
||||||
|
assert.equal(image.normalizedVisionAnalysis.scene, "A penguin stands on dark rocks.");
|
||||||
|
assert.equal(miss.qwen.visionEvidenceIncluded, true);
|
||||||
|
assert.equal(miss.qwen.imageCount, 1);
|
||||||
|
assert.deepEqual(miss.qwen.currentImageLabels, ["Current image 1"]);
|
||||||
|
assert.equal(miss.qwen.model, "qwen3:14b");
|
||||||
|
assert.equal(miss.qwen.think, false);
|
||||||
|
assert.equal(miss.qwen.stream, true);
|
||||||
|
assert.equal(miss.qwen.keepAlive, "30m");
|
||||||
|
const finalRequest = s.requests.findLast(request => request.model === "qwen3:14b");
|
||||||
|
assert.equal(miss.qwen.imageEvidence, finalRequest.messages.find(message => message.content.startsWith("<image_evidence>")).content);
|
||||||
|
assert.match(miss.qwen.visionGuidanceSha256, /^[a-f0-9]{64}$/);
|
||||||
|
assert.equal(miss.search.state, "disabled");
|
||||||
|
assert.equal(miss.visibleReasoning, null);
|
||||||
|
assert.equal(miss.finalAnswer, "Final penguin answer.");
|
||||||
|
const uploadedBase64 = s.requests.find(request => request.model === "qwen2.5vl:7b").messages.at(-1).images[0];
|
||||||
|
const diagnosticText = JSON.stringify(miss);
|
||||||
|
assert.equal(diagnosticText.includes(uploadedBase64), false);
|
||||||
|
assert.equal(diagnosticText.includes(bytes.toString("latin1")), false);
|
||||||
|
|
||||||
|
const messagesResponse = await s.request(`/chats/${chatId}/messages`, {cookie: owner});
|
||||||
|
const bot = (await messagesResponse.json()).messages.at(-1);
|
||||||
|
const retry = await s.request(`/chats/${chatId}/messages/${bot.id}/retry`, {cookie: owner, method: "POST", body: {
|
||||||
|
model: "qwen3:14b", webSearch: true, reasoning: true
|
||||||
|
}});
|
||||||
|
assert.equal(retry.status, 200);
|
||||||
|
const hit = await getDiagnostic(s, owner, retry.headers.get("x-request-id"));
|
||||||
|
assert.equal(hit.images[0].cacheHit, true);
|
||||||
|
assert.deepEqual(hit.images[0].cacheIdentity, {attachmentId: attachment.id, visionModel: "qwen2.5vl:7b",
|
||||||
|
visionPromptVersion: VISION_PROMPT_VERSION, normalizedQuestion: question});
|
||||||
|
assert.equal(hit.images[0].rawVisionResponse, null);
|
||||||
|
assert.equal(hit.images[0].imageSha256, null);
|
||||||
|
assert.deepEqual(hit.images[0].normalizedVisionAnalysis, image.normalizedVisionAnalysis);
|
||||||
|
assert.equal(hit.qwen.imageEvidence, miss.qwen.imageEvidence);
|
||||||
|
assert.equal(hit.qwen.think, true);
|
||||||
|
assert.equal(hit.visibleReasoning, "Compare the supplied visible evidence.");
|
||||||
|
assert.equal(hit.search.requested, true);
|
||||||
|
assert.equal(hit.search.needed, true);
|
||||||
|
assert.equal(hit.search.explicitSearchIntent, true);
|
||||||
|
assert.equal(hit.search.state, "succeeded");
|
||||||
|
assert.match(hit.search.visualReference, /thin black line beneath chin/i);
|
||||||
|
assert.deepEqual(hit.search.queries, s.searchQueries);
|
||||||
|
assert.equal(hit.search.candidateCount, 1);
|
||||||
|
assert.equal(hit.search.selectedCount, 1);
|
||||||
|
assert.deepEqual(hit.search.selectedSources[0], {index: 1, title: "Penguin identification guide",
|
||||||
|
url: "https://example.com/penguins", snippet: "A penguin identification guide compares crown, throat, bill, and chin markings."});
|
||||||
|
assert.equal(hit.finalAnswer, "Final penguin answer.");
|
||||||
|
const latest = await s.request("/debug/vision/latest", {cookie: owner});
|
||||||
|
assert.equal((await latest.json()).diagnostic.requestId, hit.requestId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("diagnostics distinguish skipped search and preserve multiple and historical image provenance", async t => {
|
||||||
|
const s = await setup(t, true);
|
||||||
|
const cookie = await s.register("debug-provenance");
|
||||||
|
const chatId = await s.createChat(cookie);
|
||||||
|
const red = await sharp({create: {width: 20, height: 10, channels: 3, background: "red"}}).png().toBuffer();
|
||||||
|
const blue = await sharp({create: {width: 30, height: 15, channels: 3, background: "blue"}}).png().toBuffer();
|
||||||
|
const first = await s.upload(cookie, red, "first.png");
|
||||||
|
const second = await s.upload(cookie, blue, "second.png");
|
||||||
|
const comparison = await s.request("/chat", {cookie, method: "POST", body: {
|
||||||
|
chatId, message: "What species are these?", attachments: [first.id, second.id], webSearch: true
|
||||||
|
}});
|
||||||
|
const current = await getDiagnostic(s, cookie, comparison.headers.get("x-request-id"));
|
||||||
|
assert.deepEqual(current.images.map(image => [image.attachmentId, image.position, image.scope, image.label]), [
|
||||||
|
[first.id, 0, "current", "Current image 1"], [second.id, 1, "current", "Current image 2"]
|
||||||
|
]);
|
||||||
|
assert.notEqual(current.images[0].imageSha256, current.images[1].imageSha256);
|
||||||
|
assert.equal(current.search.requested, true);
|
||||||
|
assert.equal(current.search.needed, false);
|
||||||
|
assert.equal(current.search.state, "skipped");
|
||||||
|
assert.deepEqual(current.search.queries, []);
|
||||||
|
assert.equal(current.search.candidateCount, 0);
|
||||||
|
|
||||||
|
const historicalResponse = await s.request("/chat", {cookie, method: "POST", body: {
|
||||||
|
chatId, message: "What did the previous image show?"
|
||||||
|
}});
|
||||||
|
const historical = await getDiagnostic(s, cookie, historicalResponse.headers.get("x-request-id"));
|
||||||
|
assert.equal(historical.images.length, 1);
|
||||||
|
assert.equal(historical.images[0].attachmentId, second.id);
|
||||||
|
assert.equal(historical.images[0].position, 1);
|
||||||
|
assert.equal(historical.images[0].scope, "historical");
|
||||||
|
assert.match(historical.images[0].label, /Earlier image from message/);
|
||||||
|
});
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Prüft Suchentscheidungen, SearXNG, Quellenauswahl und die Einbindung der Websuche in Chat und Oberfläche.
|
||||||
|
*/
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Sichert ab, dass explizite und datumsbezogene Websuchen vor der Modellgenerierung passende Quellen liefern.
|
||||||
|
*/
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import {createServer} from "node:http";
|
import {createServer} from "node:http";
|
||||||
|
|||||||
Reference in New Issue
Block a user