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