feat: My Resources has a menu, a library and three downloads
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 56s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m13s
Forgejo Docker Build / Build Docker image (push) Successful in 14s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 56s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m13s
Forgejo Docker Build / Build Docker image (push) Successful in 14s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
The pathway existed but was reachable only by API. It now has a tab of its own next to the Learning Hub — related, not the same thing, and sitting together is how someone discovers the difference — visible to every signed-in user with no role gate in the markup. Generate a deck or an article, see everything you have made, download each as PowerPoint, Word or PDF, delete what you no longer want. The screen says "Private to you" and "Nobody else sees these", because the distinction from published Learning content is the thing a person needs to understand before typing a patient's condition into it. Downloads are fetched rather than linked: an <a href> cannot carry the Authorization header. The blob is saved under the filename the server chose and the object URL is revoked afterwards. Resource titles come from a model, so rows are built as elements and a title is only ever assigned to textContent. The e2e stack now joins danvics_convert too. It could previously reach only Postgres and Redis, so a PDF download failed there in a way production would not — which did at least prove the degradation path works: with Gotenberg unreachable the response is "PDF conversion is unavailable right now. PowerPoint and Word still work", and the other two formats download unaffected. Verified in a browser as an ordinary user: the tab appears and opens, the form swaps slide count for word count when the format changes, the library lists their own work, and pptx, docx and pdf all download with sensible filenames (36360, 13285 and 68310 bytes). Also documents retrieval sizing in docs/retrieval-tuning.md — the per-feature budgets, and RERANKER_TOP_K, which caps all of them and had until now appeared in no configuration file at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
parent
a0d81789ff
commit
fac8757ce8
9 changed files with 490 additions and 0 deletions
|
|
@ -18,6 +18,12 @@ services:
|
|||
image: ped-ai-local:latest
|
||||
ports:
|
||||
- "127.0.0.1:3553:3000"
|
||||
networks:
|
||||
# Its own project network, plus the converter so PDF export is exercised
|
||||
# here too. Without this the e2e stack could only reach Postgres and
|
||||
# Redis, and a PDF download failed in a way production would not.
|
||||
- default
|
||||
- danvics_convert
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
|
@ -57,3 +63,7 @@ services:
|
|||
|
||||
volumes:
|
||||
scribe-logs-e2e:
|
||||
|
||||
networks:
|
||||
danvics_convert:
|
||||
external: true
|
||||
|
|
|
|||
|
|
@ -110,6 +110,9 @@ default in the right-hand column.
|
|||
| `clinical_assistant.image_model_roster` | Image models an admin added from Admin → Image Generation (**+ Add**). This is the pool the Image models tick-list offers; it is not itself an allowlist. Validated as up to 100 ids |
|
||||
| `clinical_assistant.search_limit` | Number of MCP results requested |
|
||||
| `clinical_assistant.context_chars` | Context characters requested from MCP |
|
||||
|
||||
These are capped by `RERANKER_TOP_K` in the MCP deployment, which is the real
|
||||
ceiling on every search. See [retrieval-tuning.md](retrieval-tuning.md).
|
||||
| `clinical_assistant.conversation_chars` | Input budget in UTF-16 code units. Empty means use `CLINICAL_ASSISTANT_CONVERSATION_CHARS`; a value must be 1000-1000000 |
|
||||
| `clinical_assistant.show_sources` | `true`/`false`. Display only: hides the Sources panel and the citation markers. The prompt, the retrieval and the stored answer are byte-for-byte identical either way, so it cannot bias an answer; turning it back on restores the citations |
|
||||
| `clinical_assistant.preview_enabled` | `true`/`false`. Lets signed-out visitors try the assistant read-only; anything needing an account asks them to sign in |
|
||||
|
|
|
|||
|
|
@ -198,3 +198,9 @@ OpenAI-compatible gateway — LiteLLM, Bifrost, or other proxies.
|
|||
| Bifrost | `provider/model` | Virtual keys, semantic caching, MCP gateway |
|
||||
| LiteLLM | Custom aliases | Requires PostgreSQL + Redis |
|
||||
| Any OpenAI-compatible | Varies | Must serve `/v1/chat/completions`, `/v1/audio/speech`, `/v1/audio/transcriptions`, `/v1/embeddings` |
|
||||
|
||||
## Retrieval sizing
|
||||
|
||||
How many corpus excerpts the Clinical Assistant, the Learning Hub and My
|
||||
Resources each receive, and the reranker cap that overrides all three:
|
||||
[retrieval-tuning.md](retrieval-tuning.md).
|
||||
|
|
|
|||
|
|
@ -85,3 +85,9 @@ available.
|
|||
| `learning_questions` | Quiz question prompts (FK to content) |
|
||||
| `learning_options` | Answer options (FK to question) |
|
||||
| `learning_progress` | Per-user attempt history |
|
||||
|
||||
## Retrieval sizing
|
||||
|
||||
How many corpus excerpts the Clinical Assistant, the Learning Hub and My
|
||||
Resources each receive, and the reranker cap that overrides all three:
|
||||
[retrieval-tuning.md](retrieval-tuning.md).
|
||||
|
|
|
|||
141
docs/retrieval-tuning.md
Normal file
141
docs/retrieval-tuning.md
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
# Retrieval tuning — how many excerpts each feature gets
|
||||
|
||||
Three features read from the same clinical corpus, and each takes a different
|
||||
amount of it. This is where the numbers live and what actually changes them.
|
||||
|
||||
Everything here is a Milvus collection called `mcp_bge_m3_1024`, embedded with
|
||||
`openrouter-bge-m3` at 1024 dimensions, searched through the clinical MCP
|
||||
(`clinical-assist-query`, deployed from `clinical-assist-deploy/`). There is one
|
||||
corpus. Only the budgets differ.
|
||||
|
||||
## The one number that caps everything
|
||||
|
||||
`RERANKER_TOP_K`, in `clinical-assist-deploy/docker-compose.yml`.
|
||||
|
||||
A search runs in two stages. Milvus returns a wide set of candidates by vector
|
||||
similarity, then a reranker (`cohere-rerank-v4.0-pro`) scores each against the
|
||||
query and keeps the best. `rerank_results()` takes `min(reranker_top_k, limit)`,
|
||||
so **this value is the ceiling on every search, regardless of what the caller
|
||||
asks for**. With it at 12, an app requesting 30 excerpts receives 12.
|
||||
|
||||
This caused real confusion before it was written down: it was a library default
|
||||
with no mention in any config file, so nothing explained where 12 came from.
|
||||
|
||||
```yaml
|
||||
# clinical-assist-deploy/docker-compose.yml — set on both mcp and mcp-indexer
|
||||
- RERANKER_TOP_K=${RERANKER_TOP_K:-12}
|
||||
- RERANKER_FETCH_MULTIPLIER=${RERANKER_FETCH_MULTIPLIER:-5}
|
||||
```
|
||||
|
||||
To change it:
|
||||
|
||||
```bash
|
||||
cd /home/danvics/docker/clinical-assist-deploy
|
||||
# either edit the default in docker-compose.yml, or set it in .env
|
||||
echo 'RERANKER_TOP_K=20' >> .env
|
||||
docker compose up -d mcp mcp-indexer
|
||||
docker inspect mcp-server-mcp-1 --format '{{range .Config.Env}}{{println .}}{{end}}' | grep RERANKER_TOP_K
|
||||
```
|
||||
|
||||
`RERANKER_FETCH_MULTIPLIER` decides how many candidates the reranker sees:
|
||||
`candidate_limit = max(limit, limit × multiplier)`. Raising it gives the
|
||||
reranker more to choose from at the cost of a larger Milvus query and a larger
|
||||
rerank call. 5 is the default and has not needed changing.
|
||||
|
||||
### Is 12 enough?
|
||||
|
||||
It is what the clinical assistant has always answered from, and 12 reranked
|
||||
excerpts at 2500 characters is roughly 23,000 characters of closely matched
|
||||
material — enough that a generated teaching resource reads with textbook
|
||||
specificity (bilirubin production rates, conjugation timelines, thresholds in
|
||||
mg/dL, all traceable to the indexed books).
|
||||
|
||||
Raising it to 30 was tried and reverted. The reranker exists precisely to
|
||||
discard near-misses; asking for more of what it already rejected adds length,
|
||||
not signal. Raise it if a topic is genuinely broad and the output feels thin —
|
||||
not by default.
|
||||
|
||||
## Per-feature budgets
|
||||
|
||||
These live in the `app_settings` table, are read live (2-minute cache), and are
|
||||
clamped on read so a bad value cannot break a search.
|
||||
|
||||
| Feature | Keys | Default | Clamp |
|
||||
|---|---|---|---|
|
||||
| Clinical Assistant | `clinical_assistant.search_limit`, `clinical_assistant.context_chars` | 8, 1400 | 3–20, 300–4000 |
|
||||
| Learning Hub | `learning.search_limit`, `learning.context_chars` | 30, 2500 | 3–60, 300–8000 |
|
||||
| My Resources | *the same `learning.*` keys* | 30, 2500 | 3–60, 300–8000 |
|
||||
|
||||
`search_limit` is how many excerpts to request; `context_chars` is how much text
|
||||
to pull around each one.
|
||||
|
||||
**My Resources shares the Learning budget deliberately.** Both generate a whole
|
||||
teaching resource from a topic, so they want the same shape of context. If they
|
||||
ever need to diverge, `src/utils/learningRetrieval.js` is the single place that
|
||||
reads these keys.
|
||||
|
||||
Why the assistant is so much smaller: a chat answer is a paragraph and the
|
||||
reader is waiting. A teaching resource synthesises an entire topic. Tuning one
|
||||
must never move the other, which is why they are separate keys rather than one
|
||||
shared pair.
|
||||
|
||||
To change one:
|
||||
|
||||
```sql
|
||||
-- from the postgres container
|
||||
INSERT INTO app_settings (key, value) VALUES ('learning.search_limit', '20')
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
|
||||
```
|
||||
|
||||
Remember the ceiling: setting `learning.search_limit` above `RERANKER_TOP_K`
|
||||
changes nothing. Raise the reranker cap first.
|
||||
|
||||
## Reading what actually happened
|
||||
|
||||
The MCP logs every search and what survived reranking:
|
||||
|
||||
```bash
|
||||
docker logs mcp-server-mcp-1 --since 10m 2>&1 | grep -E "reranked search|before reranking|unverified"
|
||||
# Milvus reranked search: user=..., limit=60, score_threshold=0.0, doc_type=file
|
||||
# Milvus candidate retrieval returned 600 results before reranking
|
||||
# Returning 12 unverified reranked results
|
||||
```
|
||||
|
||||
Note `limit=60` for a request of 30: `semantic.py` asks the algorithm for
|
||||
`limit × 2` and trims after verification.
|
||||
|
||||
Generation responses carry the same fact, so a caller never has to guess whether
|
||||
a resource was grounded:
|
||||
|
||||
```json
|
||||
"grounding": { "used": true, "count": 12, "reason": null }
|
||||
```
|
||||
|
||||
`used: false` with a `reason` means the resource was written from the model
|
||||
alone — retrieval never fails a generation, because ungrounded material is a far
|
||||
better outcome than an error page. The Learning screen and My Resources both
|
||||
show this, so ungrounded output is never presented as grounded.
|
||||
|
||||
## A caution on raising these
|
||||
|
||||
Context is not free and more is not automatically better.
|
||||
|
||||
* The prompt has to fit the model's window. 12 excerpts at 2500 characters is
|
||||
about 23k characters (~6k tokens); 30 at 2500 is about 57k (~14k). Overflow
|
||||
does not error — it truncates, and truncation lands in the middle of the
|
||||
excerpt block, which is the worst place to lose source material. If a resource
|
||||
starts ignoring obvious material, lower `context_chars` before suspecting the
|
||||
model.
|
||||
* Every excerpt past the reranker's confident set is a near-miss. Ten strong
|
||||
excerpts beat thirty mediocre ones for a model trying to write accurately.
|
||||
* The reranker is billed per call and scales with candidates, not results.
|
||||
`RERANKER_FETCH_MULTIPLIER` is the cost lever, not `RERANKER_TOP_K`.
|
||||
|
||||
## Where each number is read
|
||||
|
||||
| Number | Read by | File |
|
||||
|---|---|---|
|
||||
| `RERANKER_TOP_K` | clinical-assist | `clinical_assist/search/reranker.py` |
|
||||
| `RERANKER_FETCH_MULTIPLIER` | clinical-assist | `clinical_assist/search/milvus_reranked.py` |
|
||||
| `clinical_assistant.*` | ped-ai | `src/routes/clinicalAssistant.js` |
|
||||
| `learning.*` | ped-ai | `src/utils/learningRetrieval.js` |
|
||||
69
public/components/my-resources.html
Normal file
69
public/components/my-resources.html
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-folder-open"></i> My Resources</h3>
|
||||
<span style="font-size:12px;color:var(--g500);">Private to you</span>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:14px;">
|
||||
<p style="margin:0;padding:8px 10px;background:var(--g100);border-radius:6px;font-size:12px;color:var(--g600);">
|
||||
Teaching material you generate for yourself — a deck for tomorrow's session, a
|
||||
handout, a summary. Nobody else sees these. Published Learning Hub content is separate
|
||||
and stays with the moderators.
|
||||
</p>
|
||||
|
||||
<div class="admin-row">
|
||||
<label for="mr-topic" class="admin-row-label">Topic</label>
|
||||
<input id="mr-topic" type="text" class="admin-control" placeholder="e.g. febrile seizures in under-fives" maxlength="300">
|
||||
</div>
|
||||
|
||||
<div class="admin-row">
|
||||
<label for="mr-kind" class="admin-row-label">Format</label>
|
||||
<div style="flex:1;display:flex;gap:10px;align-items:center;flex-wrap:wrap;min-width:0;">
|
||||
<select id="mr-kind" class="admin-control" style="max-width:220px;">
|
||||
<option value="presentation">Presentation (slides)</option>
|
||||
<option value="article">Article (prose)</option>
|
||||
</select>
|
||||
<label style="font-size:12px;color:var(--g600);display:flex;align-items:center;gap:6px;">
|
||||
Slides
|
||||
<input id="mr-slide-count" type="number" min="3" max="30" value="8" style="width:70px;font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||
</label>
|
||||
<label style="font-size:12px;color:var(--g600);display:flex;align-items:center;gap:6px;" id="mr-word-wrap" hidden>
|
||||
Words
|
||||
<input id="mr-word-count" type="number" min="200" max="3000" step="100" value="800" style="width:90px;font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-row">
|
||||
<strong class="admin-row-label">Clinical library</strong>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:4px;min-width:0;">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;">
|
||||
<input type="checkbox" id="mr-use-corpus" checked>
|
||||
Write from the indexed clinical library
|
||||
</label>
|
||||
<p style="margin:0;font-size:12px;color:var(--g500);">
|
||||
Searches the corpus for your topic and writes from those excerpts, preferring them
|
||||
over the model's own recall, and ends with a References section. Turn this off for a
|
||||
topic the library does not cover.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-row" style="align-items:flex-start;">
|
||||
<label for="mr-refinement" class="admin-row-label">Instructions <span style="color:var(--g400);font-weight:400;">(optional)</span></label>
|
||||
<textarea id="mr-refinement" class="admin-control" style="min-height:70px;resize:vertical;font-family:inherit;" placeholder="e.g. for FY1s, case-based, emphasise red flags"></textarea>
|
||||
</div>
|
||||
|
||||
<div style="border-top:1px solid var(--g100);padding-top:12px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
|
||||
<button id="btn-mr-generate" class="btn-sm btn-primary" type="button"><i class="fas fa-wand-magic-sparkles"></i> Generate</button>
|
||||
<span id="mr-status" role="status" style="font-size:12px;color:var(--g600);"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:14px;">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-book"></i> Library</h3>
|
||||
<button id="btn-mr-refresh" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate"></i> Refresh</button>
|
||||
</div>
|
||||
<div id="mr-list" style="padding:12px 16px;display:flex;flex-direction:column;gap:6px;"></div>
|
||||
</div>
|
||||
|
|
@ -266,6 +266,10 @@
|
|||
<i class="fas fa-diagram-project"></i>
|
||||
<span>Diagrams</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="myresources">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<span>My Resources</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="learning">
|
||||
<i class="fas fa-graduation-cap"></i>
|
||||
<span>Learning Hub</span>
|
||||
|
|
@ -336,6 +340,7 @@
|
|||
<section id="calculators-tab" class="tab-content" data-component="calculators"></section>
|
||||
<section id="extensions-tab" class="tab-content" data-component="extensions"></section>
|
||||
<section id="notes-tab" class="tab-content" data-component="notes"></section>
|
||||
<section id="myresources-tab" class="tab-content" data-component="my-resources"></section>
|
||||
<section id="diagrams-tab" class="tab-content" data-component="diagrams"></section>
|
||||
<section id="assistant-tab" class="tab-content" data-component="assistant"></section>
|
||||
<section id="learning-tab" class="tab-content" data-component="learning"></section>
|
||||
|
|
@ -470,6 +475,7 @@
|
|||
crossorigin="anonymous" referrerpolicy="no-referrer" defer></script>
|
||||
<script defer src="/js/milestonesData.js"></script>
|
||||
<script src="/js/recordingModules.js"></script>
|
||||
<script src="/js/myResources.js" defer></script>
|
||||
<script type="module" src="/js/audioBackup.js"></script>
|
||||
<script type="module" src="/js/speechRecognition.js"></script>
|
||||
<script type="module" src="/js/transcriptionSettings.js"></script>
|
||||
|
|
|
|||
218
public/js/myResources.js
Normal file
218
public/js/myResources.js
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
// ============================================================
|
||||
// MY RESOURCES
|
||||
// A person's own generated teaching material.
|
||||
//
|
||||
// Separate from the Learning Hub on purpose: that is moderator-owned content
|
||||
// published into categories for everyone, this is private and needs no role
|
||||
// beyond being signed in. The server enforces that independently — every query
|
||||
// there filters on the owner — so this only has to be an honest interface to it.
|
||||
// ============================================================
|
||||
|
||||
(function () {
|
||||
var inited = false;
|
||||
|
||||
document.addEventListener('tabChanged', function (e) {
|
||||
if (!e.detail || e.detail.tab !== 'myresources') return;
|
||||
if (!inited) { init(); inited = true; }
|
||||
loadLibrary();
|
||||
});
|
||||
|
||||
function init() {
|
||||
var kind = document.getElementById('mr-kind');
|
||||
if (kind) kind.addEventListener('change', syncFormatFields);
|
||||
syncFormatFields();
|
||||
|
||||
var generate = document.getElementById('btn-mr-generate');
|
||||
if (generate) generate.addEventListener('click', runGenerate);
|
||||
|
||||
var refresh = document.getElementById('btn-mr-refresh');
|
||||
if (refresh) refresh.addEventListener('click', loadLibrary);
|
||||
|
||||
// One delegated handler: rows are rebuilt on every refresh, so binding per
|
||||
// row would leak listeners and miss anything added later.
|
||||
var list = document.getElementById('mr-list');
|
||||
if (list) list.addEventListener('click', onRowClick);
|
||||
}
|
||||
|
||||
function syncFormatFields() {
|
||||
var isArticle = (document.getElementById('mr-kind') || {}).value === 'article';
|
||||
var slides = document.getElementById('mr-slide-count');
|
||||
var words = document.getElementById('mr-word-wrap');
|
||||
if (slides && slides.parentElement) slides.parentElement.hidden = isArticle;
|
||||
if (words) words.hidden = !isArticle;
|
||||
}
|
||||
|
||||
function status(text, tone) {
|
||||
var el = document.getElementById('mr-status');
|
||||
if (!el) return;
|
||||
el.textContent = text || '';
|
||||
el.style.color = tone === 'bad' ? 'var(--red)' : tone === 'good' ? 'var(--green)' : 'var(--g600)';
|
||||
}
|
||||
|
||||
function runGenerate() {
|
||||
var topic = (document.getElementById('mr-topic') || {}).value || '';
|
||||
if (!topic.trim()) { status('Enter a topic first.', 'bad'); return; }
|
||||
|
||||
var btn = document.getElementById('btn-mr-generate');
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating...'; }
|
||||
status('Searching the library and writing. This takes a moment.');
|
||||
|
||||
var corpusBox = document.getElementById('mr-use-corpus');
|
||||
fetch('/api/my-resources/generate', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
topic: topic.trim(),
|
||||
kind: (document.getElementById('mr-kind') || {}).value || 'presentation',
|
||||
slideCount: (document.getElementById('mr-slide-count') || {}).value,
|
||||
wordCount: (document.getElementById('mr-word-count') || {}).value,
|
||||
refinement: (document.getElementById('mr-refinement') || {}).value || '',
|
||||
useCorpus: corpusBox && corpusBox.checked === false ? 'false' : 'true'
|
||||
})
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!data.success) throw new Error(data.error || 'Generation failed');
|
||||
// Say what it was written from. Ungrounded material presented as
|
||||
// grounded is the failure worth preventing.
|
||||
var g = data.grounding || {};
|
||||
status(g.used
|
||||
? 'Saved. Written from ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') + '.'
|
||||
: 'Saved. Not grounded' + (g.reason ? ' — ' + g.reason : '') + '; written from the model alone.',
|
||||
g.used ? 'good' : null);
|
||||
loadLibrary();
|
||||
})
|
||||
.catch(function (err) { status(err.message, 'bad'); })
|
||||
.finally(function () {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-wand-magic-sparkles"></i> Generate'; }
|
||||
});
|
||||
}
|
||||
|
||||
function loadLibrary() {
|
||||
var list = document.getElementById('mr-list');
|
||||
if (!list) return;
|
||||
fetch('/api/my-resources', { headers: getAuthHeaders() })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
list.textContent = '';
|
||||
var rows = (data && data.resources) || [];
|
||||
if (!rows.length) {
|
||||
var empty = document.createElement('p');
|
||||
empty.style.cssText = 'margin:0;font-size:13px;color:var(--g400);';
|
||||
empty.textContent = 'Nothing yet. Generate something above and it will appear here.';
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
rows.forEach(function (row) { list.appendChild(renderRow(row)); });
|
||||
})
|
||||
.catch(function () {
|
||||
list.textContent = '';
|
||||
var failed = document.createElement('p');
|
||||
failed.style.cssText = 'margin:0;font-size:13px;color:var(--red);';
|
||||
failed.textContent = 'Could not load your resources.';
|
||||
list.appendChild(failed);
|
||||
});
|
||||
}
|
||||
|
||||
// Built as elements rather than innerHTML: a title comes from a model, and
|
||||
// this is the one place it reaches the page.
|
||||
function renderRow(row) {
|
||||
var wrap = document.createElement('div');
|
||||
wrap.className = 'saved-enc-item';
|
||||
wrap.style.cssText = 'padding:8px 12px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;';
|
||||
|
||||
var body = document.createElement('div');
|
||||
body.style.flex = '1';
|
||||
body.style.minWidth = '180px';
|
||||
|
||||
var title = document.createElement('div');
|
||||
title.style.cssText = 'font-weight:600;font-size:13px;';
|
||||
title.textContent = row.title || 'Untitled';
|
||||
|
||||
var meta = document.createElement('div');
|
||||
meta.style.cssText = 'font-size:11px;color:var(--g500);';
|
||||
meta.textContent = (row.kind === 'article' ? 'Article' : 'Presentation') +
|
||||
' · ' + new Date(row.created_at).toLocaleString() +
|
||||
(row.grounded_count ? ' · ' + row.grounded_count + ' library excerpts' : ' · not grounded');
|
||||
|
||||
body.appendChild(title);
|
||||
body.appendChild(meta);
|
||||
wrap.appendChild(body);
|
||||
|
||||
['pptx', 'docx', 'pdf'].forEach(function (format) {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'btn-sm btn-ghost';
|
||||
btn.type = 'button';
|
||||
btn.dataset.download = String(row.id);
|
||||
btn.dataset.format = format;
|
||||
btn.textContent = format.toUpperCase();
|
||||
btn.title = 'Download as ' + format.toUpperCase();
|
||||
wrap.appendChild(btn);
|
||||
});
|
||||
|
||||
var del = document.createElement('button');
|
||||
del.className = 'btn-sm btn-ghost';
|
||||
del.type = 'button';
|
||||
del.dataset.remove = String(row.id);
|
||||
del.style.color = 'var(--red)';
|
||||
del.title = 'Delete';
|
||||
var icon = document.createElement('i');
|
||||
icon.className = 'fas fa-trash';
|
||||
del.appendChild(icon);
|
||||
wrap.appendChild(del);
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function onRowClick(event) {
|
||||
var download = event.target.closest && event.target.closest('[data-download]');
|
||||
if (download) return downloadResource(download.dataset.download, download.dataset.format, download);
|
||||
|
||||
var remove = event.target.closest && event.target.closest('[data-remove]');
|
||||
if (remove) {
|
||||
showConfirm('Delete this resource? This cannot be undone.', function () {
|
||||
fetch('/api/my-resources/' + encodeURIComponent(remove.dataset.remove), {
|
||||
method: 'DELETE', headers: getAuthHeaders()
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (!d.success) throw new Error(d.error || 'Could not delete');
|
||||
loadLibrary();
|
||||
})
|
||||
.catch(function (err) { showToast(err.message, 'error'); });
|
||||
}, { danger: true, confirmText: 'Delete' });
|
||||
}
|
||||
}
|
||||
|
||||
// Fetched rather than linked, because the download needs the auth header and
|
||||
// an <a href> cannot carry one.
|
||||
function downloadResource(id, format, btn) {
|
||||
var original = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '...';
|
||||
fetch('/api/my-resources/' + encodeURIComponent(id) + '/export?format=' + encodeURIComponent(format), {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Download failed'); });
|
||||
var name = 'resource.' + format;
|
||||
var disposition = r.headers.get('Content-Disposition') || '';
|
||||
var match = disposition.match(/filename="([^"]+)"/);
|
||||
if (match) name = match[1];
|
||||
return r.blob().then(function (blob) { saveBlob(blob, name); });
|
||||
})
|
||||
.catch(function (err) { showToast(err.message, 'error'); })
|
||||
.finally(function () { btn.disabled = false; btn.textContent = original; });
|
||||
}
|
||||
|
||||
function saveBlob(blob, name) {
|
||||
var url = URL.createObjectURL(blob);
|
||||
var link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
||||
}
|
||||
}());
|
||||
|
|
@ -78,3 +78,34 @@ test('a library has a ceiling, and generation says when it is reached', () => {
|
|||
// anyone else generating.
|
||||
assert.match(route, /SELECT COUNT\(\*\)::int AS n FROM user_resources WHERE user_id = \?/);
|
||||
});
|
||||
|
||||
test('the screen is reachable by anyone signed in, and states that it is private', () => {
|
||||
const index = read('public/index.html');
|
||||
const component = read('public/components/my-resources.html');
|
||||
|
||||
// A menu item of its own, next to the Learning Hub: related, not the same
|
||||
// thing, and sitting together is how someone discovers the difference.
|
||||
assert.match(index, /<button class="tab-btn" data-tab="myresources">/);
|
||||
assert.match(index, /<section id="myresources-tab" class="tab-content" data-component="my-resources">/);
|
||||
// No role gate in the markup: the tab button carries no hidden class, unlike
|
||||
// the admin and CMS ones which JavaScript reveals per role.
|
||||
const button = index.slice(index.indexOf('data-tab="myresources"') - 40, index.indexOf('data-tab="myresources"') + 40);
|
||||
assert.doesNotMatch(button, /hidden/, 'visible to every signed-in user');
|
||||
|
||||
assert.match(component, /Private to you/);
|
||||
assert.match(component, /Nobody else sees these/);
|
||||
});
|
||||
|
||||
test('a row offers all three formats, and the download carries its auth', () => {
|
||||
const js = read('public/js/myResources.js');
|
||||
assert.match(js, /\['pptx', 'docx', 'pdf'\]\.forEach/);
|
||||
// An <a href> cannot carry the Authorization header, so the file is fetched
|
||||
// and saved from a blob instead of linked.
|
||||
assert.match(js, /headers: getAuthHeaders\(\)/);
|
||||
assert.match(js, /filename="\(\[\^"\]\+\)"/, 'and keeps the name the server chose');
|
||||
assert.match(js, /URL\.revokeObjectURL\(url\)/, 'without leaking the object URL');
|
||||
|
||||
// Titles come from a model; this is where they reach the page.
|
||||
assert.match(js, /title\.textContent = row\.title \|\| 'Untitled';/);
|
||||
assert.doesNotMatch(js, /innerHTML\s*=\s*[^'"]*row\./, 'never interpolated into innerHTML');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue