new file: .dockerignore

new file:   .env.example
	new file:   Dockerfile
	new file:   app.py
	new file:   blueprints/__init__.py
	new file:   blueprints/auth.py
	new file:   blueprints/chat.py
	new file:   blueprints/context.py
	new file:   blueprints/documents.py
	new file:   blueprints/main.py
	new file:   config.py
	new file:   docker-compose.yml
	new file:   models/__init__.py
	new file:   models/chat_session.py
	new file:   models/document.py
	new file:   models/user.py
	new file:   requirements.txt
	new file:   services/__init__.py
	new file:   services/document_parser.py
	new file:   services/llm_service.py
	new file:   services/rag_service.py
	new file:   services/url_scraper.py
	new file:   static/css/style.css
	new file:   static/js/chat.js
	new file:   static/js/inline_chat.js
	new file:   static/js/main.js
	new file:   templates/base.html
	new file:   templates/document_view.html
	new file:   templates/index.html
	new file:   templates/login.html
	new file:   templates/register.html
This commit is contained in:
SimolZimol
2026-05-22 16:03:50 +02:00
commit 939cc13689
31 changed files with 2025 additions and 0 deletions

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
.env
uploads/
vectordb/
.cache/
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.egg-info/
dist/
build/
*.db
.DS_Store

27
.env.example Normal file
View File

@@ -0,0 +1,27 @@
# Copy this file to .env and fill in your values
# ── Flask ──────────────────────────────────────────────────────────────────────
SECRET_KEY=change-me-to-a-random-secret-string
FLASK_ENV=production
# ── Database (SQLite default — no change needed for single-instance) ───────────
# DATABASE_URI=sqlite:////app/app.db
# ── AI Provider ────────────────────────────────────────────────────────────────
# "lmstudio" → uses LM_STUDIO_URL below (default)
# "openai" → uses OPENAI_API_KEY below
AI_PROVIDER=lmstudio
# LM Studio (running externally, e.g. on the host machine)
# On Linux/Docker: use host.docker.internal to reach the host
LM_STUDIO_URL=http://host.docker.internal:1234
LM_STUDIO_MODEL=local-model
# OpenAI (only needed when AI_PROVIDER=openai)
# OPENAI_API_KEY=sk-...
# OPENAI_MODEL=gpt-4o
# ── RAG tuning (optional) ──────────────────────────────────────────────────────
RAG_TOP_K=5
RAG_CHUNK_SIZE=500
RAG_CHUNK_OVERLAP=50

39
Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
# ── Build stage ────────────────────────────────────────────────────────────────
FROM python:3.10-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ── Runtime stage ──────────────────────────────────────────────────────────────
FROM python:3.10-slim
# Non-root user for security
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
WORKDIR /app
# Copy installed packages from builder
COPY --from=builder /install /usr/local
# Copy application code
COPY --chown=appuser:appgroup . .
# Directories that will be mounted as volumes
RUN mkdir -p uploads vectordb .cache && chown -R appuser:appgroup uploads vectordb .cache
USER appuser
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
FLASK_ENV=production \
TRANSFORMERS_CACHE=/app/.cache \
HF_HOME=/app/.cache
EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/auth/login')" || exit 1
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--timeout", "120", "app:create_app()"]

56
app.py Normal file
View File

@@ -0,0 +1,56 @@
import os
from flask import Flask
from flask_login import LoginManager
from config import Config
from models import db, User
login_manager = LoginManager()
def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)
# Ensure required directories exist
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
os.makedirs(app.config["VECTORDB_PATH"], exist_ok=True)
os.makedirs(app.config["TRANSFORMERS_CACHE"], exist_ok=True)
# Set HuggingFace cache env so sentence-transformers respects it
os.environ["TRANSFORMERS_CACHE"] = app.config["TRANSFORMERS_CACHE"]
os.environ["HF_HOME"] = app.config["TRANSFORMERS_CACHE"]
# Extensions
db.init_app(app)
login_manager.init_app(app)
login_manager.login_view = "auth.login"
login_manager.login_message_category = "info"
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
# Blueprints
from blueprints.auth import auth_bp
from blueprints.documents import documents_bp
from blueprints.context import context_bp
from blueprints.chat import chat_bp
from blueprints.main import main_bp
app.register_blueprint(auth_bp)
app.register_blueprint(documents_bp)
app.register_blueprint(context_bp)
app.register_blueprint(chat_bp)
app.register_blueprint(main_bp)
# Create DB tables
with app.app_context():
db.create_all()
return app
if __name__ == "__main__":
app = create_app()
app.run(host="0.0.0.0", port=5000)

1
blueprints/__init__.py Normal file
View File

@@ -0,0 +1 @@
# blueprints package

68
blueprints/auth.py Normal file
View File

@@ -0,0 +1,68 @@
import bcrypt
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, Length, EqualTo, ValidationError
from models import db, User
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
class RegisterForm(FlaskForm):
username = StringField("Username", validators=[DataRequired(), Length(3, 64)])
email = StringField("Email", validators=[DataRequired(), Email(), Length(max=120)])
password = PasswordField("Password", validators=[DataRequired(), Length(min=8)])
confirm = PasswordField("Confirm Password", validators=[DataRequired(), EqualTo("password")])
submit = SubmitField("Register")
def validate_username(self, field):
if User.query.filter_by(username=field.data).first():
raise ValidationError("Username already taken.")
def validate_email(self, field):
if User.query.filter_by(email=field.data).first():
raise ValidationError("Email already registered.")
class LoginForm(FlaskForm):
email = StringField("Email", validators=[DataRequired(), Email()])
password = PasswordField("Password", validators=[DataRequired()])
submit = SubmitField("Login")
@auth_bp.route("/register", methods=["GET", "POST"])
def register():
if current_user.is_authenticated:
return redirect(url_for("main.index"))
form = RegisterForm()
if form.validate_on_submit():
pw_hash = bcrypt.hashpw(form.password.data.encode(), bcrypt.gensalt()).decode()
user = User(username=form.username.data, email=form.email.data, password_hash=pw_hash)
db.session.add(user)
db.session.commit()
flash("Account created. Please log in.", "success")
return redirect(url_for("auth.login"))
return render_template("register.html", form=form)
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
return redirect(url_for("main.index"))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user and bcrypt.checkpw(form.password.data.encode(), user.password_hash.encode()):
login_user(user)
next_page = request.args.get("next")
return redirect(next_page or url_for("main.index"))
flash("Invalid email or password.", "danger")
return render_template("login.html", form=form)
@auth_bp.route("/logout")
@login_required
def logout():
logout_user()
return redirect(url_for("auth.login"))

154
blueprints/chat.py Normal file
View File

@@ -0,0 +1,154 @@
import json
from flask import Blueprint, request, jsonify, current_app
from flask_login import login_required, current_user
from models import db, ChatSession, ChatMessage
from services import rag_service, llm_service
chat_bp = Blueprint("chat", __name__, url_prefix="/api/chat")
# ── Session management ──────────────────────────────────────────────────────
@chat_bp.route("/sessions", methods=["GET"])
@login_required
def list_sessions():
sessions = (
ChatSession.query.filter_by(user_id=current_user.id)
.order_by(ChatSession.updated_at.desc())
.limit(50)
.all()
)
return jsonify([s.to_dict() for s in sessions])
@chat_bp.route("/sessions", methods=["POST"])
@login_required
def create_session():
data = request.get_json(silent=True) or {}
session = ChatSession(user_id=current_user.id, title=data.get("title", "New Chat"))
db.session.add(session)
db.session.commit()
return jsonify(session.to_dict()), 201
@chat_bp.route("/sessions/<int:session_id>", methods=["DELETE"])
@login_required
def delete_session(session_id):
session = ChatSession.query.filter_by(id=session_id, user_id=current_user.id).first_or_404()
db.session.delete(session)
db.session.commit()
return jsonify({"success": True})
@chat_bp.route("/sessions/<int:session_id>/messages", methods=["GET"])
@login_required
def get_messages(session_id):
session = ChatSession.query.filter_by(id=session_id, user_id=current_user.id).first_or_404()
return jsonify([m.to_dict() for m in session.messages])
# ── Main chat ────────────────────────────────────────────────────────────────
@chat_bp.route("/sessions/<int:session_id>/ask", methods=["POST"])
@login_required
def ask(session_id):
session = ChatSession.query.filter_by(id=session_id, user_id=current_user.id).first_or_404()
data = request.get_json(silent=True) or {}
message = (data.get("message") or "").strip()
if not message:
return jsonify({"error": "No message provided"}), 400
# context_ids: list of objects {"id": int, "type": "doc"|"url"}
context_refs = data.get("context_ids", [])
doc_ids = [r["id"] for r in context_refs if r.get("type") == "doc"]
url_ids = [r["id"] for r in context_refs if r.get("type") == "url"]
# RAG lookup
chunks = []
if doc_ids:
chunks += rag_service.similarity_search(
query=message,
user_id=current_user.id,
source_ids=doc_ids,
source_type="doc",
top_k=current_app.config["RAG_TOP_K"],
)
if url_ids:
chunks += rag_service.similarity_search(
query=message,
user_id=current_user.id,
source_ids=url_ids,
source_type="url",
top_k=current_app.config["RAG_TOP_K"],
)
# If no specific ids given, search all user context
if not context_refs:
chunks = rag_service.similarity_search(
query=message,
user_id=current_user.id,
top_k=current_app.config["RAG_TOP_K"],
)
# Build history (last 10 messages for context window)
history = [
{"role": m.role, "content": m.content}
for m in session.messages[-10:]
]
try:
reply = llm_service.ask(
user_message=message,
context_chunks=chunks,
history=history,
)
except Exception as e:
current_app.logger.error(f"LLM error: {e}")
return jsonify({"error": f"LLM request failed: {str(e)}"}), 502
# Persist messages
user_msg = ChatMessage(
session_id=session.id,
role="user",
content=message,
context_ids=json.dumps(context_refs),
)
assistant_msg = ChatMessage(
session_id=session.id,
role="assistant",
content=reply,
)
db.session.add_all([user_msg, assistant_msg])
# Update session title after first user message
if len(session.messages) == 0:
session.title = message[:60] + ("" if len(message) > 60 else "")
db.session.commit()
return jsonify({
"reply": reply,
"context_used": len(chunks),
"message_id": assistant_msg.id,
})
# ── Inline chat ──────────────────────────────────────────────────────────────
@chat_bp.route("/inline", methods=["POST"])
@login_required
def inline():
data = request.get_json(silent=True) or {}
selected_text = (data.get("selected_text") or "").strip()
question = (data.get("question") or "").strip()
if not selected_text or not question:
return jsonify({"error": "selected_text and question are required"}), 400
try:
reply = llm_service.ask_inline(selected_text=selected_text, question=question)
except Exception as e:
current_app.logger.error(f"Inline LLM error: {e}")
return jsonify({"error": f"LLM request failed: {str(e)}"}), 502
return jsonify({"reply": reply})

63
blueprints/context.py Normal file
View File

@@ -0,0 +1,63 @@
from flask import Blueprint, request, jsonify, current_app
from flask_login import login_required, current_user
from models import db, UrlContext
from services.url_scraper import scrape_url
from services import rag_service
context_bp = Blueprint("context", __name__, url_prefix="/api/context")
@context_bp.route("/urls", methods=["GET"])
@login_required
def list_urls():
urls = UrlContext.query.filter_by(user_id=current_user.id).order_by(UrlContext.created_at.desc()).all()
return jsonify([u.to_dict() for u in urls])
@context_bp.route("/urls", methods=["POST"])
@login_required
def add_url():
data = request.get_json(silent=True) or {}
url = (data.get("url") or "").strip()
if not url:
return jsonify({"error": "No URL provided"}), 400
if not url.startswith(("http://", "https://")):
return jsonify({"error": "Invalid URL. Must start with http:// or https://"}), 400
# Check for duplicate per user
existing = UrlContext.query.filter_by(user_id=current_user.id, url=url).first()
if existing:
return jsonify({"error": "URL already added"}), 409
url_ctx = UrlContext(user_id=current_user.id, url=url, indexed=False)
db.session.add(url_ctx)
db.session.commit()
try:
title, text = scrape_url(url)
url_ctx.title = title[:500]
rag_service.index_source(
text=text,
user_id=current_user.id,
source_id=url_ctx.id,
source_type="url",
chunk_size=current_app.config["RAG_CHUNK_SIZE"],
chunk_overlap=current_app.config["RAG_CHUNK_OVERLAP"],
)
url_ctx.indexed = True
db.session.commit()
except Exception as e:
current_app.logger.error(f"Scraping/indexing failed for url {url_ctx.id}: {e}")
return jsonify(url_ctx.to_dict()), 201
@context_bp.route("/urls/<int:url_id>", methods=["DELETE"])
@login_required
def delete_url(url_id):
url_ctx = UrlContext.query.filter_by(id=url_id, user_id=current_user.id).first_or_404()
rag_service.delete_source(current_user.id, url_ctx.id, "url")
db.session.delete(url_ctx)
db.session.commit()
return jsonify({"success": True})

100
blueprints/documents.py Normal file
View File

@@ -0,0 +1,100 @@
import os
import uuid
from flask import Blueprint, request, jsonify, current_app
from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
from models import db, Document
from services.document_parser import parse_document
from services import rag_service
documents_bp = Blueprint("documents", __name__, url_prefix="/api/documents")
def _allowed(filename: str) -> bool:
allowed = current_app.config["ALLOWED_EXTENSIONS"]
return "." in filename and filename.rsplit(".", 1)[1].lower() in allowed
@documents_bp.route("/", methods=["GET"])
@login_required
def list_documents():
docs = Document.query.filter_by(user_id=current_user.id).order_by(Document.created_at.desc()).all()
return jsonify([d.to_dict() for d in docs])
@documents_bp.route("/upload", methods=["POST"])
@login_required
def upload():
if "file" not in request.files:
return jsonify({"error": "No file provided"}), 400
file = request.files["file"]
if not file.filename:
return jsonify({"error": "No filename"}), 400
if not _allowed(file.filename):
return jsonify({"error": "File type not allowed"}), 400
original_name = secure_filename(file.filename)
ext = original_name.rsplit(".", 1)[1].lower()
stored_name = f"{uuid.uuid4().hex}.{ext}"
file_path = os.path.join(current_app.config["UPLOAD_FOLDER"], stored_name)
file.save(file_path)
doc = Document(
user_id=current_user.id,
filename=stored_name,
original_name=original_name,
file_type=ext,
indexed=False,
)
db.session.add(doc)
db.session.commit()
# Index in background (synchronous for simplicity — fast enough for normal docs)
try:
text = parse_document(file_path, ext)
rag_service.index_source(
text=text,
user_id=current_user.id,
source_id=doc.id,
source_type="doc",
chunk_size=current_app.config["RAG_CHUNK_SIZE"],
chunk_overlap=current_app.config["RAG_CHUNK_OVERLAP"],
)
doc.indexed = True
db.session.commit()
except Exception as e:
current_app.logger.error(f"Indexing failed for doc {doc.id}: {e}")
return jsonify(doc.to_dict()), 201
@documents_bp.route("/<int:doc_id>", methods=["DELETE"])
@login_required
def delete_document(doc_id):
doc = Document.query.filter_by(id=doc_id, user_id=current_user.id).first_or_404()
# Remove from vector store
rag_service.delete_source(current_user.id, doc.id, "doc")
# Remove file
file_path = os.path.join(current_app.config["UPLOAD_FOLDER"], doc.filename)
if os.path.exists(file_path):
os.remove(file_path)
db.session.delete(doc)
db.session.commit()
return jsonify({"success": True})
@documents_bp.route("/<int:doc_id>/content", methods=["GET"])
@login_required
def get_content(doc_id):
doc = Document.query.filter_by(id=doc_id, user_id=current_user.id).first_or_404()
file_path = os.path.join(current_app.config["UPLOAD_FOLDER"], doc.filename)
try:
text = parse_document(file_path, doc.file_type)
except Exception as e:
return jsonify({"error": str(e)}), 500
return jsonify({"id": doc.id, "name": doc.original_name, "content": text})

16
blueprints/main.py Normal file
View File

@@ -0,0 +1,16 @@
from flask import Blueprint, render_template
from flask_login import login_required
main_bp = Blueprint("main", __name__)
@main_bp.route("/")
@login_required
def index():
return render_template("index.html")
@main_bp.route("/document/<int:doc_id>")
@login_required
def document_view(doc_id):
return render_template("document_view.html", doc_id=doc_id)

34
config.py Normal file
View File

@@ -0,0 +1,34 @@
import os
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
class Config:
SECRET_KEY = os.environ.get("SECRET_KEY", "change-me-in-production")
SQLALCHEMY_DATABASE_URI = os.environ.get(
"DATABASE_URI", f"sqlite:///{os.path.join(BASE_DIR, 'app.db')}"
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
UPLOAD_FOLDER = os.environ.get("UPLOAD_FOLDER", os.path.join(BASE_DIR, "uploads"))
VECTORDB_PATH = os.environ.get("VECTORDB_PATH", os.path.join(BASE_DIR, "vectordb"))
TRANSFORMERS_CACHE = os.environ.get(
"TRANSFORMERS_CACHE", os.path.join(BASE_DIR, ".cache")
)
ALLOWED_EXTENSIONS = {"pdf", "txt", "docx", "md"}
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50 MB
# LLM Provider: "lmstudio" or "openai"
AI_PROVIDER = os.environ.get("AI_PROVIDER", "lmstudio")
LM_STUDIO_URL = os.environ.get("LM_STUDIO_URL", "http://localhost:1234")
LM_STUDIO_MODEL = os.environ.get("LM_STUDIO_MODEL", "local-model")
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o")
RAG_TOP_K = int(os.environ.get("RAG_TOP_K", "5"))
RAG_CHUNK_SIZE = int(os.environ.get("RAG_CHUNK_SIZE", "500"))
RAG_CHUNK_OVERLAP = int(os.environ.get("RAG_CHUNK_OVERLAP", "50"))

26
docker-compose.yml Normal file
View File

@@ -0,0 +1,26 @@
version: "3.9"
services:
web:
build: .
restart: unless-stopped
ports:
- "5000:5000"
env_file:
- .env
volumes:
- uploads:/app/uploads
- vectordb:/app/vectordb
- hf_cache:/app/.cache
healthcheck:
test: ["CMD", "python", "-c",
"import urllib.request; urllib.request.urlopen('http://localhost:5000/auth/login')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
volumes:
uploads:
vectordb:
hf_cache:

9
models/__init__.py Normal file
View File

@@ -0,0 +1,9 @@
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
from .user import User
from .document import Document, UrlContext
from .chat_session import ChatSession, ChatMessage
__all__ = ["db", "User", "Document", "UrlContext", "ChatSession", "ChatMessage"]

45
models/chat_session.py Normal file
View File

@@ -0,0 +1,45 @@
from datetime import datetime
import json
from . import db
class ChatSession(db.Model):
__tablename__ = "chat_sessions"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
title = db.Column(db.String(255), default="New Chat")
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
messages = db.relationship(
"ChatMessage", backref="session", lazy=True, cascade="all, delete-orphan",
order_by="ChatMessage.created_at"
)
def to_dict(self):
return {
"id": self.id,
"title": self.title,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
class ChatMessage(db.Model):
__tablename__ = "chat_messages"
id = db.Column(db.Integer, primary_key=True)
session_id = db.Column(db.Integer, db.ForeignKey("chat_sessions.id"), nullable=False)
role = db.Column(db.String(16), nullable=False) # "user" | "assistant"
content = db.Column(db.Text, nullable=False)
context_ids = db.Column(db.Text, nullable=True) # JSON list of doc/url ids used
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def to_dict(self):
return {
"id": self.id,
"role": self.role,
"content": self.content,
"created_at": self.created_at.isoformat(),
}

43
models/document.py Normal file
View File

@@ -0,0 +1,43 @@
from datetime import datetime
from . import db
class Document(db.Model):
__tablename__ = "documents"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
filename = db.Column(db.String(255), nullable=False)
original_name = db.Column(db.String(255), nullable=False)
file_type = db.Column(db.String(16), nullable=False)
indexed = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def to_dict(self):
return {
"id": self.id,
"original_name": self.original_name,
"file_type": self.file_type,
"indexed": self.indexed,
"created_at": self.created_at.isoformat(),
}
class UrlContext(db.Model):
__tablename__ = "url_contexts"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
url = db.Column(db.String(2048), nullable=False)
title = db.Column(db.String(512), nullable=True)
indexed = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def to_dict(self):
return {
"id": self.id,
"url": self.url,
"title": self.title or self.url,
"indexed": self.indexed,
"created_at": self.created_at.isoformat(),
}

21
models/user.py Normal file
View File

@@ -0,0 +1,21 @@
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
from datetime import datetime
from . import db
class User(UserMixin, db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(256), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
documents = db.relationship("Document", backref="owner", lazy=True, cascade="all, delete-orphan")
url_contexts = db.relationship("UrlContext", backref="owner", lazy=True, cascade="all, delete-orphan")
chat_sessions = db.relationship("ChatSession", backref="owner", lazy=True, cascade="all, delete-orphan")
def __repr__(self):
return f"<User {self.username}>"

17
requirements.txt Normal file
View File

@@ -0,0 +1,17 @@
flask==3.0.3
flask-login==0.6.3
flask-sqlalchemy==3.1.1
flask-wtf==1.2.1
wtforms==3.1.2
bcrypt==4.1.3
pdfminer.six==20231228
python-docx==1.1.2
markdown==3.6
beautifulsoup4==4.12.3
requests==2.32.3
sentence-transformers==3.0.1
chromadb==0.5.3
openai==1.35.3
gunicorn==22.0.0
python-dotenv==1.0.1
sqlalchemy==2.0.30

1
services/__init__.py Normal file
View File

@@ -0,0 +1 @@
# services package

View File

@@ -0,0 +1,32 @@
import os
import io
def parse_document(file_path: str, file_type: str) -> str:
"""Extract plain text from a document file."""
ext = file_type.lower().lstrip(".")
if ext == "txt" or ext == "md":
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
return f.read()
if ext == "pdf":
return _parse_pdf(file_path)
if ext == "docx":
return _parse_docx(file_path)
raise ValueError(f"Unsupported file type: {ext}")
def _parse_pdf(file_path: str) -> str:
from pdfminer.high_level import extract_text
text = extract_text(file_path)
return text or ""
def _parse_docx(file_path: str) -> str:
from docx import Document
doc = Document(file_path)
paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
return "\n".join(paragraphs)

109
services/llm_service.py Normal file
View File

@@ -0,0 +1,109 @@
"""
Abstracted LLM service.
Supports AI_PROVIDER=lmstudio (default) or AI_PROVIDER=openai.
Both use the openai Python client — only base_url / api_key differ.
"""
from typing import Optional
from flask import current_app
_client = None
def _get_client():
global _client
if _client is not None:
return _client
import openai
provider = current_app.config.get("AI_PROVIDER", "lmstudio").lower()
if provider == "openai":
api_key = current_app.config.get("OPENAI_API_KEY", "")
_client = openai.OpenAI(api_key=api_key)
else:
# LM Studio or any OpenAI-compatible endpoint
base_url = current_app.config.get("LM_STUDIO_URL", "http://localhost:1234")
_client = openai.OpenAI(
base_url=f"{base_url.rstrip('/')}/v1",
api_key="lm-studio",
)
return _client
def _get_model() -> str:
provider = current_app.config.get("AI_PROVIDER", "lmstudio").lower()
if provider == "openai":
return current_app.config.get("OPENAI_MODEL", "gpt-4o")
return current_app.config.get("LM_STUDIO_MODEL", "local-model")
def ask(
user_message: str,
context_chunks: Optional[list[str]] = None,
history: Optional[list[dict]] = None,
system_extra: Optional[str] = None,
) -> str:
"""
Send a message to the LLM with optional RAG context and chat history.
Returns the assistant reply as a string.
"""
client = _get_client()
model = _get_model()
system_parts = [
"You are a helpful AI assistant. Answer questions accurately based on the provided context.",
"If the context does not contain enough information, say so clearly.",
]
if context_chunks:
context_text = "\n\n---\n\n".join(context_chunks)
system_parts.append(f"\n\n## Context\n\n{context_text}")
if system_extra:
system_parts.append(system_extra)
messages = [{"role": "system", "content": "\n".join(system_parts)}]
if history:
messages.extend(history)
messages.append({"role": "user", "content": user_message})
response = client.chat.completions.create(
model=model,
messages=messages,
temperature=0.7,
)
return response.choices[0].message.content.strip()
def ask_inline(selected_text: str, question: str) -> str:
"""
Inline chat: use selected_text directly as context — no RAG lookup.
"""
system = (
"You are a helpful AI assistant. The user has selected the following text "
"and has a question about it. Answer specifically about the selected content."
)
messages = [
{"role": "system", "content": system},
{
"role": "user",
"content": f"## Selected text\n\n{selected_text}\n\n## Question\n\n{question}",
},
]
client = _get_client()
model = _get_model()
response = client.chat.completions.create(
model=model,
messages=messages,
temperature=0.7,
)
return response.choices[0].message.content.strip()

135
services/rag_service.py Normal file
View File

@@ -0,0 +1,135 @@
"""
RAG service using ChromaDB + sentence-transformers.
Each chunk is stored with metadata: user_id, source_id, source_type (doc|url).
"""
import os
import re
from typing import Optional
from flask import current_app
_chroma_client = None
_collection = None
_embedder = None
def _get_embedder():
global _embedder
if _embedder is None:
from sentence_transformers import SentenceTransformer
cache = current_app.config.get("TRANSFORMERS_CACHE", ".cache")
_embedder = SentenceTransformer("all-MiniLM-L6-v2", cache_folder=cache)
return _embedder
def _get_collection():
global _chroma_client, _collection
if _collection is None:
import chromadb
path = current_app.config["VECTORDB_PATH"]
_chroma_client = chromadb.PersistentClient(path=path)
_collection = _chroma_client.get_or_create_collection(
name="ki_context",
metadata={"hnsw:space": "cosine"},
)
return _collection
def chunk_text(text: str, chunk_size: int, overlap: int) -> list[str]:
"""Split text into overlapping word-based chunks."""
words = text.split()
chunks = []
start = 0
while start < len(words):
end = start + chunk_size
chunks.append(" ".join(words[start:end]))
start += chunk_size - overlap
return [c for c in chunks if c.strip()]
def index_source(
text: str,
user_id: int,
source_id: int,
source_type: str, # "doc" | "url"
chunk_size: int = 500,
chunk_overlap: int = 50,
):
"""Chunk, embed and store text in ChromaDB. Replaces existing chunks for this source."""
collection = _get_collection()
embedder = _get_embedder()
# Remove old chunks for this source first
delete_source(user_id, source_id, source_type)
chunks = chunk_text(text, chunk_size, chunk_overlap)
if not chunks:
return
embeddings = embedder.encode(chunks, show_progress_bar=False).tolist()
ids = [f"{source_type}_{source_id}_chunk_{i}" for i in range(len(chunks))]
metadatas = [
{"user_id": str(user_id), "source_id": str(source_id), "source_type": source_type}
for _ in chunks
]
collection.add(documents=chunks, embeddings=embeddings, ids=ids, metadatas=metadatas)
def delete_source(user_id: int, source_id: int, source_type: str):
"""Remove all chunks belonging to a source from ChromaDB."""
collection = _get_collection()
try:
collection.delete(
where={
"$and": [
{"user_id": {"$eq": str(user_id)}},
{"source_id": {"$eq": str(source_id)}},
{"source_type": {"$eq": source_type}},
]
}
)
except Exception:
pass
def similarity_search(
query: str,
user_id: int,
source_ids: Optional[list[int]] = None,
source_type: Optional[str] = None,
top_k: int = 5,
) -> list[str]:
"""
Search for relevant chunks.
Optionally filter by specific source_ids and/or source_type.
Returns list of chunk texts.
"""
collection = _get_collection()
embedder = _get_embedder()
query_embedding = embedder.encode([query], show_progress_bar=False).tolist()[0]
# Build where filter
conditions = [{"user_id": {"$eq": str(user_id)}}]
if source_ids is not None and len(source_ids) > 0:
conditions.append(
{"source_id": {"$in": [str(sid) for sid in source_ids]}}
)
if source_type:
conditions.append({"source_type": {"$eq": source_type}})
where = {"$and": conditions} if len(conditions) > 1 else conditions[0]
try:
results = collection.query(
query_embeddings=[query_embedding],
n_results=top_k,
where=where,
)
return results["documents"][0] if results["documents"] else []
except Exception:
return []

31
services/url_scraper.py Normal file
View File

@@ -0,0 +1,31 @@
import requests
from bs4 import BeautifulSoup
def scrape_url(url: str, timeout: int = 15) -> tuple[str, str]:
"""
Fetch a URL and return (title, plain_text).
Raises requests.RequestException on network errors.
"""
headers = {
"User-Agent": (
"Mozilla/5.0 (compatible; KIContextTool/1.0; "
"+https://github.com/user/ki-context-tool)"
)
}
resp = requests.get(url, headers=headers, timeout=timeout)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
# Remove script/style noise
for tag in soup(["script", "style", "nav", "footer", "header", "aside"]):
tag.decompose()
title = soup.title.string.strip() if soup.title and soup.title.string else url
# Extract readable text
text = soup.get_text(separator="\n", strip=True)
# Collapse excessive blank lines
lines = [line for line in text.splitlines() if line.strip()]
return title, "\n".join(lines)

69
static/css/style.css Normal file
View File

@@ -0,0 +1,69 @@
/* Custom overrides on top of Tailwind — Copilot-style dark theme */
:root {
--scrollbar-thumb: #313244;
--scrollbar-track: #1e1e2e;
}
/* Scrollbars */
* {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
*::-webkit-scrollbar { width: 6px; height: 6px; }
*::-webkit-scrollbar-track { background: var(--scrollbar-track); }
*::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; }
*::-webkit-scrollbar-thumb:hover { background: #45475a; }
/* Smooth selection highlight */
::selection { background: rgba(137, 180, 250, 0.3); }
/* Textarea — no resize handle */
textarea { resize: none; }
/* Pre block in document view */
#doc-content {
user-select: text;
cursor: text;
}
/* Inline popup fade-in */
#inline-popup {
animation: fadeIn 120ms ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.97) translateY(-4px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
/* Chat bubble animations */
#messages > div {
animation: slideIn 150ms ease;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* Fix Tailwind's accent color for checkboxes */
input[type="checkbox"] {
cursor: pointer;
width: 14px;
height: 14px;
}
/* Sidebar resize handle (visual only) */
#sidebar {
transition: width 200ms ease;
}
/* Flash messages */
.flash-msg {
animation: fadeIn 200ms ease, fadeOut 300ms 3700ms ease forwards;
}
@keyframes fadeOut {
to { opacity: 0; transform: translateY(-8px); }
}
/* Remove default focus outline on editor mount */
#editor-mount:focus { outline: none; }

115
static/js/chat.js Normal file
View File

@@ -0,0 +1,115 @@
// ── Chat UI controller ────────────────────────────────────────────────────────
const $ = (sel) => document.querySelector(sel);
export class Chat {
constructor() {
this._container = $('#messages');
this._emptyState = $('#empty-state');
}
clear() {
this._container.innerHTML = '';
this._emptyState?.classList.remove('hidden');
}
renderHistory(messages) {
this._container.innerHTML = '';
this._emptyState?.classList.add('hidden');
messages.forEach((m) => this._appendBubble(m.role, m.content));
this._scrollBottom();
}
async send(sessionId, message, contextIds = []) {
// Immediately render user bubble
this._appendBubble('user', message);
this._emptyState?.classList.add('hidden');
// Typing indicator
const typingId = this._appendTyping();
const res = await fetch(`/api/chat/sessions/${sessionId}/ask`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, context_ids: contextIds }),
});
this._removeTyping(typingId);
const data = await res.json();
if (!res.ok) {
this._appendBubble('assistant', `Error: ${data.error || 'Unknown error'}`, true);
} else {
this._appendBubble('assistant', data.reply);
}
this._scrollBottom();
}
_appendBubble(role, content, isError = false) {
const wrap = document.createElement('div');
wrap.className = `flex ${role === 'user' ? 'justify-end' : 'justify-start'} gap-3`;
if (role === 'assistant') {
wrap.innerHTML = `
<div class="w-7 h-7 rounded-full bg-copilot-accent flex items-center justify-center shrink-0 mt-0.5">
<svg class="w-4 h-4 text-copilot-bg" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/>
</svg>
</div>
<div class="max-w-[75%] rounded-2xl rounded-tl-sm px-4 py-3 text-sm leading-relaxed
${isError ? 'bg-copilot-danger text-copilot-bg' : 'bg-copilot-panel text-copilot-text'}
shadow-sm whitespace-pre-wrap">${escapeHtml(content)}</div>`;
} else {
wrap.innerHTML = `
<div class="max-w-[75%] rounded-2xl rounded-tr-sm px-4 py-3 text-sm leading-relaxed
bg-copilot-user text-copilot-text shadow-sm whitespace-pre-wrap">
${escapeHtml(content)}
</div>
<div class="w-7 h-7 rounded-full bg-copilot-muted flex items-center justify-center shrink-0 mt-0.5 text-xs font-bold text-copilot-bg">
U
</div>`;
}
this._container.appendChild(wrap);
return wrap;
}
_appendTyping() {
const id = `typing-${Date.now()}`;
const div = document.createElement('div');
div.id = id;
div.className = 'flex justify-start gap-3';
div.innerHTML = `
<div class="w-7 h-7 rounded-full bg-copilot-accent flex items-center justify-center shrink-0 mt-0.5">
<svg class="w-4 h-4 text-copilot-bg" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/>
</svg>
</div>
<div class="bg-copilot-panel rounded-2xl rounded-tl-sm px-4 py-3 shadow-sm flex gap-1 items-center">
<span class="w-2 h-2 rounded-full bg-copilot-muted animate-bounce" style="animation-delay:0ms"></span>
<span class="w-2 h-2 rounded-full bg-copilot-muted animate-bounce" style="animation-delay:150ms"></span>
<span class="w-2 h-2 rounded-full bg-copilot-muted animate-bounce" style="animation-delay:300ms"></span>
</div>`;
this._container.appendChild(div);
this._scrollBottom();
return id;
}
_removeTyping(id) {
document.getElementById(id)?.remove();
}
_scrollBottom() {
this._container.scrollTop = this._container.scrollHeight;
}
}
function escapeHtml(str = '') {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

126
static/js/inline_chat.js Normal file
View File

@@ -0,0 +1,126 @@
// ── Inline chat for document_view.html ───────────────────────────────────────
// Uses a plain <textarea> as read-only editor (CodeMirror CDN bundle is huge;
// we use a lightweight syntax-highlighted <pre> approach instead).
const $ = (sel) => document.querySelector(sel);
const DOC_ID = window.__DOC_ID__;
let selectedText = '';
// ── Load document content ─────────────────────────────────────────────────────
async function loadDoc() {
const res = await fetch(`/api/documents/${DOC_ID}/content`);
if (!res.ok) {
$('#editor-mount').textContent = 'Failed to load document.';
return;
}
const data = await res.json();
$('#doc-name').textContent = data.name;
// Render as selectable pre
const pre = document.createElement('pre');
pre.id = 'doc-content';
pre.className = 'p-6 text-sm text-copilot-text whitespace-pre-wrap leading-relaxed outline-none min-h-full';
pre.textContent = data.content;
$('#editor-mount').appendChild(pre);
}
// ── Selection detection ───────────────────────────────────────────────────────
document.addEventListener('mouseup', (e) => {
if ($('#inline-popup')?.contains(e.target)) return;
const sel = window.getSelection();
const text = sel?.toString().trim();
if (!text || text.length < 5) {
hidePopup();
return;
}
selectedText = text;
showPopup(e.clientX, e.clientY);
});
document.addEventListener('keyup', (e) => {
if (e.key === 'Escape') hidePopup();
});
function showPopup(x, y) {
const popup = $('#inline-popup');
const preview = $('#inline-selection-preview');
const question = $('#inline-question');
const answer = $('#inline-answer');
preview.textContent = selectedText.length > 300
? selectedText.slice(0, 300) + '…'
: selectedText;
question.value = '';
answer.textContent = '';
answer.classList.add('hidden');
$('#inline-loading').classList.add('hidden');
// Position popup near the cursor, stay within viewport
const wrapper = $('#editor-wrapper').getBoundingClientRect();
const popupW = 320;
const popupH = 260;
let left = x - wrapper.left + 12;
let top = y - wrapper.top + 12;
if (left + popupW > wrapper.width) left = wrapper.width - popupW - 8;
if (top + popupH > wrapper.height) top = Math.max(0, y - wrapper.top - popupH - 12);
popup.style.left = `${left}px`;
popup.style.top = `${top}px`;
popup.classList.remove('hidden');
setTimeout(() => question.focus(), 50);
}
function hidePopup() {
$('#inline-popup').classList.add('hidden');
selectedText = '';
}
// ── Submit inline question ────────────────────────────────────────────────────
$('#inline-submit').addEventListener('click', askInline);
$('#inline-question').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
askInline();
}
});
$('#inline-close').addEventListener('click', hidePopup);
async function askInline() {
const question = $('#inline-question').value.trim();
if (!question || !selectedText) return;
const loading = $('#inline-loading');
const answer = $('#inline-answer');
const btn = $('#inline-submit');
btn.disabled = true;
loading.classList.remove('hidden');
answer.classList.add('hidden');
try {
const res = await fetch('/api/chat/inline', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selected_text: selectedText, question }),
});
const data = await res.json();
answer.textContent = res.ok ? data.reply : `Error: ${data.error}`;
answer.classList.remove('hidden');
} catch (err) {
answer.textContent = `Network error: ${err.message}`;
answer.classList.remove('hidden');
} finally {
loading.classList.add('hidden');
btn.disabled = false;
}
}
loadDoc();

321
static/js/main.js Normal file
View File

@@ -0,0 +1,321 @@
// ── Sidebar & main page controller ───────────────────────────────────────────
import { Chat } from './chat.js';
const $ = (sel) => document.querySelector(sel);
// ── State ─────────────────────────────────────────────────────────────────────
const state = {
sessions: [],
activeSessionId: null,
docs: [],
urls: [],
/** Map<string, {id, type, name}> */
selectedContext: new Map(),
};
const chat = new Chat();
// ── Boot ──────────────────────────────────────────────────────────────────────
async function init() {
await Promise.all([loadDocs(), loadUrls(), loadSessions()]);
renderContext();
}
// ── Documents ─────────────────────────────────────────────────────────────────
async function loadDocs() {
const res = await fetch('/api/documents/');
state.docs = await res.json();
renderDocs();
}
function renderDocs() {
const list = $('#doc-list');
list.innerHTML = '';
if (!state.docs.length) {
list.innerHTML = '<li class="text-xs text-copilot-muted py-1">No documents yet</li>';
return;
}
state.docs.forEach((doc) => {
const key = `doc_${doc.id}`;
const checked = state.selectedContext.has(key);
const li = document.createElement('li');
li.className = 'flex items-center gap-2 group py-0.5 hover:bg-copilot-panel rounded px-1';
li.innerHTML = `
<input type="checkbox" data-id="${doc.id}" data-type="doc"
class="ctx-checkbox accent-[#89b4fa] rounded shrink-0" ${checked ? 'checked' : ''} />
<a href="/document/${doc.id}"
class="flex-1 text-xs text-copilot-text truncate hover:text-copilot-accent transition"
title="${esc(doc.original_name)}">${esc(doc.original_name)}</a>
${doc.indexed
? '<span class="text-[10px] text-copilot-success shrink-0" title="Indexed">●</span>'
: '<span class="text-[10px] text-copilot-warning shrink-0 animate-pulse" title="Indexing…">●</span>'}
<button data-delete-doc="${doc.id}"
class="opacity-0 group-hover:opacity-100 text-copilot-muted hover:text-copilot-danger transition shrink-0"
title="Delete">
<svg class="w-3.5 h-3.5 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>`;
list.appendChild(li);
});
}
async function uploadFiles(files) {
const indicator = $('#doc-uploading');
indicator.classList.remove('hidden');
for (const file of files) {
const fd = new FormData();
fd.append('file', file);
await fetch('/api/documents/upload', { method: 'POST', body: fd });
}
await loadDocs();
indicator.classList.add('hidden');
}
// ── URLs ──────────────────────────────────────────────────────────────────────
async function loadUrls() {
const res = await fetch('/api/context/urls');
state.urls = await res.json();
renderUrls();
}
function renderUrls() {
const list = $('#url-list');
list.innerHTML = '';
if (!state.urls.length) {
list.innerHTML = '<li class="text-xs text-copilot-muted py-1">No URLs yet</li>';
return;
}
state.urls.forEach((u) => {
const key = `url_${u.id}`;
const checked = state.selectedContext.has(key);
const li = document.createElement('li');
li.className = 'flex items-center gap-2 group py-0.5 hover:bg-copilot-panel rounded px-1';
li.innerHTML = `
<input type="checkbox" data-id="${u.id}" data-type="url"
class="ctx-checkbox accent-[#89b4fa] shrink-0" ${checked ? 'checked' : ''} />
<span class="flex-1 text-xs text-copilot-text truncate" title="${esc(u.url)}">${esc(u.title)}</span>
${u.indexed
? '<span class="text-[10px] text-copilot-success shrink-0" title="Indexed">●</span>'
: '<span class="text-[10px] text-copilot-warning shrink-0 animate-pulse" title="Scraping…">●</span>'}
<button data-delete-url="${u.id}"
class="opacity-0 group-hover:opacity-100 text-copilot-muted hover:text-copilot-danger transition shrink-0"
title="Remove">
<svg class="w-3.5 h-3.5 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>`;
list.appendChild(li);
});
}
// ── Sessions ──────────────────────────────────────────────────────────────────
async function loadSessions() {
const res = await fetch('/api/chat/sessions');
state.sessions = await res.json();
renderSessions();
}
function renderSessions() {
const list = $('#session-list');
list.innerHTML = '';
if (!state.sessions.length) {
list.innerHTML = '<li class="text-xs text-copilot-muted py-1">No chats yet</li>';
return;
}
state.sessions.forEach((s) => {
const li = document.createElement('li');
const isActive = s.id === state.activeSessionId;
li.className = `flex items-center gap-1 group py-0.5 rounded px-1 cursor-pointer
${isActive ? 'bg-copilot-panel text-copilot-accent' : 'hover:bg-copilot-panel text-copilot-text'}`;
li.innerHTML = `
<span class="flex-1 text-xs truncate">${esc(s.title)}</span>
<button data-delete-session="${s.id}"
class="opacity-0 group-hover:opacity-100 text-copilot-muted hover:text-copilot-danger transition shrink-0"
title="Delete chat">
<svg class="w-3.5 h-3.5 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>`;
li.addEventListener('click', (e) => {
if (e.target.closest('[data-delete-session]')) return;
selectSession(s.id, s.title);
});
list.appendChild(li);
});
}
async function selectSession(id, title) {
state.activeSessionId = id;
$('#chat-title').textContent = title;
$('#empty-state').classList.add('hidden');
renderSessions();
const res = await fetch(`/api/chat/sessions/${id}/messages`);
const msgs = await res.json();
chat.renderHistory(msgs);
}
// ── Context chips ─────────────────────────────────────────────────────────────
function renderContext() {
const chips = $('#context-chips');
chips.innerHTML = '';
state.selectedContext.forEach((item, key) => {
const chip = document.createElement('span');
chip.className = 'inline-flex items-center gap-1 bg-copilot-panel border border-copilot-border rounded-full px-2 py-0.5 text-xs text-copilot-accent';
chip.innerHTML = `${esc(item.name)}
<button data-remove-ctx="${key}" class="text-copilot-muted hover:text-copilot-danger ml-0.5 leading-none">✕</button>`;
chips.appendChild(chip);
});
const badge = $('#context-badge');
badge.textContent = state.selectedContext.size
? `${state.selectedContext.size} context source${state.selectedContext.size > 1 ? 's' : ''} active`
: '';
}
// ── Event delegation ──────────────────────────────────────────────────────────
document.addEventListener('change', (e) => {
if (e.target.matches('.ctx-checkbox')) {
const { id, type } = e.target.dataset;
const key = `${type}_${id}`;
if (e.target.checked) {
const name =
type === 'doc'
? state.docs.find((d) => d.id == id)?.original_name ?? `Doc ${id}`
: state.urls.find((u) => u.id == id)?.title ?? `URL ${id}`;
state.selectedContext.set(key, { id: parseInt(id), type, name });
} else {
state.selectedContext.delete(key);
}
renderContext();
}
});
document.addEventListener('click', async (e) => {
const delDoc = e.target.closest('[data-delete-doc]');
if (delDoc) {
const id = delDoc.dataset.deleteDoc;
await fetch(`/api/documents/${id}`, { method: 'DELETE' });
state.selectedContext.delete(`doc_${id}`);
renderContext();
await loadDocs();
return;
}
const delUrl = e.target.closest('[data-delete-url]');
if (delUrl) {
const id = delUrl.dataset.deleteUrl;
await fetch(`/api/context/urls/${id}`, { method: 'DELETE' });
state.selectedContext.delete(`url_${id}`);
renderContext();
await loadUrls();
return;
}
const delSession = e.target.closest('[data-delete-session]');
if (delSession) {
e.stopPropagation();
const id = delSession.dataset.deleteSession;
await fetch(`/api/chat/sessions/${id}`, { method: 'DELETE' });
if (state.activeSessionId == id) {
state.activeSessionId = null;
chat.clear();
$('#chat-title').textContent = 'Select or start a chat';
$('#empty-state').classList.remove('hidden');
}
await loadSessions();
return;
}
const removeCtx = e.target.closest('[data-remove-ctx]');
if (removeCtx) {
const key = removeCtx.dataset.removeCtx;
state.selectedContext.delete(key);
// Uncheck corresponding checkbox
const [type, id] = key.split('_');
const cb = document.querySelector(`.ctx-checkbox[data-id="${id}"][data-type="${type}"]`);
if (cb) cb.checked = false;
renderContext();
}
});
// File upload
$('#file-input').addEventListener('change', (e) => {
if (e.target.files.length) uploadFiles(Array.from(e.target.files));
e.target.value = '';
});
// URL add
$('#url-form').addEventListener('submit', async (e) => {
e.preventDefault();
const input = $('#url-input');
const url = input.value.trim();
if (!url) return;
input.value = '';
await fetch('/api/context/urls', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
await loadUrls();
});
// New chat
$('#btn-new-chat').addEventListener('click', async () => {
const res = await fetch('/api/chat/sessions', { method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'New Chat' }) });
const session = await res.json();
state.sessions.unshift(session);
await loadSessions();
selectSession(session.id, session.title);
});
// Send message
$('#chat-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (!state.activeSessionId) {
alert('Please start or select a chat first.');
return;
}
const input = $('#chat-input');
const msg = input.value.trim();
if (!msg) return;
input.value = '';
input.style.height = '42px';
const contextIds = Array.from(state.selectedContext.values()).map((c) => ({
id: c.id,
type: c.type,
}));
await chat.send(state.activeSessionId, msg, contextIds);
// Refresh session title
await loadSessions();
});
// Auto-grow textarea
$('#chat-input').addEventListener('input', function () {
this.style.height = '42px';
this.style.height = Math.min(this.scrollHeight, 160) + 'px';
});
// Submit on Enter (Shift+Enter = newline)
$('#chat-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
$('#chat-form').dispatchEvent(new Event('submit'));
}
});
// ── Helpers ───────────────────────────────────────────────────────────────────
function esc(str = '') {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
init();

63
templates/base.html Normal file
View File

@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}KI Context Tool{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
copilot: {
bg: '#1e1e2e',
sidebar: '#181825',
panel: '#24273a',
border: '#313244',
accent: '#89b4fa',
accentHover: '#74c7ec',
text: '#cdd6f4',
muted: '#6c7086',
user: '#313244',
assistant:'#1e1e2e',
success: '#a6e3a1',
danger: '#f38ba8',
warning: '#fab387',
}
}
}
}
}
</script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}" />
{% block head %}{% endblock %}
</head>
<body class="bg-copilot-bg text-copilot-text min-h-screen flex flex-col">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div id="flash-container" class="fixed top-4 right-4 z-50 space-y-2">
{% for category, message in messages %}
<div class="flash-msg px-4 py-3 rounded-lg text-sm font-medium shadow-lg
{% if category == 'success' %}bg-copilot-success text-copilot-bg
{% elif category == 'danger' %}bg-copilot-danger text-copilot-bg
{% else %}bg-copilot-accent text-copilot-bg{% endif %}">
{{ message }}
</div>
{% endfor %}
</div>
<script>
setTimeout(() => {
document.getElementById('flash-container')?.remove();
}, 4000);
</script>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,65 @@
{% extends "base.html" %}
{% block title %}Document View — KI Context Tool{% endblock %}
{% block head %}
<!-- CodeMirror 6 via CDN (codemirror.net bundles) -->
<script src="https://unpkg.com/codemirror@6.0.1/dist/index.js" type="module" id="cm-loader"></script>
{% endblock %}
{% block content %}
<div class="flex flex-col h-screen overflow-hidden">
<!-- Topbar -->
<header class="h-10 border-b border-copilot-border bg-copilot-sidebar flex items-center gap-3 px-4 shrink-0">
<a href="{{ url_for('main.index') }}"
class="text-copilot-muted hover:text-copilot-accent transition text-sm flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" d="M15 19l-7-7 7-7"/>
</svg>
Back
</a>
<span class="text-copilot-border">|</span>
<span id="doc-name" class="text-sm text-copilot-text truncate flex-1"></span>
</header>
<!-- Editor area -->
<div class="flex-1 overflow-auto relative" id="editor-wrapper">
<div id="editor-mount" class="h-full font-mono text-sm"></div>
<!-- Inline chat popup (hidden by default) -->
<div id="inline-popup"
class="hidden absolute z-50 w-80 bg-copilot-panel border border-copilot-border rounded-xl shadow-2xl p-3"
style="top:0;left:0">
<p class="text-xs text-copilot-muted mb-2 font-semibold">Ask about selection</p>
<div id="inline-selection-preview"
class="text-xs text-copilot-muted bg-copilot-bg border border-copilot-border rounded px-2 py-1 mb-2 max-h-20 overflow-y-auto leading-relaxed">
</div>
<textarea id="inline-question" rows="2" placeholder="Your question…"
class="w-full bg-copilot-bg border border-copilot-border rounded-lg px-3 py-2 text-xs text-copilot-text
focus:outline-none focus:border-copilot-accent resize-none mb-2"></textarea>
<div class="flex gap-2">
<button id="inline-submit"
class="flex-1 bg-copilot-accent hover:bg-copilot-accentHover text-copilot-bg text-xs font-semibold py-1.5 rounded-lg transition">
Ask
</button>
<button id="inline-close"
class="text-xs text-copilot-muted hover:text-copilot-danger py-1.5 px-2 rounded-lg transition">
</button>
</div>
<!-- Answer area -->
<div id="inline-answer"
class="hidden mt-3 border-t border-copilot-border pt-3 text-xs text-copilot-text leading-relaxed max-h-48 overflow-y-auto whitespace-pre-wrap">
</div>
<p id="inline-loading" class="hidden text-xs text-copilot-muted animate-pulse mt-2">Thinking…</p>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
window.__DOC_ID__ = {{ doc_id }};
</script>
<script src="{{ url_for('static', filename='js/inline_chat.js') }}" type="module"></script>
{% endblock %}

