release: 1.0.0

This commit is contained in:
Per Stark
2026-01-11 18:37:07 +01:00
parent db43be1606
commit 8fe4ac9fec
53 changed files with 757 additions and 630 deletions

View File

@@ -608,7 +608,7 @@
line-height: inherit;
}
.markdown-content :not(pre) > code {
.markdown-content :not(pre)>code {
background-color: rgba(0, 0, 0, 0.05);
color: var(--color-base-content);
padding: 0.15em 0.4em;
@@ -662,7 +662,7 @@
color: var(--color-base-content);
}
[data-theme="dark"] .markdown-content :not(pre) > code {
[data-theme="dark"] .markdown-content :not(pre)>code {
background-color: rgba(255, 255, 255, 0.12);
color: var(--color-base-content);
}
@@ -677,6 +677,136 @@
z-index: 9999;
box-shadow: var(--nb-shadow);
}
/* .nb-label: Uppercase, bold, tracking-wide, text-xs for section headers */
.nb-label {
@apply uppercase font-bold tracking-wide text-xs;
}
/* .nb-data: JetBrains Mono, tabular-nums for timestamps, IDs, badges */
.nb-data {
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
font-variant-numeric: tabular-nums;
}
/* The Stamp: Button :active state pushes into page */
.nb-btn:active {
transform: translate(2px, 2px) !important;
box-shadow: 2px 2px 0 0 #000 !important;
}
/* Staggered Card Dealing Animation */
@keyframes deal-in {
0% {
opacity: 0;
transform: translateY(12px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
/* Staggered deal-in animation - STRICTLY SCOPED to main content area */
main .nb-card,
main .nb-panel {
animation: deal-in 300ms var(--ease-mechanical, cubic-bezier(0.25, 1, 0.5, 1)) backwards;
}
/* Exclude elements that shouldn't animate even inside main */
main nav.nb-panel,
main .no-animation {
animation: none;
}
/* Apply staggered delays only to direct children of grids/lists or top-level containers */
main .nb-masonry>.nb-card:nth-child(1),
main .grid>.nb-panel:nth-child(1) {
animation-delay: 0ms;
}
main .nb-masonry>.nb-card:nth-child(2),
main .grid>.nb-panel:nth-child(2) {
animation-delay: 50ms;
}
main .nb-masonry>.nb-card:nth-child(3),
main .grid>.nb-panel:nth-child(3) {
animation-delay: 100ms;
}
main .nb-masonry>.nb-card:nth-child(4),
main .grid>.nb-panel:nth-child(4) {
animation-delay: 150ms;
}
main .nb-masonry>.nb-card:nth-child(5),
main .grid>.nb-panel:nth-child(5) {
animation-delay: 200ms;
}
main .nb-masonry>.nb-card:nth-child(6),
main .grid>.nb-panel:nth-child(6) {
animation-delay: 250ms;
}
main .nb-masonry>.nb-card:nth-child(7),
main .grid>.nb-panel:nth-child(7) {
animation-delay: 300ms;
}
main .nb-masonry>.nb-card:nth-child(8),
main .grid>.nb-panel:nth-child(8) {
animation-delay: 350ms;
}
main .nb-masonry>.nb-card:nth-child(n+9),
main .grid>.nb-panel:nth-child(n+9) {
animation-delay: 400ms;
}
/* HTMX Swap Fade-Up Animation */
@keyframes fade-up {
0% {
opacity: 0;
transform: translateY(8px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-up {
animation: fade-up 200ms var(--ease-mechanical, cubic-bezier(0.25, 1, 0.5, 1)) forwards;
}
/* Kinetic Input: Chat Armed State */
#chat-input:not(:placeholder-shown)~button {
filter: saturate(1.3) brightness(1.1);
}
#chat-input:not(:placeholder-shown) {
border-color: var(--color-accent);
}
/* Evidence Frame for images (Tufte treatment) */
.nb-evidence-frame {
@apply border-2 border-neutral m-2 bg-base-200;
}
.nb-evidence-frame img {
display: block;
width: 100%;
height: auto;
}
.nb-evidence-frame figcaption {
@apply text-xs px-2 py-1 border-t-2 border-neutral;
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
}
/* Theme-aware placeholder contrast tweaks */
@@ -691,6 +821,31 @@
color: rgba(255, 255, 255, 0.78) !important;
opacity: 0.85;
}
/* === DESIGN POLISHING: Receding Reality === */
/* Modal opens → background scales and blurs */
body:has(dialog[open]) #main-content-wrapper,
body.modal-open #main-content-wrapper {
transform: scale(0.98);
filter: blur(2px);
transition: transform 250ms var(--ease-mechanical, cubic-bezier(0.25, 1, 0.5, 1)),
filter 250ms var(--ease-mechanical, cubic-bezier(0.25, 1, 0.5, 1));
}
#main-content-wrapper {
transform: scale(1);
filter: blur(0);
transition: transform 250ms var(--ease-mechanical, cubic-bezier(0.25, 1, 0.5, 1)),
filter 250ms var(--ease-mechanical, cubic-bezier(0.25, 1, 0.5, 1));
}
/* === DESIGN POLISHING: Scroll-Linked Navbar Shadow === */
nav {
--scroll-depth: 0;
box-shadow: 4px calc(4px + var(--scroll-depth) * 4px) 0 0 #000;
transition: box-shadow 150ms ease;
}
}
/* satoshi.css */
@@ -714,6 +869,15 @@
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('fonts/JetBrainsMono-Regular.woff2') format('woff2'),
url('fonts/JetBrainsMono-Variable.ttf') format('truetype');
font-weight: 400 700;
font-style: normal;
font-display: swap;
}
/* Minimal override: prevent DaisyUI .menu hover bg on our nb buttons */
@layer utilities {
@@ -736,4 +900,4 @@
.toast-alert-title {
@apply text-lg font-bold;
}
}
}

