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:
14
.dockerignore
Normal file
14
.dockerignore
Normal 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
27
.env.example
Normal 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
39
Dockerfile
Normal 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
56
app.py
Normal 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
1
blueprints/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# blueprints package
|
||||||
68
blueprints/auth.py
Normal file
68
blueprints/auth.py
Normal 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
154
blueprints/chat.py
Normal 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
63
blueprints/context.py
Normal 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
100
blueprints/documents.py
Normal 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
16
blueprints/main.py
Normal 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
34
config.py
Normal 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
26
docker-compose.yml
Normal 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
9
models/__init__.py
Normal 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
45
models/chat_session.py
Normal 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
43
models/document.py
Normal 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
21
models/user.py
Normal 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
17
requirements.txt
Normal 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
1
services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# services package
|
||||||
32
services/document_parser.py
Normal file
32
services/document_parser.py
Normal 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
109
services/llm_service.py
Normal 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
135
services/rag_service.py
Normal 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
31
services/url_scraper.py
Normal 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
69
static/css/style.css
Normal 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
115
static/js/chat.js
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
126
static/js/inline_chat.js
Normal file
126
static/js/inline_chat.js
Normal 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
321
static/js/main.js
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
63
templates/base.html
Normal file
63
templates/base.html
Normal 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>
|
||||||
65
templates/document_view.html
Normal file
65
templates/document_view.html
Normal 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
123
templates/index.html
Normal 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
43
templates/login.html
Normal 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
59
templates/register.html
Normal 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 %}
|
||||||
Reference in New Issue
Block a user