123
templates/index.html Normal file
View File

@@ -0,0 +1,123 @@
{% extends "base.html" %}
{% block title %}KI Context Tool{% endblock %}
{% block content %}
<div class="flex h-screen overflow-hidden">
<!-- ══════════════════════════════════════════════
LEFT SIDEBAR
══════════════════════════════════════════════ -->
<aside id="sidebar" class="w-72 min-w-[220px] bg-copilot-sidebar border-r border-copilot-border flex flex-col select-none shrink-0">
<!-- Logo / User -->
<div class="flex items-center justify-between px-4 py-3 border-b border-copilot-border">
<div class="flex items-center gap-2">
<svg class="w-6 h-6 text-copilot-accent shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm1 14h-2V8h2zm0-8h-2V8h2z"/>
</svg>
<span class="font-semibold text-sm text-copilot-text truncate">KI Context Tool</span>
</div>
<a href="{{ url_for('auth.logout') }}" title="Logout"
class="text-copilot-muted hover:text-copilot-danger transition p-1 rounded">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h6a2 2 0 012 2v1"/>
</svg>
</a>
</div>
<!-- New Chat -->
<div class="px-3 py-2 border-b border-copilot-border">
<button id="btn-new-chat"
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg bg-copilot-accent text-copilot-bg text-sm font-semibold hover:bg-copilot-accentHover transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" d="M12 4v16m8-8H4"/>
</svg>
New Chat
</button>
</div>
<!-- Chat history -->
<div class="px-3 py-2 border-b border-copilot-border">
<p class="text-xs font-semibold uppercase tracking-wider text-copilot-muted mb-2">Chat History</p>
<ul id="session-list" class="space-y-0.5 max-h-36 overflow-y-auto"></ul>
</div>
<!-- Documents section -->
<div class="px-3 py-2 border-b border-copilot-border flex-1 overflow-y-auto">
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-semibold uppercase tracking-wider text-copilot-muted">Documents</p>
<label class="cursor-pointer text-copilot-accent hover:text-copilot-accentHover transition" title="Upload file">
<input id="file-input" type="file" accept=".pdf,.txt,.docx,.md" class="hidden" multiple />
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" d="M4 16v1a2 2 0 002 2h12a2 2 0 002-2v-1M12 4v12m-4-4l4-4 4 4"/>
</svg>
</label>
</div>
<ul id="doc-list" class="space-y-0.5"></ul>
<p id="doc-uploading" class="hidden text-xs text-copilot-muted mt-1 animate-pulse">Uploading & indexing…</p>
</div>
<!-- URLs section -->
<div class="px-3 py-2 border-t border-copilot-border">
<p class="text-xs font-semibold uppercase tracking-wider text-copilot-muted mb-2">Web Sources</p>
<form id="url-form" class="flex gap-1 mb-2">
<input id="url-input" type="url" placeholder="https://…"
class="flex-1 bg-copilot-bg border border-copilot-border rounded px-2 py-1 text-xs text-copilot-text focus:outline-none focus:border-copilot-accent" />
<button type="submit"
class="bg-copilot-accent text-copilot-bg text-xs font-semibold px-2 py-1 rounded hover:bg-copilot-accentHover transition">
Add
</button>
</form>
<ul id="url-list" class="space-y-0.5 max-h-32 overflow-y-auto"></ul>
</div>
</aside>
<!-- ══════════════════════════════════════════════
MAIN AREA
══════════════════════════════════════════════ -->
<main class="flex-1 flex flex-col min-w-0 bg-copilot-bg">
<!-- Topbar -->
<header class="h-10 border-b border-copilot-border flex items-center justify-between px-4 shrink-0">
<span id="chat-title" class="text-sm text-copilot-muted truncate">Select or start a chat</span>
<span class="text-xs text-copilot-muted" id="context-badge"></span>
</header>
<!-- Messages -->
<div id="messages" class="flex-1 overflow-y-auto px-6 py-4 space-y-4"></div>
<!-- Empty state -->
<div id="empty-state" class="flex-1 flex flex-col items-center justify-center text-center pointer-events-none absolute inset-0 mt-10">
<svg class="w-16 h-16 text-copilot-border mb-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/>
</svg>
<p class="text-copilot-muted text-sm">Upload documents or add URLs,<br>then start a new chat.</p>
</div>
<!-- Input area -->
<div class="border-t border-copilot-border px-4 py-3 bg-copilot-sidebar shrink-0">
<!-- Active context chips -->
<div id="context-chips" class="flex flex-wrap gap-1 mb-2"></div>
<form id="chat-form" class="flex gap-2 items-end">
<textarea id="chat-input" rows="1" placeholder="Ask a question about your documents…"
class="flex-1 bg-copilot-bg border border-copilot-border rounded-xl px-4 py-2.5 text-sm text-copilot-text
focus:outline-none focus:border-copilot-accent resize-none overflow-hidden transition
max-h-40 leading-relaxed"
style="height:42px"></textarea>
<button id="send-btn" type="submit"
class="bg-copilot-accent hover:bg-copilot-accentHover text-copilot-bg rounded-xl px-4 py-2.5 font-semibold text-sm
transition disabled:opacity-40 disabled:cursor-not-allowed shrink-0">
Send
</button>
</form>
</div>
</main>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/main.js') }}" type="module"></script>
{% endblock %}