View File

@@ -0,0 +1,199 @@
/**
* Design Polishing Pass - Interactive Effects
*
* Includes:
* - Scroll-Linked Navbar Shadow
* - HTMX Swap Animation
* - Typewriter AI Response
* - Rubberbanding Scroll
*/
(function() {
'use strict';
// === SCROLL-LINKED NAVBAR SHADOW ===
function initScrollShadow() {
const mainContent = document.querySelector('main');
const navbar = document.querySelector('nav');
if (!mainContent || !navbar) return;
mainContent.addEventListener('scroll', () => {
const scrollTop = mainContent.scrollTop;
const scrollHeight = mainContent.scrollHeight - mainContent.clientHeight;
const scrollDepth = scrollHeight > 0 ? Math.min(scrollTop / 200, 1) : 0;
navbar.style.setProperty('--scroll-depth', scrollDepth.toFixed(2));
}, { passive: true });
}
// === HTMX SWAP ANIMATION ===
function initHtmxSwapAnimation() {
document.body.addEventListener('htmx:afterSwap', (event) => {
let target = event.detail.target;
if (!target) return;
// If full body swap (hx-boost), animate only the main content
if (target.tagName === 'BODY') {
const main = document.querySelector('main');
if (main) target = main;
}
// Only animate if target is valid and inside/is main content or a card/panel
// Avoid animating sidebar or navbar updates
if (target && (target.tagName === 'MAIN' || target.closest('main'))) {
if (!target.classList.contains('animate-fade-up')) {
target.classList.add('animate-fade-up');
// Remove class after animation completes to allow re-animation
setTimeout(() => {
target.classList.remove('animate-fade-up');
}, 250);
}
}
});
}
// === TYPEWRITER AI RESPONSE ===
// Works with SSE streaming - buffers text and reveals character by character
window.initTypewriter = function(element, options = {}) {
const {
minDelay = 5,
maxDelay = 15,
showCursor = true
} = options;
let buffer = '';
let isTyping = false;
let cursorElement = null;
if (showCursor) {
cursorElement = document.createElement('span');
cursorElement.className = 'typewriter-cursor';
cursorElement.textContent = '▌';
cursorElement.style.animation = 'blink 1s step-end infinite';
element.appendChild(cursorElement);
}
function typeNextChar() {
if (buffer.length === 0) {
isTyping = false;
return;
}
isTyping = true;
const char = buffer.charAt(0);
buffer = buffer.slice(1);
// Insert before cursor
if (cursorElement && cursorElement.parentNode) {
const textNode = document.createTextNode(char);
element.insertBefore(textNode, cursorElement);
} else {
element.textContent += char;
}
const delay = minDelay + Math.random() * (maxDelay - minDelay);
setTimeout(typeNextChar, delay);
}
return {
append: function(text) {
buffer += text;
if (!isTyping) {
typeNextChar();
}
},
complete: function() {
// Flush remaining buffer immediately
if (cursorElement && cursorElement.parentNode) {
const textNode = document.createTextNode(buffer);
element.insertBefore(textNode, cursorElement);
cursorElement.remove();
} else {
element.textContent += buffer;
}
buffer = '';
isTyping = false;
}
};
};
// === RUBBERBANDING SCROLL ===
function initRubberbanding() {
const containers = document.querySelectorAll('#chat-scroll-container, .content-scroll-container');
containers.forEach(container => {
let startY = 0;
let pulling = false;
let pullDistance = 0;
const maxPull = 60;
const resistance = 0.4;
container.addEventListener('touchstart', (e) => {
startY = e.touches[0].clientY;
}, { passive: true });
container.addEventListener('touchmove', (e) => {
const currentY = e.touches[0].clientY;
const diff = currentY - startY;
// At top boundary, pulling down
if (container.scrollTop <= 0 && diff > 0) {
pulling = true;
pullDistance = Math.min(diff * resistance, maxPull);
container.style.transform = `translateY(${pullDistance}px)`;
}
// At bottom boundary, pulling up
else if (container.scrollTop + container.clientHeight >= container.scrollHeight && diff < 0) {
pulling = true;
pullDistance = Math.max(diff * resistance, -maxPull);
container.style.transform = `translateY(${pullDistance}px)`;
}
}, { passive: true });
container.addEventListener('touchend', () => {
if (pulling) {
container.style.transition = 'transform 300ms cubic-bezier(0.25, 1, 0.5, 1)';
container.style.transform = 'translateY(0)';
setTimeout(() => {
container.style.transition = '';
}, 300);
pulling = false;
pullDistance = 0;
}
}, { passive: true });
});
}
// === INITIALIZATION ===
function init() {
initScrollShadow();
initHtmxSwapAnimation();
initRubberbanding();
}
// Run on DOMContentLoaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// Re-init rubberbanding after HTMX navigations
document.body.addEventListener('htmx:afterSettle', () => {
initRubberbanding();
});
// Add typewriter cursor blink animation
const style = document.createElement('style');
style.textContent = `
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.typewriter-cursor {
color: var(--color-accent);
font-weight: bold;
}
`;
document.head.appendChild(style);
})();

