v0.17.2: auto-fact detection with conflict-alert flow
- auto_detect_facts() scans chat turns for factual content (IPs, paths, services, config changes, hardware refs) using pattern matching - check_fact_conflicts() cross-references detected facts against stored FTS5 memories — when a contradiction exists (same topic, diff value) the system surfaces a rag_update_suggestion in the done SSE payload - Frontend shows a floating notification banner comparing old vs new fact with Update/Dismiss buttons - confirm_fact_update() replaces the memory + re-embeds/re-indexes the Qdraft entry on user confirmation - Silent auto-ingest (memories + Qdrant) when no conflict exists - Frontend: msg-toolbar opacity 0→0.35 for visibility
This commit is contained in:
@@ -9,7 +9,7 @@ import logging
|
|||||||
|
|
||||||
log = logging.getLogger("caic")
|
log = logging.getLogger("caic")
|
||||||
|
|
||||||
VERSION = "v0.17.1"
|
VERSION = "v0.17.2"
|
||||||
OLLAMA_BASE = os.environ.get("OLLAMA_BASE", "http://localhost:11434")
|
OLLAMA_BASE = os.environ.get("OLLAMA_BASE", "http://localhost:11434")
|
||||||
LLAMA_SERVER_BASE = os.environ.get("LLAMA_SERVER_BASE", "http://192.168.50.108:8081")
|
LLAMA_SERVER_BASE = os.environ.get("LLAMA_SERVER_BASE", "http://192.168.50.108:8081")
|
||||||
SEARXNG_BASE = "http://localhost:8888"
|
SEARXNG_BASE = "http://localhost:8888"
|
||||||
|
|||||||
@@ -27,6 +27,80 @@ FORGET_PATTERNS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
AUTO_FACT_PATTERNS = [
|
||||||
|
re.compile(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b"),
|
||||||
|
re.compile(r"\b(?:systemd|nginx|docker|ssh|ufw|iptables|postgres(?:ql)?|redis|mosquitto|node_exporter|prometheus|grafana|qdrant|rabbitmq|searxng|llama-server)\b", re.IGNORECASE),
|
||||||
|
re.compile(r"/(?:etc|home|usr|var|opt|tmp|mnt)/\S+"),
|
||||||
|
re.compile(r"\b(?:Ryzen|RX\s*\d{4}|RTX\s*\d{4}|Radeon|AMD|NVIDIA|Core\s*i[579]|Threadripper)\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\b(?:Qwen|Llama|Gemma|Phi|Mistral|DeepSeek)\S*\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\b(?:systemd\.service|docker\s+(?:compose|container|service)|systemctl|journalctl)\b", re.IGNORECASE),
|
||||||
|
]
|
||||||
|
SOCIAL_TRIGGERS = {"hi", "hello", "hey", "yo", "sup", "howdy", "good morning", "good evening"}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_social(text: str) -> bool:
|
||||||
|
t = text.strip().lower()
|
||||||
|
if t in SOCIAL_TRIGGERS or any(t.startswith(w) for w in ("thanks", "thank you", "ty")):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def auto_detect_facts(user_message: str, assistant_message: str) -> list[str]:
|
||||||
|
"""Extract environmental/factual content from a chat turn.
|
||||||
|
|
||||||
|
Returns a list of fact strings ready for storage. Empty list means
|
||||||
|
nothing worth persisting.
|
||||||
|
"""
|
||||||
|
if _is_social(user_message):
|
||||||
|
return []
|
||||||
|
if len(assistant_message) < 40:
|
||||||
|
return []
|
||||||
|
if process_remember_command(user_message) is not None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
found = []
|
||||||
|
for pat in AUTO_FACT_PATTERNS:
|
||||||
|
if pat.search(user_message):
|
||||||
|
found.append(user_message)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Also capture when the user is reporting a change they made
|
||||||
|
change_match = re.search(
|
||||||
|
r"(?:I\s+)?(?:set|changed?|updated|installed|configured|enabled|disabled|added|removed|created|deleted|restarted|reloaded|switched|moved|copied|renamed|symlinked|mounted|unmounted)\s+(?:the\s+)?(.+)",
|
||||||
|
user_message, re.IGNORECASE,
|
||||||
|
)
|
||||||
|
if change_match and user_message not in found:
|
||||||
|
found.append(user_message)
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
deduped = []
|
||||||
|
for f in found:
|
||||||
|
key = f.strip().lower()
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
deduped.append(f.strip()[:MAX_MEMORY_FACT_CHARS])
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
|
||||||
|
def check_fact_conflicts(facts: list[str]) -> list[dict]:
|
||||||
|
"""Search for existing memories that conflict with detected facts.
|
||||||
|
|
||||||
|
Returns list of {memory_id, old_fact, new_fact} for each conflict.
|
||||||
|
"""
|
||||||
|
conflicts = []
|
||||||
|
for new_fact in facts:
|
||||||
|
related = search_memories(new_fact, limit=1)
|
||||||
|
if related:
|
||||||
|
old = related[0]["fact"]
|
||||||
|
if old.rstrip(".") != new_fact.rstrip("."):
|
||||||
|
conflicts.append({
|
||||||
|
"memory_id": related[0]["rowid"],
|
||||||
|
"old_fact": old,
|
||||||
|
"new_fact": new_fact,
|
||||||
|
})
|
||||||
|
return conflicts
|
||||||
|
|
||||||
|
|
||||||
def detect_topic(fact: str) -> str:
|
def detect_topic(fact: str) -> str:
|
||||||
fact_lower = fact.lower()
|
fact_lower = fact.lower()
|
||||||
if any(w in fact_lower for w in ["prefer", "like", "hate", "always", "never", "favorite"]):
|
if any(w in fact_lower for w in ["prefer", "like", "hate", "always", "never", "favorite"]):
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ cAIc - RAG pipeline: Qdrant vector search + system prompt assembly.
|
|||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -24,6 +25,103 @@ from eviction import ( # noqa: E402
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _upsert_fact(fact: str, text: str, topic: str,
|
||||||
|
client: httpx.AsyncClient) -> bool:
|
||||||
|
"""Embed text and upsert a fact to Qdrant."""
|
||||||
|
chunks = chunk_text(text)
|
||||||
|
if not chunks:
|
||||||
|
return False
|
||||||
|
ts = datetime.now(timezone.utc).timestamp()
|
||||||
|
ok = False
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
try:
|
||||||
|
er = await client.post(
|
||||||
|
f"{EMBED_URL}/api/embeddings",
|
||||||
|
json={"model": EMBED_MODEL, "prompt": chunk},
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
if er.status_code != 200:
|
||||||
|
continue
|
||||||
|
vector = er.json()["embedding"]
|
||||||
|
pid = f"auto-{ts}-{i}"
|
||||||
|
payload = {
|
||||||
|
"text": chunk, "source": "auto_fact", "fact": fact,
|
||||||
|
"ingest_date": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"type": "auto_fact", "topic": topic,
|
||||||
|
}
|
||||||
|
r = await client.put(
|
||||||
|
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points?wait=true",
|
||||||
|
json={"points": [{"id": pid, "vector": vector, "payload": payload}]},
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
if r.status_code in (200, 201):
|
||||||
|
ok = True
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Qdrant upsert error: {e}")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
async def ingest_auto_fact(facts: list[str], user_message: str,
|
||||||
|
assistant_message: str) -> int:
|
||||||
|
"""Persist pre-detected facts to memories + Qdrant.
|
||||||
|
|
||||||
|
Call this when no conflicts exist — silent ingest.
|
||||||
|
Returns the number of facts stored.
|
||||||
|
"""
|
||||||
|
from memory import add_memory, detect_topic
|
||||||
|
|
||||||
|
ingested = 0
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
for fact in facts:
|
||||||
|
topic = detect_topic(fact)
|
||||||
|
add_memory(fact, topic=topic, source="auto")
|
||||||
|
ingested += 1
|
||||||
|
text = f"Q: {user_message}\nA: {assistant_message}"
|
||||||
|
await _upsert_fact(fact, text, topic, client)
|
||||||
|
if ingested:
|
||||||
|
log.info(f"Auto-ingested {ingested} fact(s) from conversation")
|
||||||
|
return ingested
|
||||||
|
|
||||||
|
|
||||||
|
async def confirm_fact_update(memory_id: int, old_fact: str, new_fact: str,
|
||||||
|
user_message: str, assistant_message: str) -> bool:
|
||||||
|
"""Confirm a user-accepted fact update: replace memory + Qdrant entry."""
|
||||||
|
from memory import update_memory, detect_topic
|
||||||
|
|
||||||
|
if not update_memory(memory_id, new_fact):
|
||||||
|
return False
|
||||||
|
|
||||||
|
topic = detect_topic(new_fact)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
# scroll old points with matching fact and delete them
|
||||||
|
scroll_r = await client.post(
|
||||||
|
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/scroll",
|
||||||
|
json={
|
||||||
|
"filter": {"must": [{"key": "fact", "match": {"value": old_fact}}]},
|
||||||
|
"limit": 100,
|
||||||
|
"with_payload": False,
|
||||||
|
},
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
if scroll_r.status_code == 200:
|
||||||
|
ids = [p["id"] for p in scroll_r.json().get("result", [])]
|
||||||
|
if ids:
|
||||||
|
await client.post(
|
||||||
|
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/delete",
|
||||||
|
json={"points": ids},
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = f"Q: {user_message}\nA: {assistant_message}"
|
||||||
|
await _upsert_fact(new_fact, text, topic, client)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Fact update RAG error: {e}")
|
||||||
|
|
||||||
|
log.info(f"Fact updated [memory_id={memory_id}]: {new_fact}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def chunk_text(text: str, chunk_size: int = 512, overlap: int = 128) -> list:
|
def chunk_text(text: str, chunk_size: int = 512, overlap: int = 128) -> list:
|
||||||
words = text.split()
|
words = text.split()
|
||||||
target_words = int(chunk_size / 1.3)
|
target_words = int(chunk_size / 1.3)
|
||||||
|
|||||||
+22
-4
@@ -1,4 +1,5 @@
|
|||||||
"""JarvisChat routers - /api/chat streaming endpoint."""
|
"""JarvisChat routers - /api/chat streaming endpoint."""
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
@@ -10,8 +11,8 @@ from fastapi.responses import StreamingResponse
|
|||||||
|
|
||||||
from config import DEFAULT_MODEL, LLAMA_SERVER_BASE
|
from config import DEFAULT_MODEL, LLAMA_SERVER_BASE
|
||||||
from db import get_db, get_upload_context
|
from db import get_db, get_upload_context
|
||||||
from memory import process_remember_command
|
from memory import process_remember_command, auto_detect_facts, check_fact_conflicts
|
||||||
from rag import build_system_prompt
|
from rag import build_system_prompt, ingest_auto_fact
|
||||||
from search import (calculate_perplexity, is_uncertain, is_refusal,
|
from search import (calculate_perplexity, is_uncertain, is_refusal,
|
||||||
clean_hedging, format_search_results, format_direct_answer,
|
clean_hedging, format_search_results, format_direct_answer,
|
||||||
extract_search_query, query_searxng)
|
extract_search_query, query_searxng)
|
||||||
@@ -127,6 +128,7 @@ async def chat(request: Request):
|
|||||||
tokens_per_sec = 0.0
|
tokens_per_sec = 0.0
|
||||||
completion_tokens = 0
|
completion_tokens = 0
|
||||||
prompt_tokens = 0
|
prompt_tokens = 0
|
||||||
|
rag_update = None
|
||||||
|
|
||||||
if remember_response:
|
if remember_response:
|
||||||
yield f"data: {json.dumps({'token': remember_response + chr(10) + chr(10), 'conversation_id': conv_id})}\n\n"
|
yield f"data: {json.dumps({'token': remember_response + chr(10) + chr(10), 'conversation_id': conv_id})}\n\n"
|
||||||
@@ -204,7 +206,15 @@ async def chat(request: Request):
|
|||||||
db2.commit()
|
db2.commit()
|
||||||
db2.close()
|
db2.close()
|
||||||
|
|
||||||
yield f"data: {json.dumps({'done': True, 'conversation_id': conv_id, 'searched': True, 'perplexity': round(perplexity, 2), 'tokens_per_sec': round(tokens_per_sec, 1), 'prompt_tokens': prompt_tokens, 'completion_tokens': completion_tokens, 'context_length': MODEL_CONTEXT_LENGTH})}\n\n"
|
facts = auto_detect_facts(user_message, cleaned_response)
|
||||||
|
if facts:
|
||||||
|
conflicts = check_fact_conflicts(facts)
|
||||||
|
if conflicts:
|
||||||
|
rag_update = {"conflicts": conflicts}
|
||||||
|
else:
|
||||||
|
asyncio.ensure_future(ingest_auto_fact(facts, user_message, cleaned_response))
|
||||||
|
|
||||||
|
yield f"data: {json.dumps({'done': True, 'conversation_id': conv_id, 'searched': True, 'perplexity': round(perplexity, 2), 'tokens_per_sec': round(tokens_per_sec, 1), 'prompt_tokens': prompt_tokens, 'completion_tokens': completion_tokens, 'context_length': MODEL_CONTEXT_LENGTH, **(rag_update and {'rag_update_suggestion': rag_update} or {})})}\n\n"
|
||||||
return
|
return
|
||||||
|
|
||||||
saved_msg = assistant_msg
|
saved_msg = assistant_msg
|
||||||
@@ -217,7 +227,15 @@ async def chat(request: Request):
|
|||||||
db2.commit()
|
db2.commit()
|
||||||
db2.close()
|
db2.close()
|
||||||
|
|
||||||
yield f"data: {json.dumps({'done': True, 'conversation_id': conv_id, 'perplexity': round(perplexity, 2), 'tokens_per_sec': round(tokens_per_sec, 1), 'prompt_tokens': prompt_tokens, 'completion_tokens': completion_tokens, 'context_length': MODEL_CONTEXT_LENGTH})}\n\n"
|
facts = auto_detect_facts(user_message, assistant_msg)
|
||||||
|
if facts:
|
||||||
|
conflicts = check_fact_conflicts(facts)
|
||||||
|
if conflicts:
|
||||||
|
rag_update = {"conflicts": conflicts}
|
||||||
|
else:
|
||||||
|
asyncio.ensure_future(ingest_auto_fact(facts, user_message, assistant_msg))
|
||||||
|
|
||||||
|
yield f"data: {json.dumps({'done': True, 'conversation_id': conv_id, 'perplexity': round(perplexity, 2), 'tokens_per_sec': round(tokens_per_sec, 1), 'prompt_tokens': prompt_tokens, 'completion_tokens': completion_tokens, 'context_length': MODEL_CONTEXT_LENGTH, **(rag_update and {'rag_update_suggestion': rag_update} or {})})}\n\n"
|
||||||
|
|
||||||
except httpx.RemoteProtocolError:
|
except httpx.RemoteProtocolError:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from db import get_db
|
from db import get_db
|
||||||
from memory import add_memory, delete_memory, update_memory, get_all_memories, search_memories
|
from memory import add_memory, delete_memory, update_memory, get_all_memories, search_memories
|
||||||
|
from rag import confirm_fact_update
|
||||||
from security import read_json_body, BODY_LIMIT_DEFAULT_BYTES
|
from security import read_json_body, BODY_LIMIT_DEFAULT_BYTES
|
||||||
from config import MAX_MEMORY_FACT_CHARS
|
from config import MAX_MEMORY_FACT_CHARS
|
||||||
|
|
||||||
@@ -54,6 +55,22 @@ async def search_memories_api(q: str, limit: int = 10):
|
|||||||
return {"results": results, "count": len(results)}
|
return {"results": results, "count": len(results)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/memories/confirm-update")
|
||||||
|
async def confirm_memory_update(request: Request):
|
||||||
|
body = await read_json_body(request, BODY_LIMIT_DEFAULT_BYTES)
|
||||||
|
memory_id = body.get("memory_id")
|
||||||
|
new_fact = str(body.get("new_fact", "")).strip()
|
||||||
|
old_fact = str(body.get("old_fact", "")).strip()
|
||||||
|
user_message = str(body.get("user_message", "")).strip()
|
||||||
|
assistant_message = str(body.get("assistant_message", "")).strip()
|
||||||
|
if not memory_id or not new_fact:
|
||||||
|
raise HTTPException(status_code=400, detail="memory_id and new_fact are required")
|
||||||
|
ok = await confirm_fact_update(memory_id, old_fact, new_fact, user_message, assistant_message)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail="Memory not found")
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/memories/stats")
|
@router.get("/api/memories/stats")
|
||||||
async def memory_stats():
|
async def memory_stats():
|
||||||
db = get_db()
|
db = get_db()
|
||||||
|
|||||||
+56
-1
@@ -1418,6 +1418,9 @@ async function sendMessage() {
|
|||||||
addCopyButtons(assistantDiv);
|
addCopyButtons(assistantDiv);
|
||||||
addMessageToolbar(assistantDiv);
|
addMessageToolbar(assistantDiv);
|
||||||
setStreamingState(false);
|
setStreamingState(false);
|
||||||
|
if (data.rag_update_suggestion && data.rag_update_suggestion.conflicts) {
|
||||||
|
showFactConflictBanner(data.rag_update_suggestion.conflicts, data.conversation_id);
|
||||||
|
}
|
||||||
await loadConversations();
|
await loadConversations();
|
||||||
await loadMemoryStats();
|
await loadMemoryStats();
|
||||||
checkOllamaStatus();
|
checkOllamaStatus();
|
||||||
@@ -1609,8 +1612,60 @@ userInput.addEventListener('keydown', e => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const toastStyle = document.createElement('style');
|
const toastStyle = document.createElement('style');
|
||||||
toastStyle.textContent = '.toast-error { position:fixed; bottom:80px; left:50%; transform:translateX(-50%); background:var(--danger); color:#fff; padding:12px 24px; border-radius:var(--radius); font-family:var(--font-body); font-size:14px; z-index:9999; animation:fadeIn 0.2s; } @keyframes fadeIn { from{opacity:0;transform:translateX(-50%) translateY(10px)} to{opacity:1;transform:translateX(-50%) translateY(0)} }';
|
toastStyle.textContent = '.toast-error { position:fixed; bottom:80px; left:50%; transform:translateX(-50%); background:var(--danger); color:#fff; padding:12px 24px; border-radius:var(--radius); font-family:var(--font-body); font-size:14px; z-index:9999; animation:fadeIn 0.2s; } @keyframes fadeIn { from{opacity:0;transform:translateX(-50%) translateY(10px)} to{opacity:1;transform:translateX(-50%) translateY(0)} } .fact-conflict-banner { position:fixed; bottom:80px; left:50%; transform:translateX(-50%); background:var(--bg-secondary); border:1px solid var(--accent); border-radius:var(--radius); padding:12px 20px; font-family:var(--font-body); font-size:13px; z-index:9999; box-shadow:0 4px 20px rgba(0,0,0,0.3); max-width:600px; line-height:1.5; } .fact-conflict-banner .actions { margin-top:8px; display:flex; gap:8px; } .fact-conflict-banner button { padding:4px 14px; border-radius:var(--radius); cursor:pointer; font-size:12px; } .fact-conflict-banner .btn-confirm { background:var(--accent); color:var(--bg-primary); border:none; } .fact-conflict-banner .btn-dismiss { background:transparent; color:var(--text-muted); border:1px solid var(--border); }';
|
||||||
document.head.appendChild(toastStyle);
|
document.head.appendChild(toastStyle);
|
||||||
|
function showFactConflictBanner(conflicts, convId) {
|
||||||
|
if (!conflicts || !conflicts.length) return;
|
||||||
|
const existing = document.querySelector('.fact-conflict-banner');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
let html = '<div class="fact-conflict-banner">';
|
||||||
|
html += '<strong>📝 Knowledge conflict detected</strong>';
|
||||||
|
html += '<div style="margin-top:6px;font-size:12px;color:var(--text-muted);">';
|
||||||
|
conflicts.forEach((c, i) => {
|
||||||
|
html += `<div style="margin-top:${i>0?'8':'4'}px">`;
|
||||||
|
html += `<div><span style="color:var(--text-muted)">Stored:</span> ${escapeHtml(c.old_fact)}</div>`;
|
||||||
|
html += `<div><span style="color:var(--accent)">Now:</span> ${escapeHtml(c.new_fact)}</div>`;
|
||||||
|
html += '<div class="actions">';
|
||||||
|
html += `<button class="btn-confirm" onclick="confirmFactUpdate(${c.memory_id},${Date.now()},this)">Update knowledge</button>`;
|
||||||
|
html += '<button class="btn-dismiss" onclick="this.closest(\'.fact-conflict-banner\').remove()">Dismiss</button>';
|
||||||
|
html += '</div></div>';
|
||||||
|
});
|
||||||
|
html += '</div></div>';
|
||||||
|
document.body.insertAdjacentHTML('beforeend', html);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmFactUpdate(memoryId, ts, btn) {
|
||||||
|
const banner = btn.closest('.fact-conflict-banner');
|
||||||
|
const text = banner ? banner.textContent : '';
|
||||||
|
const lines = text.split('\n').map(l => l.trim()).filter(l => l);
|
||||||
|
const newFact = lines.length > 0 ? lines[lines.length-1].replace(/^Now:\s*/, '') : '';
|
||||||
|
btn.textContent = 'Updating...';
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const resp = await authFetch('/api/memories/confirm-update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
memory_id: memoryId,
|
||||||
|
new_fact: newFact,
|
||||||
|
old_fact: '',
|
||||||
|
user_message: '',
|
||||||
|
assistant_message: '',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
btn.textContent = '✓ Updated';
|
||||||
|
setTimeout(() => { if (banner) banner.remove(); }, 1500);
|
||||||
|
} else {
|
||||||
|
btn.textContent = 'Failed';
|
||||||
|
btn.style.background = 'var(--danger)';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
btn.textContent = 'Error';
|
||||||
|
btn.style.background = 'var(--danger)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
userInput.addEventListener('paste', e => {
|
userInput.addEventListener('paste', e => {
|
||||||
const hasImage = Array.from(e.clipboardData.items).some(i => i.type.startsWith('image/'));
|
const hasImage = Array.from(e.clipboardData.items).some(i => i.type.startsWith('image/'));
|
||||||
if (hasImage) {
|
if (hasImage) {
|
||||||
|
|||||||
Reference in New Issue
Block a user