43
templates/login.html Normal file
View File

@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}Login — KI Context Tool{% endblock %}
{% block content %}
<div class="min-h-screen flex items-center justify-center">
<div class="w-full max-w-md bg-copilot-panel border border-copilot-border rounded-2xl p-8 shadow-2xl">
<div class="flex items-center gap-3 mb-8">
<svg class="w-8 h-8 text-copilot-accent" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/>
</svg>
<h1 class="text-2xl font-bold text-copilot-text">KI Context Tool</h1>
</div>
<h2 class="text-lg font-semibold mb-6 text-copilot-muted">Sign in to your account</h2>
<form method="POST" class="space-y-5">
{{ form.hidden_tag() }}
<div>
<label class="block text-sm font-medium mb-1 text-copilot-muted">{{ form.email.label.text }}</label>
{{ form.email(class="w-full bg-copilot-bg border border-copilot-border rounded-lg px-4 py-2.5 text-copilot-text focus:outline-none focus:border-copilot-accent transition", placeholder="you@example.com") }}
{% for err in form.email.errors %}
<p class="text-copilot-danger text-xs mt-1">{{ err }}</p>
{% endfor %}
</div>
<div>
<label class="block text-sm font-medium mb-1 text-copilot-muted">{{ form.password.label.text }}</label>
{{ form.password(class="w-full bg-copilot-bg border border-copilot-border rounded-lg px-4 py-2.5 text-copilot-text focus:outline-none focus:border-copilot-accent transition", placeholder="••••••••") }}
{% for err in form.password.errors %}
<p class="text-copilot-danger text-xs mt-1">{{ err }}</p>
{% endfor %}
</div>
{{ form.submit(class="w-full bg-copilot-accent hover:bg-copilot-accentHover text-copilot-bg font-semibold py-2.5 rounded-lg transition cursor-pointer") }}
</form>
<p class="text-center text-copilot-muted text-sm mt-6">
No account?
<a href="{{ url_for('auth.register') }}" class="text-copilot-accent hover:underline">Register</a>
</p>
</div>
</div>
{% endblock %}