Binary file not shown.

Binary file not shown.

View File

@@ -1,144 +0,0 @@
//==========================================================
// head-support.js
//
// An extension to add head tag merging.
//==========================================================
(function(){
var api = null;
function log() {
//console.log(arguments);
}
function mergeHead(newContent, defaultMergeStrategy) {
if (newContent && newContent.indexOf('<head') > -1) {
const htmlDoc = document.createElement("html");
// remove svgs to avoid conflicts
var contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
// extract head tag
var headTag = contentWithSvgsRemoved.match(/(<head(\s[^>]*>|>)([\s\S]*?)<\/head>)/im);
// if the head tag exists...
if (headTag) {
var added = []
var removed = []
var preserved = []
var nodesToAppend = []
htmlDoc.innerHTML = headTag;
var newHeadTag = htmlDoc.querySelector("head");
var currentHead = document.head;
if (newHeadTag == null) {
return;
} else {
// put all new head elements into a Map, by their outerHTML
var srcToNewHeadNodes = new Map();
for (const newHeadChild of newHeadTag.children) {
srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
}
}
// determine merge strategy
var mergeStrategy = api.getAttributeValue(newHeadTag, "hx-head") || defaultMergeStrategy;
// get the current head
for (const currentHeadElt of currentHead.children) {
// If the current head element is in the map
var inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
var isReAppended = currentHeadElt.getAttribute("hx-head") === "re-eval";
var isPreserved = api.getAttributeValue(currentHeadElt, "hx-preserve") === "true";
if (inNewContent || isPreserved) {
if (isReAppended) {
// remove the current version and let the new version replace it and re-execute
removed.push(currentHeadElt);
} else {
// this element already exists and should not be re-appended, so remove it from
// the new content map, preserving it in the DOM
srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
preserved.push(currentHeadElt);
}
} else {
if (mergeStrategy === "append") {
// we are appending and this existing element is not new content
// so if and only if it is marked for re-append do we do anything
if (isReAppended) {
removed.push(currentHeadElt);
nodesToAppend.push(currentHeadElt);
}
} else {
// if this is a merge, we remove this content since it is not in the new head
if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: currentHeadElt}) !== false) {
removed.push(currentHeadElt);
}
}
}
}
// Push the tremaining new head elements in the Map into the
// nodes to append to the head tag
nodesToAppend.push(...srcToNewHeadNodes.values());
log("to append: ", nodesToAppend);
for (const newNode of nodesToAppend) {
log("adding: ", newNode);
var newElt = document.createRange().createContextualFragment(newNode.outerHTML);
log(newElt);
if (api.triggerEvent(document.body, "htmx:addingHeadElement", {headElement: newElt}) !== false) {
currentHead.appendChild(newElt);
added.push(newElt);
}
}
// remove all removed elements, after we have appended the new elements to avoid
// additional network requests for things like style sheets
for (const removedElement of removed) {
if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: removedElement}) !== false) {
currentHead.removeChild(removedElement);
}
}
api.triggerEvent(document.body, "htmx:afterHeadMerge", {added: added, kept: preserved, removed: removed});
}
}
}
htmx.defineExtension("head-support", {
init: function(apiRef) {
// store a reference to the internal API.
api = apiRef;
htmx.on('htmx:afterSwap', function(evt){
let xhr = evt.detail.xhr;
if (xhr) {
var serverResponse = xhr.response;
if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) {
mergeHead(serverResponse, evt.detail.boosted ? "merge" : "append");
}
}
})
htmx.on('htmx:historyRestore', function(evt){
if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) {
if (evt.detail.cacheMiss) {
mergeHead(evt.detail.serverResponse, "merge");
} else {
mergeHead(evt.detail.item.head, "merge");
}
}
})
htmx.on('htmx:historyItemCreated', function(evt){
var historyItem = evt.detail.item;
historyItem.head = document.head.outerHTML;
})
}
});
})()

File diff suppressed because one or more lines are too long

View File

@@ -41,6 +41,7 @@ pub struct ScratchpadDetailData {
user: User,
scratchpad: ScratchpadDetail,
conversation_archive: Vec<Conversation>,
is_editing_title: bool,
}
#[derive(Serialize)]
@@ -180,8 +181,8 @@ pub async fn show_scratchpad_modal(
let scratchpad_detail = ScratchpadDetail::from(&scratchpad);
// Handle edit_title query parameter if needed in future
let _ = query.edit_title.unwrap_or(false);
// Handle edit_title query parameter
let is_editing_title = query.edit_title.unwrap_or(false);
Ok(TemplateResponse::new_template(
"scratchpad/editor_modal.html",
@@ -189,6 +190,7 @@ pub async fn show_scratchpad_modal(
user,
scratchpad: scratchpad_detail,
conversation_archive,
is_editing_title,
},
))
}
@@ -263,6 +265,7 @@ pub async fn update_scratchpad_title(
user,
scratchpad: ScratchpadDetail::from(&scratchpad),
conversation_archive,
is_editing_title: false,
},
))
}