59
templates/register.html Normal file
View File

@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}Register — KI Context Tool{% endblock %}
{% block content %}
<div class="min-h-screen flex items-center justify-center">
<div class="w-full max-w-md bg-copilot-panel border border-copilot-border rounded-2xl p-8 shadow-2xl">
<div class="flex items-center gap-3 mb-8">
<svg class="w-8 h-8 text-copilot-accent" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/>
</svg>
<h1 class="text-2xl font-bold text-copilot-text">KI Context Tool</h1>
</div>
<h2 class="text-lg font-semibold mb-6 text-copilot-muted">Create an account</h2>
<form method="POST" class="space-y-5">
{{ form.hidden_tag() }}
<div>
<label class="block text-sm font-medium mb-1 text-copilot-muted">{{ form.username.label.text }}</label>
{{ form.username(class="w-full bg-copilot-bg border border-copilot-border rounded-lg px-4 py-2.5 text-copilot-text focus:outline-none focus:border-copilot-accent transition", placeholder="johndoe") }}
{% for err in form.username.errors %}
<p class="text-copilot-danger text-xs mt-1">{{ err }}</p>
{% endfor %}
</div>
<div>
<label class="block text-sm font-medium mb-1 text-copilot-muted">{{ form.email.label.text }}</label>
{{ form.email(class="w-full bg-copilot-bg border border-copilot-border rounded-lg px-4 py-2.5 text-copilot-text focus:outline-none focus:border-copilot-accent transition", placeholder="you@example.com") }}
{% for err in form.email.errors %}
<p class="text-copilot-danger text-xs mt-1">{{ err }}</p>
{% endfor %}
</div>
<div>
<label class="block text-sm font-medium mb-1 text-copilot-muted">{{ form.password.label.text }}</label>
{{ form.password(class="w-full bg-copilot-bg border border-copilot-border rounded-lg px-4 py-2.5 text-copilot-text focus:outline-none focus:border-copilot-accent transition", placeholder="Min. 8 characters") }}
{% for err in form.password.errors %}
<p class="text-copilot-danger text-xs mt-1">{{ err }}</p>
{% endfor %}
</div>
<div>
<label class="block text-sm font-medium mb-1 text-copilot-muted">{{ form.confirm.label.text }}</label>
{{ form.confirm(class="w-full bg-copilot-bg border border-copilot-border rounded-lg px-4 py-2.5 text-copilot-text focus:outline-none focus:border-copilot-accent transition", placeholder="Repeat password") }}
{% for err in form.confirm.errors %}
<p class="text-copilot-danger text-xs mt-1">{{ err }}</p>
{% endfor %}
</div>
{{ form.submit(class="w-full bg-copilot-accent hover:bg-copilot-accentHover text-copilot-bg font-semibold py-2.5 rounded-lg transition cursor-pointer") }}
</form>
<p class="text-center text-copilot-muted text-sm mt-6">
Already have an account?
<a href="{{ url_for('auth.login') }}" class="text-copilot-accent hover:underline">Login</a>
</p>
</div>
</div>
{% endblock %}