View File

@@ -1,6 +1,7 @@
use std::{
collections::{HashMap, HashSet},
fmt, str::FromStr,
fmt,
str::FromStr,
};
use axum::{
@@ -198,59 +199,60 @@ pub async fn search_result_handler(
}
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
let (search_results_for_template, final_query_param_for_template) =
if let Some(actual_query) = params.query {
let trimmed_query = actual_query.trim();
if trimmed_query.is_empty() {
(Vec::<SearchResultForTemplate>::new(), String::new())
let (search_results_for_template, final_query_param_for_template) = if let Some(actual_query) =
params.query
{
let trimmed_query = actual_query.trim();
if trimmed_query.is_empty() {
(Vec::<SearchResultForTemplate>::new(), String::new())
} else {
// Use retrieval pipeline Search strategy
let config = RetrievalConfig::for_search(SearchTarget::Both);
// Checkout a reranker lease if pool is available
let reranker_lease = match &state.reranker_pool {
Some(pool) => Some(pool.checkout().await),
None => None,
};
let result = retrieval_pipeline::pipeline::run_pipeline(
&state.db,
&state.openai_client,
Some(&state.embedding_provider),
trimmed_query,
&user.id,
config,
reranker_lease,
)
.await?;
let search_result = match result {
StrategyOutput::Search(sr) => sr,
_ => SearchResult::new(vec![], vec![]),
};
let mut source_ids = HashSet::new();
for chunk_result in &search_result.chunks {
source_ids.insert(chunk_result.chunk.source_id.clone());
}
for entity_result in &search_result.entities {
source_ids.insert(entity_result.entity.source_id.clone());
}
let source_label_map = if source_ids.is_empty() {
HashMap::new()
} else {
// Use retrieval pipeline Search strategy
let config = RetrievalConfig::for_search(SearchTarget::Both);
// Checkout a reranker lease if pool is available
let reranker_lease = match &state.reranker_pool {
Some(pool) => Some(pool.checkout().await),
None => None,
};
let result = retrieval_pipeline::pipeline::run_pipeline(
&state.db,
&state.openai_client,
Some(&state.embedding_provider),
trimmed_query,
&user.id,
config,
reranker_lease,
)
.await?;
let search_result = match result {
StrategyOutput::Search(sr) => sr,
_ => SearchResult::new(vec![], vec![]),
};
let mut source_ids = HashSet::new();
for chunk_result in &search_result.chunks {
source_ids.insert(chunk_result.chunk.source_id.clone());
}
for entity_result in &search_result.entities {
source_ids.insert(entity_result.entity.source_id.clone());
}
let source_label_map = if source_ids.is_empty() {
HashMap::new()
} else {
let record_ids: Vec<RecordId> = source_ids
.iter()
.filter_map(|id| {
if id.contains(':') {
RecordId::from_str(id).ok()
} else {
Some(RecordId::from_table_key(TextContent::table_name(), id))
}
})
.collect();
let mut response = state
let record_ids: Vec<RecordId> = source_ids
.iter()
.filter_map(|id| {
if id.contains(':') {
RecordId::from_str(id).ok()
} else {
Some(RecordId::from_table_key(TextContent::table_name(), id))
}
})
.collect();
let mut response = state
.db
.client
.query(
@@ -260,84 +262,84 @@ pub async fn search_result_handler(
.bind(("user_id", user.id.clone()))
.bind(("record_ids", record_ids))
.await?;
let contents: Vec<SourceLabelRow> = response.take(0)?;
let contents: Vec<SourceLabelRow> = response.take(0)?;
tracing::debug!(
source_id_count = source_ids.len(),
label_row_count = contents.len(),
"Resolved search source labels"
tracing::debug!(
source_id_count = source_ids.len(),
label_row_count = contents.len(),
"Resolved search source labels"
);
let mut labels = HashMap::new();
for content in contents {
let label = build_source_label(&content);
labels.insert(content.id.clone(), label.clone());
labels.insert(
format!("{}:{}", TextContent::table_name(), content.id),
label,
);
}
let mut labels = HashMap::new();
for content in contents {
let label = build_source_label(&content);
labels.insert(content.id.clone(), label.clone());
labels.insert(
format!("{}:{}", TextContent::table_name(), content.id),
label,
);
}
labels
};
labels
};
let mut combined_results: Vec<SearchResultForTemplate> =
Vec::with_capacity(search_result.chunks.len() + search_result.entities.len());
let mut combined_results: Vec<SearchResultForTemplate> =
Vec::with_capacity(search_result.chunks.len() + search_result.entities.len());
// Add chunk results
for chunk_result in search_result.chunks {
let source_label = source_label_map
.get(&chunk_result.chunk.source_id)
.cloned()
.unwrap_or_else(|| fallback_source_label(&chunk_result.chunk.source_id));
combined_results.push(SearchResultForTemplate {
result_type: "text_chunk".to_string(),
// Add chunk results
for chunk_result in search_result.chunks {
let source_label = source_label_map
.get(&chunk_result.chunk.source_id)
.cloned()
.unwrap_or_else(|| fallback_source_label(&chunk_result.chunk.source_id));
combined_results.push(SearchResultForTemplate {
result_type: "text_chunk".to_string(),
score: chunk_result.score,
text_chunk: Some(TextChunkForTemplate {
id: chunk_result.chunk.id,
source_id: chunk_result.chunk.source_id,
source_label,
chunk: chunk_result.chunk.chunk,
score: chunk_result.score,
text_chunk: Some(TextChunkForTemplate {
id: chunk_result.chunk.id,
source_id: chunk_result.chunk.source_id,
source_label,
chunk: chunk_result.chunk.chunk,
score: chunk_result.score,
}),
knowledge_entity: None,
});
}
// Add entity results
for entity_result in search_result.entities {
let source_label = source_label_map
.get(&entity_result.entity.source_id)
.cloned()
.unwrap_or_else(|| fallback_source_label(&entity_result.entity.source_id));
combined_results.push(SearchResultForTemplate {
result_type: "knowledge_entity".to_string(),
score: entity_result.score,
text_chunk: None,
knowledge_entity: Some(KnowledgeEntityForTemplate {
id: entity_result.entity.id,
name: entity_result.entity.name,
description: entity_result.entity.description,
entity_type: format!("{:?}", entity_result.entity.entity_type),
source_id: entity_result.entity.source_id,
source_label,
score: entity_result.score,
}),
});
}
// Sort by score descending
combined_results.sort_by(|a, b| b.score.total_cmp(&a.score));
// Limit results
const TOTAL_LIMIT: usize = 10;
combined_results.truncate(TOTAL_LIMIT);
(combined_results, trimmed_query.to_string())
}),
knowledge_entity: None,
});
}
} else {
(Vec::<SearchResultForTemplate>::new(), String::new())
};
// Add entity results
for entity_result in search_result.entities {
let source_label = source_label_map
.get(&entity_result.entity.source_id)
.cloned()
.unwrap_or_else(|| fallback_source_label(&entity_result.entity.source_id));
combined_results.push(SearchResultForTemplate {
result_type: "knowledge_entity".to_string(),
score: entity_result.score,
text_chunk: None,
knowledge_entity: Some(KnowledgeEntityForTemplate {
id: entity_result.entity.id,
name: entity_result.entity.name,
description: entity_result.entity.description,
entity_type: format!("{:?}", entity_result.entity.entity_type),
source_id: entity_result.entity.source_id,
source_label,
score: entity_result.score,
}),
});
}
// Sort by score descending
combined_results.sort_by(|a, b| b.score.total_cmp(&a.score));
// Limit results
const TOTAL_LIMIT: usize = 10;
combined_results.truncate(TOTAL_LIMIT);
(combined_results, trimmed_query.to_string())
}
} else {
(Vec::<SearchResultForTemplate>::new(), String::new())
};
Ok(TemplateResponse::new_template(
"search/base.html",

View File

@@ -1,5 +1,7 @@
{% extends "modal_base.html" %}
{% block modal_class %}max-w-3xl{% endblock %}
{% block form_attributes %}
hx-patch="/update-image-prompt"
hx-target="#system_prompt_section"

View File

@@ -1,5 +1,7 @@
{% extends "modal_base.html" %}
{% block modal_class %}max-w-3xl{% endblock %}
{% block form_attributes %}
hx-patch="/update-ingestion-prompt"
hx-target="#system_prompt_section"

View File

@@ -1,5 +1,7 @@
{% extends "modal_base.html" %}
{% block modal_class %}max-w-3xl{% endblock %}
{% block form_attributes %}
hx-patch="/update-query-prompt"
hx-target="#system_prompt_section"

View File

@@ -2,8 +2,8 @@
{% block body %}
<body class="relative" hx-ext="head-support">
<div class="drawer lg:drawer-open">
<body class="relative">
<div id="main-content-wrapper" class="drawer lg:drawer-open">
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
<!-- Page Content -->
<div class="drawer-content flex flex-col h-screen">
@@ -14,6 +14,7 @@
{% block main %}{% endblock %}
<div class="p32 min-h-[10px]"></div>
</main>
{% block overlay %}{% endblock %}
</div>
<!-- Sidebar -->
{% if user %}

View File

@@ -2,10 +2,6 @@
{% block title %}Minne - Chat{% endblock %}
{% block head %}
<script src="/assets/htmx-ext-sse.js" defer></script>
{% endblock %}
{% block main %}
<div class="flex grow relative justify-center mt-2 sm:mt-4">
<div class="container">
@@ -17,32 +13,69 @@
</section>
<div id="chat-scroll-container" class="overflow-auto hide-scrollbar">
{% include "chat/history.html" %}
{% include "chat/new_message_form.html" %}
</div>
</div>
</div>
<script>
function doScrollChatToBottom() {
const mainScroll = document.querySelector('main');
if (mainScroll) mainScroll.scrollTop = mainScroll.scrollHeight;
const chatScroll = document.getElementById('chat-scroll-container');
if (chatScroll) chatScroll.scrollTop = chatScroll.scrollHeight;
const chatContainer = document.getElementById('chat_container');
if (chatContainer) chatContainer.scrollTop = chatContainer.scrollHeight;
window.scrollTo(0, document.body.scrollHeight);
}
function scrollChatToBottom() {
requestAnimationFrame(() => {
const mainScroll = document.querySelector('main');
if (mainScroll) mainScroll.scrollTop = mainScroll.scrollHeight;
const chatScroll = document.getElementById('chat-scroll-container');
if (chatScroll) chatScroll.scrollTop = chatScroll.scrollHeight;
const chatContainer = document.getElementById('chat_container');
if (chatContainer) chatContainer.scrollTop = chatContainer.scrollHeight;
window.scrollTo(0, document.body.scrollHeight);
});
if (!window.location.pathname.startsWith('/chat')) return;
requestAnimationFrame(doScrollChatToBottom);
}
window.scrollChatToBottom = scrollChatToBottom;
document.addEventListener('DOMContentLoaded', scrollChatToBottom);
// Delay initial scroll to avoid interfering with view transition
document.addEventListener('DOMContentLoaded', () => setTimeout(scrollChatToBottom, 350));
document.body.addEventListener('htmx:afterSwap', scrollChatToBottom);
document.body.addEventListener('htmx:afterSettle', scrollChatToBottom);
function handleChatSwap(e) {
if (!window.location.pathname.startsWith('/chat')) return;
// Full page swap: delay for view transition; partial swap: immediate
if (e.detail && e.detail.target && e.detail.target.tagName === 'BODY') {
setTimeout(scrollChatToBottom, 350);
} else {
scrollChatToBottom();
}
}
function cleanupChatListeners(e) {
if (e.detail && e.detail.target && e.detail.target.tagName === 'BODY') {
document.body.removeEventListener('htmx:afterSwap', window._chatEventHandlers.afterSwap);
document.body.removeEventListener('htmx:afterSettle', window._chatEventHandlers.afterSettle);
document.body.removeEventListener('htmx:beforeSwap', window._chatEventHandlers.beforeSwap);
delete window._chatEventHandlers;
window._chatListenersAttached = false;
}
}
window._chatEventHandlers = {
afterSwap: handleChatSwap,
afterSettle: handleChatSwap,
beforeSwap: cleanupChatListeners
};
if (!window._chatListenersAttached) {
document.body.addEventListener('htmx:afterSwap', window._chatEventHandlers.afterSwap);
document.body.addEventListener('htmx:afterSettle', window._chatEventHandlers.afterSettle);
document.body.addEventListener('htmx:beforeSwap', window._chatEventHandlers.beforeSwap);
window._chatListenersAttached = true;
}
</script>
{% endblock %}
{% block overlay %}
{% include "chat/new_message_form.html" %}
{% endblock %}

View File

@@ -1,6 +1,6 @@
<div class="fixed bottom-0 left-0 right-0 lg:left-72 z-20">
<div class="mx-auto max-w-3xl px-4 pb-3">
<div class="nb-panel p-2">
<div class="nb-panel p-2 no-animation">
<form hx-post="{% if conversation %} /chat/{{conversation.id}} {% else %} /chat {% endif %}"
hx-target="#chat_container" hx-swap="beforeend" class="relative flex gap-2 items-end" id="chat-form">
<textarea autofocus required name="content" placeholder="Type your message…" rows="3"

View File

@@ -8,15 +8,15 @@
{% if text_contents|length > 0 %}
<div class="nb-masonry w-full">
{% for text_content in text_contents %}
<article class="nb-card cursor-pointer mx-auto mb-4 w-full max-w-[92vw] space-y-3 sm:max-w-none"
<article class="nb-card cursor-pointer mx-auto mb-4 w-full space-y-3"
hx-get="/content/{{ text_content.id }}/read" hx-target="#modal" hx-swap="innerHTML">
{% if text_content.url_info %}
<figure class="-mx-4 -mt-4 border-b-2 border-neutral bg-base-200">
<figure class="nb-evidence-frame -mx-4 -mt-4 mb-3">
<img class="w-full h-auto" src="/file/{{text_content.url_info.image_id}}" alt="website screenshot" />
</figure>
{% endif %}
{% if text_content.file_info.mime_type == "image/png" or text_content.file_info.mime_type == "image/jpeg" %}
<figure class="-mx-4 -mt-4 border-b-2 border-neutral bg-base-200">
<figure class="nb-evidence-frame -mx-4 -mt-4 mb-3">
<img class="w-full h-auto" src="/file/{{text_content.file_info.id}}" alt="{{text_content.file_info.file_name}}" />
</figure>
{% endif %}
@@ -31,10 +31,10 @@
{% endif %}
</h2>
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-xs opacity-60 shrink-0">
<p class="nb-data text-xs opacity-60 shrink-0">
{{ text_content.created_at | datetimeformat(format="short", tz=user.timezone) }}
</p>
<span class="nb-badge">{{ text_content.category }}</span>
<span class="nb-badge nb-data">{{ text_content.category }}</span>
<div class="flex gap-2" hx-on:click="event.stopPropagation()">
{% if text_content.url_info %}
<a href="{{text_content.url_info.url}}" target="_blank" rel="noopener noreferrer"

View File

@@ -2,10 +2,6 @@
{% block title %}Minne - Dashboard{% endblock %}
{% block head %}
<script src="/assets/htmx-ext-sse.js" defer></script>
{% endblock %}
{% block main %}
<div class="flex justify-center grow mt-2 sm:mt-4 pb-4 w-full">
<div class="container">

View File

@@ -16,11 +16,15 @@
<!-- Scripts -->
<script src="/assets/htmx.min.js" defer></script>
<script src="/assets/htmx-ext-sse.js" defer></script>
<script src="/assets/theme-toggle.js" defer></script>
<script src="/assets/toast.js" defer></script>
<script src="/assets/htmx-head-ext.js" defer></script>
<script src="/assets/marked.min.js" defer></script>
<script src="/assets/knowledge-graph.js" defer></script>
<script src="/assets/design-polish.js" defer></script>
<!-- Global View Transition -->
<meta name="view-transition" content="same-origin" />
<!-- Icons -->
<link rel="icon" href="/assets/icon/favicon.ico">
@@ -38,6 +42,7 @@
(function wait_for_htmx() {
if (window.htmx) {
htmx.config.globalViewTransitions = true;
htmx.config.selfRequestsOnly = false;
} else {
setTimeout(wait_for_htmx, 50);
}

View File

@@ -1,41 +1,78 @@
{% extends "modal_base.html" %}
{% block modal_class %}max-w-3xl{% endblock %}
{% block form_attributes %}
hx-post="/ingress-form"
enctype="multipart/form-data"
{% endblock %}
{% block modal_content %}
<h3 class="text-xl font-extrabold tracking-tight">Add New Content</h3>
<div class="flex flex-col gap-3">
<h3 class="text-xl font-extrabold tracking-tight pr-8">Add New Content</h3>
<div class="flex flex-col">
<!-- Content Source -->
<label class="w-full">
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Content</div>
<textarea name="content" class="nb-input w-full min-h-28"
<div class="nb-label mb-1">Content</div>
<textarea name="content" class="nb-input w-full" rows="4" autofocus
placeholder="Paste a URL or type/paste text to ingest…">{{ content }}</textarea>
</label>
<label class="w-full">
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Context</div>
<textarea name="context" class="nb-input w-full min-h-24"
placeholder="Optional: add context to guide how the content should be interpreted…">{{ context }}</textarea>
<!-- Context (Optional) -->
<label class="w-full mt-6">
<div class="nb-label mb-1 flex justify-between items-center">
<span>Context</span>
<!-- Tufte-style annotation: clean, small caps, structural -->
<span class="text-[10px] tracking-widest uppercase border border-neutral px-1.5 py-px bg-transparent opacity-60">Optional</span>
</div>
<textarea name="context" class="nb-input w-full" rows="2"
placeholder="Guide how this content should be interpreted…">{{ context }}</textarea>
</label>
<label class="w-full">
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Category</div>
<input type="text" name="category" class="nb-input validator w-full" value="{{ category }}" list="category-list" required />
<datalist id="category-list">
{% for category in user_categories %}
<option value="{{ category }}" />
{% endfor %}
</datalist>
<div class="validator-hint hidden text-xs opacity-70 mt-1">Category is required</div>
</label>
<!-- Metadata Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-x-12 gap-y-8 items-start mt-6">
<!-- Category -->
<label class="w-full">
<div class="nb-label mb-1">Category <span class="text-error font-bold" title="Required">*</span></div>
<div class="relative">
<input type="text" name="category" class="nb-input validator w-full pr-8" value="{{ category }}" list="category-list" required placeholder="Select or type..." />
<div class="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none opacity-50">
{% include "icons/chevron_icon.html" %}
</div>
</div>
<datalist id="category-list">
{% for category in user_categories %}
<option value="{{ category }}" />
{% endfor %}
</datalist>
<div class="validator-hint hidden text-xs opacity-70 mt-1 text-error">Category is required</div>
</label>
<label class="w-full">
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Files</div>
<input type="file" name="files" multiple class="file-input w-full rounded-none border-2 border-neutral" />
</label>
<!-- Dimensional File Drop Zone -->
<div class="w-full">
<div class="nb-label mb-1">Files</div>
<!-- "Card" style dropzone: solid border, hard shadow, lift on hover -->
<div class="relative w-full h-32 group bg-base-100 border-2 border-neutral shadow-[4px_4px_0_0_#000] hover:translate-x-[-1px] hover:translate-y-[-1px] hover:shadow-[6px_6px_0_0_#000] transition-all duration-150">
<!-- Visual Facade -->
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 text-sm font-medium text-neutral pointer-events-none">
<div class="p-2 border-2 border-neutral rounded-none bg-base-200 group-hover:bg-base-100 transition-colors">
<span class="w-6 h-6 block">{% include "icons/document_icon.html" %}</span>
</div>
<span id="file-label-text" class="text-center px-4 text-xs uppercase tracking-wide">Drop files or click</span>
</div>
<!-- Actual Input -->
<input type="file" name="files" multiple
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onchange="const count = this.files.length; document.getElementById('file-label-text').innerText = count > 0 ? count + ' FILE' + (count !== 1 ? 'S' : '') + ' SELECTED' : 'DROP FILES OR CLICK';" />
</div>
</div>
</div>
</div>
<div id="error-message" class="text-error text-center {% if not error %}hidden{% endif %}">{{ error }}</div>
<script>
(function () {
const form = document.getElementById('modal_form');
@@ -51,8 +88,9 @@ enctype="multipart/form-data"
})();
</script>
{% endblock %}
{% block primary_actions %}
<button type="submit" class="nb-btn nb-cta">
<button type="submit" class="nb-btn nb-cta w-full sm:w-auto">
Add Content
</button>
{% endblock %}

View File

@@ -1,15 +1,24 @@
<dialog id="body_modal" class="modal">
<div
class="modal-box rounded-none border-2 border-neutral bg-base-100 shadow-[8px_8px_0_0_#000] {% block modal_class %}{% endblock %}">
class="modal-box relative rounded-none border-2 border-neutral bg-base-100 shadow-[8px_8px_0_0_#000] p-6 {% block modal_class %}max-w-lg{% endblock %}">
<!-- God Level UX: Explicit Escape Hatch -->
<button type="button"
class="btn btn-sm btn-square btn-ghost absolute right-2 top-2 z-10"
onclick="document.getElementById('body_modal').close()"
aria-label="Close modal">
{% include "icons/x_icon.html" %}
</button>
<form id="modal_form" {% block form_attributes %}{% endblock %}>
<div class="flex flex-col flex-1 gap-4">
<div class="flex flex-col flex-1 gap-5">
{% block modal_content %}{% endblock %}
</div>
<div class="u-hairline mt-4 pt-3 flex flex-col gap-2 sm:flex-row sm:justify-end sm:items-center">
<!-- Close button (always visible) -->
<button type="button" class="nb-btn w-full sm:w-auto" onclick="document.getElementById('body_modal').close()">
Close
<div class="mt-8 pt-2 flex flex-col gap-2 sm:flex-row sm:justify-end sm:items-center">
<!-- Secondary Action: Ghost style to reduce noise -->
<button type="button" class="btn btn-ghost rounded-none w-full sm:w-auto hover:bg-neutral/10" onclick="document.getElementById('body_modal').close()">
Cancel
</button>
<!-- Primary actions block -->

View File

@@ -1,4 +1,4 @@
<nav class="sticky top-0 z-10 nb-panel nb-panel-canvas border-t-0">
<nav class="sticky top-0 z-10 nb-panel nb-panel-canvas border-t-0" style="view-transition-name: navbar; contain: layout;">
<div class="container mx-auto navbar">
<div class="mr-2 flex-1">
{% include "searchbar.html" %}

View File

@@ -6,7 +6,7 @@
{% block modal_content %}
<h3 class="text-xl font-extrabold tracking-tight">
<div class="flex items-center gap-2" id="title-container">
<div class="flex items-center gap-2 {% if is_editing_title %}hidden{% endif %}" id="title-container">
<span class="font-semibold text-lg flex-1 truncate" id="title-display">{{ scratchpad.title }}</span>
<button type="button" onclick="editTitle()" class="nb-btn nb-btn-sm btn-ghost">
{% include "icons/edit_icon.html" %} Edit title
@@ -15,9 +15,9 @@
<!-- Hidden title form -->
<form id="title-form" hx-patch="/scratchpad/{{ scratchpad.id }}/title" hx-target="#body_modal" hx-swap="outerHTML"
class="hidden flex items-center gap-2">
class="{% if not is_editing_title %}hidden{% endif %} flex items-center gap-2">
<input type="text" name="title" value="{{ scratchpad.title }}"
class="nb-input nb-input-sm font-semibold text-lg flex-1" id="title-input">
class="nb-input nb-input-sm font-semibold text-lg flex-1" id="title-input" {% if is_editing_title %}autofocus{% endif %}>
<button type="submit" class="nb-btn nb-btn-sm">{% include "icons/check_icon.html" %}</button>
<button type="button" onclick="cancelEditTitle()" class="nb-btn nb-btn-sm btn-ghost">{% include "icons/x_icon.html" %}</button>
</form>

View File

@@ -1,41 +0,0 @@
<!-- Theme switch script -->
<script>
const initializeTheme = () => {
console.log("Initializing theme toggle...");
const themeToggle = document.querySelector('.theme-controller');
if (!themeToggle) {
console.log("Theme toggle not found.");
return;
}
// Detect system preference
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
// Initialize theme from local storage or system preference
const savedTheme = localStorage.getItem('theme');
const initialTheme = savedTheme ? savedTheme : (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', initialTheme);
themeToggle.checked = initialTheme === 'dark';
// Update theme and local storage on toggle
themeToggle.addEventListener('change', () => {
const theme = themeToggle.checked ? 'dark' : 'light';
console.log("Theme switched to:", theme);
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
});
console.log("Theme toggle initialized.");
};
// Run the initialization after the DOM is fully loaded
document.addEventListener('DOMContentLoaded', () => {
console.log("DOM fully loaded. Initializing theme toggle...");
initializeTheme();
});
// Reinitialize theme toggle after HTMX swaps
document.addEventListener('htmx:afterSwap', initializeTheme);
document.addEventListener('htmx:afterSettle', initializeTheme);
</script>

View File

@@ -14,7 +14,7 @@
{% endif %}
{% endmacro %}
<div class="drawer-side z-20">
<div class="drawer-side z-20" style="view-transition-name: sidebar; contain: layout;">
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<ul class="menu p-0 w-72 h-full nb-canvas text-base-content flex flex-col border-r-2 border-neutral">
@@ -47,7 +47,7 @@
</div>
<!-- === MIDDLE SCROLLABLE SECTION === -->
<span class="px-4 py-2 font-semibold tracking-wide">Recent Chats</span>
<span class="px-4 py-2 nb-label">Recent Chats</span>
<div class="flex-1 overflow-y-auto space-y-1 custom-scrollbar">
{% if conversation_archive is defined and conversation_archive %}
{% for conversation in conversation_archive %}