feat: AI assistant chat widget + session categories + anomaly detection
- Migration 0005: sessions.category, manual_entries.category, ai_flags table, assistant_messages table - AiFlag + AssistantMessage ORM models added to models.py - src/services/assistant.py: gap detection (>30min gaps, low active/wall ratio, uncategorized sessions, long days), Anthropic tool_use loop with 7 tools (get_daily_summary, get_sessions, get_project_stats, detect_anomalies, create_manual_entry, set_session_category, get_unresolved_flags) - src/routers/assistant.py: POST /api/assistant/chat (SSE streaming), GET/DELETE /history, GET /flags, POST /flags/scan, PATCH /flags/:id/resolve, PATCH /sessions/:id/category - APScheduler: hourly anomaly scan for all users, persists AiFlag records - AssistantWidget.vue: floating bottom-right chat panel, streaming SSE rendering, quick-hint chips, tool activity indicators, red badge for unresolved flags, markdown rendering, clear history Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ff52d502b8
commit
2533f4b046
9 changed files with 1305 additions and 1 deletions
104
alembic/versions/0005_session_category.py
Normal file
104
alembic/versions/0005_session_category.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""Add session category and time_gaps view for accurate time accounting
|
||||
|
||||
Revision ID: 0005
|
||||
Revises: 0004
|
||||
Create Date: 2026-05-06
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0005"
|
||||
down_revision = "0004"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
CATEGORIES = ("coding", "thinking", "deployment", "meeting", "review", "other")
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Add category to sessions — defaults to NULL (uncategorized)
|
||||
op.add_column(
|
||||
"sessions",
|
||||
sa.Column(
|
||||
"category",
|
||||
sa.String(20),
|
||||
nullable=True,
|
||||
comment="coding|thinking|deployment|meeting|review|other",
|
||||
),
|
||||
)
|
||||
op.create_index("ix_sessions_category", "sessions", ["user_id", "category"])
|
||||
|
||||
# Add category to manual_entries as well
|
||||
op.add_column(
|
||||
"manual_entries",
|
||||
sa.Column(
|
||||
"category",
|
||||
sa.String(20),
|
||||
nullable=True,
|
||||
server_default="other",
|
||||
),
|
||||
)
|
||||
|
||||
# ai_flags: anomalies/suggestions surfaced by the assistant
|
||||
op.create_table(
|
||||
"ai_flags",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("flag_date", sa.Date, nullable=False),
|
||||
sa.Column(
|
||||
"kind",
|
||||
sa.String(30),
|
||||
nullable=False,
|
||||
comment="gap|long_session|uncategorized|missing_overhead",
|
||||
),
|
||||
sa.Column("description", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("entity_type", sa.String(20), nullable=True), # session|manual_entry|day
|
||||
sa.Column("entity_id", sa.String(36), nullable=True),
|
||||
sa.Column("resolved", sa.Boolean, nullable=False, server_default="false"),
|
||||
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now()
|
||||
),
|
||||
)
|
||||
op.create_index("ix_ai_flags_user_date", "ai_flags", ["user_id", "flag_date"])
|
||||
op.create_index("ix_ai_flags_resolved", "ai_flags", ["user_id", "resolved"])
|
||||
|
||||
# assistant_messages: persisted chat history per user
|
||||
op.create_table(
|
||||
"assistant_messages",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("role", sa.String(10), nullable=False), # user|assistant
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("tool_calls", sa.JSON, nullable=True), # tool use metadata
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now()
|
||||
),
|
||||
)
|
||||
op.create_index("ix_assistant_messages_user", "assistant_messages", ["user_id", "created_at"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("ix_assistant_messages_user", "assistant_messages")
|
||||
op.drop_table("assistant_messages")
|
||||
|
||||
op.drop_index("ix_ai_flags_resolved", "ai_flags")
|
||||
op.drop_index("ix_ai_flags_user_date", "ai_flags")
|
||||
op.drop_table("ai_flags")
|
||||
|
||||
op.drop_column("manual_entries", "category")
|
||||
|
||||
op.drop_index("ix_sessions_category", "sessions")
|
||||
op.drop_column("sessions", "category")
|
||||
|
|
@ -23,7 +23,7 @@ def _ensure_static_dir() -> None:
|
|||
)
|
||||
from src.middleware.logging import LoggingMiddleware
|
||||
from src.routers import admin, auth, dashboard, events, ingest, keys, projects
|
||||
from src.routers import calendar, tasks, manual_entries, budgets, tags, devops, exports, reports
|
||||
from src.routers import calendar, tasks, manual_entries, budgets, tags, devops, exports, reports, assistant
|
||||
from src.services.scheduler import scheduler, setup_scheduler
|
||||
|
||||
BASE = settings.BASE_PATH
|
||||
|
|
@ -92,6 +92,7 @@ for router in [
|
|||
devops.router,
|
||||
exports.router,
|
||||
reports.router,
|
||||
assistant.router,
|
||||
]:
|
||||
app.include_router(router)
|
||||
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ class Session(Base):
|
|||
files_changed: Mapped[list] = mapped_column(JSONB, default=list)
|
||||
raw_stats: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
task_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
category: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="sessions")
|
||||
|
|
@ -176,6 +177,7 @@ class ManualEntry(Base):
|
|||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
notes: Mapped[str] = mapped_column(Text, default="")
|
||||
source: Mapped[str] = mapped_column(String(20), default="manual")
|
||||
category: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
project: Mapped["Project | None"] = relationship(foreign_keys=[project_id])
|
||||
|
|
@ -268,3 +270,35 @@ class AuditLog(Base):
|
|||
entity_id: Mapped[str] = mapped_column(String(64), default="")
|
||||
payload_json: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
|
||||
class AiFlag(Base):
|
||||
"""Anomaly/suggestion surfaced by the AI assistant."""
|
||||
__tablename__ = "ai_flags"
|
||||
|
||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid)
|
||||
user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
flag_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(30), nullable=False) # gap|long_session|uncategorized|missing_overhead
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
entity_type: Mapped[str | None] = mapped_column(String(20), nullable=True) # session|manual_entry|day
|
||||
entity_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
resolved: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user: Mapped["User"] = relationship(foreign_keys=[user_id])
|
||||
|
||||
|
||||
class AssistantMessage(Base):
|
||||
"""Persisted chat history for the AI assistant per user."""
|
||||
__tablename__ = "assistant_messages"
|
||||
|
||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=new_uuid)
|
||||
user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
role: Mapped[str] = mapped_column(String(10), nullable=False) # user|assistant
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
tool_calls: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user: Mapped["User"] = relationship(foreign_keys=[user_id])
|
||||
|
|
|
|||
139
src/routers/assistant.py
Normal file
139
src/routers/assistant.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"""AI assistant router — streaming chat + flag management + session categorization."""
|
||||
import logging
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth import CurrentUser
|
||||
from src.database import get_db
|
||||
from src.models import AiFlag, AssistantMessage, Session
|
||||
from src.schemas import (
|
||||
AiFlagOut,
|
||||
AssistantChatIn,
|
||||
AssistantMessageOut,
|
||||
SessionCategoryIn,
|
||||
)
|
||||
from src.services.assistant import chat_stream, detect_day_anomalies, persist_flags
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/assistant", tags=["assistant"])
|
||||
|
||||
|
||||
@router.post("/chat")
|
||||
async def chat(
|
||||
body: AssistantChatIn,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> StreamingResponse:
|
||||
"""Stream chat response as SSE. Each event is a JSON object on a `data:` line."""
|
||||
return StreamingResponse(
|
||||
chat_stream(user, body.message, db),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/history", response_model=list[AssistantMessageOut])
|
||||
async def get_history(
|
||||
user: CurrentUser,
|
||||
limit: int = 50,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[AssistantMessage]:
|
||||
result = await db.execute(
|
||||
select(AssistantMessage)
|
||||
.where(AssistantMessage.user_id == user.id)
|
||||
.order_by(AssistantMessage.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(reversed(result.scalars().all()))
|
||||
|
||||
|
||||
@router.delete("/history", status_code=204)
|
||||
async def clear_history(
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
result = await db.execute(
|
||||
select(AssistantMessage).where(AssistantMessage.user_id == user.id)
|
||||
)
|
||||
for msg in result.scalars().all():
|
||||
await db.delete(msg)
|
||||
await db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
# ── Flags ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/flags", response_model=list[AiFlagOut])
|
||||
async def list_flags(
|
||||
user: CurrentUser,
|
||||
days_back: int = 14,
|
||||
resolved: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[AiFlag]:
|
||||
from datetime import timedelta
|
||||
since = datetime.now(timezone.utc).date() - timedelta(days=days_back)
|
||||
q = (
|
||||
select(AiFlag)
|
||||
.where(
|
||||
AiFlag.user_id == user.id,
|
||||
AiFlag.flag_date >= since,
|
||||
AiFlag.resolved == resolved,
|
||||
)
|
||||
.order_by(AiFlag.flag_date.desc(), AiFlag.created_at.desc())
|
||||
)
|
||||
result = await db.execute(q)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/flags/scan", response_model=list[AiFlagOut])
|
||||
async def scan_day(
|
||||
check_date: date,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[AiFlag]:
|
||||
"""Run anomaly detection for a specific date and persist results."""
|
||||
anomalies = await detect_day_anomalies(user, check_date, db)
|
||||
flags = await persist_flags(user, check_date, anomalies, db)
|
||||
await db.commit()
|
||||
return flags
|
||||
|
||||
|
||||
@router.patch("/flags/{flag_id}/resolve", response_model=AiFlagOut)
|
||||
async def resolve_flag(
|
||||
flag_id: str,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AiFlag:
|
||||
flag = await db.get(AiFlag, flag_id)
|
||||
if not flag or flag.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Flag not found")
|
||||
flag.resolved = True
|
||||
flag.resolved_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
await db.refresh(flag)
|
||||
return flag
|
||||
|
||||
|
||||
# ── Session categorization ────────────────────────────────────────────────────
|
||||
|
||||
@router.patch("/sessions/{session_id}/category", response_model=dict)
|
||||
async def set_session_category(
|
||||
session_id: str,
|
||||
body: SessionCategoryIn,
|
||||
user: CurrentUser,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
session = await db.get(Session, session_id)
|
||||
if not session or session.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
session.category = body.category
|
||||
await db.commit()
|
||||
return {"id": session.id, "category": session.category}
|
||||
|
|
@ -405,3 +405,36 @@ class AiReportOut(BaseModel):
|
|||
class GenerateReportIn(BaseModel):
|
||||
type: str = Field(pattern="^(daily|weekly)$")
|
||||
date: date
|
||||
|
||||
|
||||
# ── AI Assistant ──────────────────────────────────────────────────────────────
|
||||
|
||||
class AiFlagOut(BaseModel):
|
||||
id: str
|
||||
flag_date: date
|
||||
kind: str
|
||||
description: str
|
||||
entity_type: str | None
|
||||
entity_id: str | None
|
||||
resolved: bool
|
||||
resolved_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AssistantMessageOut(BaseModel):
|
||||
id: str
|
||||
role: str
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AssistantChatIn(BaseModel):
|
||||
message: str = Field(min_length=1, max_length=4000)
|
||||
|
||||
|
||||
class SessionCategoryIn(BaseModel):
|
||||
category: str | None = Field(default=None, pattern="^(coding|thinking|deployment|meeting|review|other)?$")
|
||||
|
|
|
|||
534
src/services/assistant.py
Normal file
534
src/services/assistant.py
Normal file
|
|
@ -0,0 +1,534 @@
|
|||
"""AI assistant service: gap detection, anomaly analysis, tool-use chat."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.config import settings
|
||||
from src.models import AiFlag, AssistantMessage, ManualEntry, Project, Session, Task, User
|
||||
from src.services.aggregator import _union_hours
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ── Gap detection constants ───────────────────────────────────────────────────
|
||||
GAP_THRESHOLD_MINUTES = 30 # gaps between sessions longer than this may be unlogged work
|
||||
LONG_SESSION_HOURS = 8 # single session wall-clock > this is suspicious
|
||||
LONG_DAY_HOURS = 14 # total day wall-clock > this is suspicious
|
||||
RATIO_THRESHOLD = 0.4 # active_hours / wall_clock < 40% → likely thinking time
|
||||
|
||||
|
||||
# ── Gap / anomaly detection ───────────────────────────────────────────────────
|
||||
|
||||
async def detect_day_anomalies(
|
||||
user: User,
|
||||
check_date: date,
|
||||
db: AsyncSession,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Analyse a single day and return list of anomaly dicts (not persisted)."""
|
||||
sessions_result = await db.execute(
|
||||
select(Session, Project.display_name)
|
||||
.join(Project, Session.project_id == Project.id)
|
||||
.where(Session.user_id == user.id, Session.date == check_date)
|
||||
.order_by(Session.start_at)
|
||||
)
|
||||
rows = sessions_result.all()
|
||||
|
||||
anomalies: list[dict[str, Any]] = []
|
||||
|
||||
if not rows:
|
||||
return anomalies
|
||||
|
||||
intervals = [(s.start_at, s.end_at) for s, _ in rows]
|
||||
total_wall = _union_hours(intervals)
|
||||
|
||||
# Suspiciously long day
|
||||
if total_wall > LONG_DAY_HOURS:
|
||||
anomalies.append({
|
||||
"kind": "long_session",
|
||||
"description": f"Day total {total_wall:.1f}h exceeds {LONG_DAY_HOURS}h — verify all entries",
|
||||
"entity_type": "day",
|
||||
"entity_id": check_date.isoformat(),
|
||||
})
|
||||
|
||||
# Per-session checks
|
||||
for s, display_name in rows:
|
||||
wall = (s.end_at - s.start_at).total_seconds() / 3600
|
||||
|
||||
if wall > LONG_SESSION_HOURS:
|
||||
anomalies.append({
|
||||
"kind": "long_session",
|
||||
"description": (
|
||||
f"Session on {display_name} lasted {wall:.1f}h "
|
||||
f"({s.start_at.strftime('%H:%M')}–{s.end_at.strftime('%H:%M')}). "
|
||||
"Consider splitting into coding + thinking/deployment entries."
|
||||
),
|
||||
"entity_type": "session",
|
||||
"entity_id": s.id,
|
||||
})
|
||||
|
||||
if wall > 0 and s.active_hours / wall < RATIO_THRESHOLD:
|
||||
anomalies.append({
|
||||
"kind": "uncategorized",
|
||||
"description": (
|
||||
f"Session on {display_name}: active_hours ({s.active_hours:.1f}h) is only "
|
||||
f"{s.active_hours / wall * 100:.0f}% of wall-clock ({wall:.1f}h). "
|
||||
"The difference might be thinking/review time — add a manual entry."
|
||||
),
|
||||
"entity_type": "session",
|
||||
"entity_id": s.id,
|
||||
})
|
||||
|
||||
if not s.category:
|
||||
anomalies.append({
|
||||
"kind": "uncategorized",
|
||||
"description": f"Session on {display_name} ({s.start_at.strftime('%H:%M')}–{s.end_at.strftime('%H:%M')}) has no category.",
|
||||
"entity_type": "session",
|
||||
"entity_id": s.id,
|
||||
})
|
||||
|
||||
# Gaps between consecutive sessions
|
||||
sorted_intervals = sorted(intervals, key=lambda x: x[0])
|
||||
for i in range(1, len(sorted_intervals)):
|
||||
prev_end = sorted_intervals[i - 1][1]
|
||||
curr_start = sorted_intervals[i][0]
|
||||
gap_min = (curr_start - prev_end).total_seconds() / 60
|
||||
if gap_min >= GAP_THRESHOLD_MINUTES:
|
||||
anomalies.append({
|
||||
"kind": "gap",
|
||||
"description": (
|
||||
f"Gap of {gap_min:.0f} min between sessions "
|
||||
f"({prev_end.strftime('%H:%M')}–{curr_start.strftime('%H:%M')}). "
|
||||
"Was this a meeting, call, or thinking time? Consider adding a manual entry."
|
||||
),
|
||||
"entity_type": "day",
|
||||
"entity_id": check_date.isoformat(),
|
||||
})
|
||||
|
||||
return anomalies
|
||||
|
||||
|
||||
async def persist_flags(
|
||||
user: User,
|
||||
check_date: date,
|
||||
anomalies: list[dict[str, Any]],
|
||||
db: AsyncSession,
|
||||
) -> list[AiFlag]:
|
||||
"""Upsert-like: clear old unresolved flags for the date then re-insert."""
|
||||
existing = await db.execute(
|
||||
select(AiFlag).where(
|
||||
AiFlag.user_id == user.id,
|
||||
AiFlag.flag_date == check_date,
|
||||
AiFlag.resolved == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
for flag in existing.scalars().all():
|
||||
await db.delete(flag)
|
||||
|
||||
flags = []
|
||||
for a in anomalies:
|
||||
flag = AiFlag(
|
||||
user_id=user.id,
|
||||
flag_date=check_date,
|
||||
kind=a["kind"],
|
||||
description=a["description"],
|
||||
entity_type=a.get("entity_type"),
|
||||
entity_id=a.get("entity_id"),
|
||||
)
|
||||
db.add(flag)
|
||||
flags.append(flag)
|
||||
|
||||
await db.flush()
|
||||
return flags
|
||||
|
||||
|
||||
# ── Anthropic tool definitions ────────────────────────────────────────────────
|
||||
|
||||
TOOLS: list[dict] = [
|
||||
{
|
||||
"name": "get_daily_summary",
|
||||
"description": "Get total hours, session count, projects worked, and tasks for a specific date.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date": {"type": "string", "description": "ISO date YYYY-MM-DD"}
|
||||
},
|
||||
"required": ["date"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_sessions",
|
||||
"description": "Get raw session details for a date range including start/end times, projects, summaries, and categories.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"from_date": {"type": "string", "description": "Start date YYYY-MM-DD"},
|
||||
"to_date": {"type": "string", "description": "End date YYYY-MM-DD"},
|
||||
},
|
||||
"required": ["from_date", "to_date"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_project_stats",
|
||||
"description": "Get total hours logged per project for a date range.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"from_date": {"type": "string", "description": "Start date YYYY-MM-DD"},
|
||||
"to_date": {"type": "string", "description": "End date YYYY-MM-DD"},
|
||||
},
|
||||
"required": ["from_date", "to_date"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "detect_anomalies",
|
||||
"description": (
|
||||
"Analyse a day for time-tracking anomalies: large gaps between sessions, "
|
||||
"sessions without categories, unusually long sessions, low active/wall-clock ratio."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date": {"type": "string", "description": "ISO date YYYY-MM-DD"}
|
||||
},
|
||||
"required": ["date"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "create_manual_entry",
|
||||
"description": (
|
||||
"Create a manual time entry (e.g. for a meeting, deployment, thinking session). "
|
||||
"project_id is optional — pass null if unknown."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"start_at": {"type": "string", "description": "ISO datetime with TZ, e.g. 2026-05-06T14:00:00+00:00"},
|
||||
"end_at": {"type": "string", "description": "ISO datetime with TZ"},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": ["coding", "thinking", "deployment", "meeting", "review", "other"],
|
||||
},
|
||||
"project_id": {"type": ["string", "null"]},
|
||||
},
|
||||
"required": ["title", "start_at", "end_at", "category"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "set_session_category",
|
||||
"description": "Set the category of a specific session.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": {"type": "string"},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": ["coding", "thinking", "deployment", "meeting", "review", "other"],
|
||||
},
|
||||
},
|
||||
"required": ["session_id", "category"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_unresolved_flags",
|
||||
"description": "Get all unresolved AI-detected anomaly flags for the user.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"days_back": {"type": "integer", "description": "How many days back to look (default 7)"}
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ── Tool execution ────────────────────────────────────────────────────────────
|
||||
|
||||
async def execute_tool(
|
||||
tool_name: str,
|
||||
tool_input: dict,
|
||||
user: User,
|
||||
db: AsyncSession,
|
||||
) -> Any:
|
||||
"""Execute an assistant tool call and return a JSON-serialisable result."""
|
||||
today = datetime.now(timezone.utc).date()
|
||||
|
||||
if tool_name == "get_daily_summary":
|
||||
d = date.fromisoformat(tool_input["date"])
|
||||
sessions_result = await db.execute(
|
||||
select(Session, Project.display_name)
|
||||
.join(Project, Session.project_id == Project.id)
|
||||
.where(Session.user_id == user.id, Session.date == d)
|
||||
.order_by(Session.start_at)
|
||||
)
|
||||
rows = sessions_result.all()
|
||||
intervals = [(s.start_at, s.end_at) for s, _ in rows]
|
||||
total = _union_hours(intervals) + (user.daily_overhead_hours if intervals else 0)
|
||||
tasks_result = await db.execute(
|
||||
select(Task).where(Task.user_id == user.id, Task.planned_date == d)
|
||||
)
|
||||
tasks = tasks_result.scalars().all()
|
||||
projects: dict[str, float] = {}
|
||||
for s, name in rows:
|
||||
h = (s.end_at - s.start_at).total_seconds() / 3600
|
||||
projects[name] = projects.get(name, 0) + h
|
||||
return {
|
||||
"date": d.isoformat(),
|
||||
"total_hours": round(total, 2),
|
||||
"session_count": len(rows),
|
||||
"projects": {k: round(v, 2) for k, v in projects.items()},
|
||||
"tasks_done": sum(1 for t in tasks if t.status == "done"),
|
||||
"tasks_total": len(tasks),
|
||||
}
|
||||
|
||||
if tool_name == "get_sessions":
|
||||
fd = date.fromisoformat(tool_input["from_date"])
|
||||
td = date.fromisoformat(tool_input["to_date"])
|
||||
result = await db.execute(
|
||||
select(Session, Project.display_name, Project.job_number)
|
||||
.join(Project, Session.project_id == Project.id)
|
||||
.where(Session.user_id == user.id, Session.date >= fd, Session.date <= td)
|
||||
.order_by(Session.start_at)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": s.id,
|
||||
"date": s.date.isoformat(),
|
||||
"project": name,
|
||||
"job_number": job_number,
|
||||
"start": s.start_at.isoformat(),
|
||||
"end": s.end_at.isoformat(),
|
||||
"wall_clock_h": round((s.end_at - s.start_at).total_seconds() / 3600, 2),
|
||||
"active_h": round(s.active_hours, 2),
|
||||
"category": s.category,
|
||||
"summary": s.work_summary[:200] if s.work_summary else "",
|
||||
}
|
||||
for s, name, job_number in result.all()
|
||||
]
|
||||
|
||||
if tool_name == "get_project_stats":
|
||||
fd = date.fromisoformat(tool_input["from_date"])
|
||||
td = date.fromisoformat(tool_input["to_date"])
|
||||
result = await db.execute(
|
||||
select(Session, Project.display_name, Project.job_number)
|
||||
.join(Project, Session.project_id == Project.id)
|
||||
.where(Session.user_id == user.id, Session.date >= fd, Session.date <= td)
|
||||
)
|
||||
by_project: dict[str, list] = defaultdict(list)
|
||||
for s, name, job_number in result.all():
|
||||
label = f"{job_number} {name}".strip() if job_number else name
|
||||
by_project[label].append((s.start_at, s.end_at))
|
||||
return {
|
||||
label: round(sum((e - st).total_seconds() / 3600 for st, e in intervals), 2)
|
||||
for label, intervals in sorted(by_project.items(), key=lambda x: -sum((e - s).total_seconds() for s, e in x[1]))
|
||||
}
|
||||
|
||||
if tool_name == "detect_anomalies":
|
||||
d = date.fromisoformat(tool_input["date"])
|
||||
anomalies = await detect_day_anomalies(user, d, db)
|
||||
return {"date": d.isoformat(), "anomalies": anomalies, "count": len(anomalies)}
|
||||
|
||||
if tool_name == "create_manual_entry":
|
||||
from src.models import ManualEntry
|
||||
entry = ManualEntry(
|
||||
user_id=user.id,
|
||||
project_id=tool_input.get("project_id"),
|
||||
start_at=datetime.fromisoformat(tool_input["start_at"]),
|
||||
end_at=datetime.fromisoformat(tool_input["end_at"]),
|
||||
title=tool_input["title"],
|
||||
category=tool_input.get("category"),
|
||||
source="assistant",
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
hours = (entry.end_at - entry.start_at).total_seconds() / 3600
|
||||
return {"created": entry.id, "hours": round(hours, 2), "title": entry.title}
|
||||
|
||||
if tool_name == "set_session_category":
|
||||
session = await db.get(Session, tool_input["session_id"])
|
||||
if not session or session.user_id != user.id:
|
||||
return {"error": "Session not found"}
|
||||
session.category = tool_input["category"]
|
||||
await db.flush()
|
||||
return {"updated": session.id, "category": session.category}
|
||||
|
||||
if tool_name == "get_unresolved_flags":
|
||||
days_back = int(tool_input.get("days_back", 7))
|
||||
since = today - timedelta(days=days_back)
|
||||
result = await db.execute(
|
||||
select(AiFlag).where(
|
||||
AiFlag.user_id == user.id,
|
||||
AiFlag.flag_date >= since,
|
||||
AiFlag.resolved == False, # noqa: E712
|
||||
).order_by(AiFlag.flag_date.desc())
|
||||
)
|
||||
flags = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": f.id,
|
||||
"date": f.flag_date.isoformat(),
|
||||
"kind": f.kind,
|
||||
"description": f.description,
|
||||
"entity_type": f.entity_type,
|
||||
"entity_id": f.entity_id,
|
||||
}
|
||||
for f in flags
|
||||
]
|
||||
|
||||
return {"error": f"Unknown tool: {tool_name}"}
|
||||
|
||||
|
||||
# ── Streaming chat ────────────────────────────────────────────────────────────
|
||||
|
||||
SYSTEM_PROMPT = """You are a time-tracking assistant for CC Dashboard — a productivity app for a developer/manager at Oliver Agency.
|
||||
|
||||
Your job is to:
|
||||
1. Help the user understand how they spent their time
|
||||
2. Detect and flag inaccuracies in time logs: gaps between sessions, uncategorized sessions, missing thinking/deployment/meeting time
|
||||
3. Suggest and create manual entries for unlogged work
|
||||
4. Set categories on sessions when appropriate
|
||||
5. Answer questions about projects, hours, and task completion
|
||||
|
||||
Always be proactive: when asked about a day, automatically check for anomalies and mention them.
|
||||
When you detect gaps or uncategorized sessions, suggest specific actions (create manual entry, set category).
|
||||
Respond concisely. Use Markdown for structure when helpful.
|
||||
|
||||
Today's date: {today}
|
||||
User's daily overhead: {overhead}h/day
|
||||
"""
|
||||
|
||||
|
||||
async def chat_stream(
|
||||
user: User,
|
||||
user_message: str,
|
||||
db: AsyncSession,
|
||||
) -> AsyncIterator[str]:
|
||||
"""
|
||||
Stream SSE events for the chat response.
|
||||
Yields strings formatted as SSE data lines.
|
||||
Uses Anthropic tool_use loop with up to 5 rounds.
|
||||
Falls back to a static error message if no API key.
|
||||
"""
|
||||
if not settings.ANTHROPIC_API_KEY:
|
||||
yield f"data: {json.dumps({'type': 'text', 'text': 'Anthropic API key not configured.'})}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
return
|
||||
|
||||
import anthropic
|
||||
|
||||
# Persist user message
|
||||
user_msg_rec = AssistantMessage(
|
||||
user_id=user.id,
|
||||
role="user",
|
||||
content=user_message,
|
||||
)
|
||||
db.add(user_msg_rec)
|
||||
await db.flush()
|
||||
|
||||
# Load recent history (last 20 turns to stay within context)
|
||||
history_result = await db.execute(
|
||||
select(AssistantMessage)
|
||||
.where(AssistantMessage.user_id == user.id)
|
||||
.order_by(AssistantMessage.created_at.desc())
|
||||
.limit(20)
|
||||
)
|
||||
history = list(reversed(history_result.scalars().all()))
|
||||
|
||||
messages: list[dict] = []
|
||||
for m in history:
|
||||
if m.tool_calls:
|
||||
# assistant message with tool_use
|
||||
messages.append({"role": "assistant", "content": json.loads(m.tool_calls) if isinstance(m.tool_calls, str) else m.tool_calls})
|
||||
else:
|
||||
messages.append({"role": m.role, "content": m.content})
|
||||
|
||||
today = datetime.now(timezone.utc).date()
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
|
||||
|
||||
system = SYSTEM_PROMPT.format(today=today.isoformat(), overhead=user.daily_overhead_hours)
|
||||
|
||||
full_response_text = ""
|
||||
tool_calls_log: list[dict] = []
|
||||
|
||||
try:
|
||||
# Agentic loop — up to 5 tool-use rounds
|
||||
for _round in range(5):
|
||||
response = await client.messages.create(
|
||||
model="claude-sonnet-4-6",
|
||||
max_tokens=2048,
|
||||
system=system,
|
||||
tools=TOOLS,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
# Collect text from this round
|
||||
round_text = ""
|
||||
has_tool_use = False
|
||||
|
||||
for block in response.content:
|
||||
if block.type == "text":
|
||||
round_text += block.text
|
||||
full_response_text += block.text
|
||||
# Stream text chunk
|
||||
yield f"data: {json.dumps({'type': 'text', 'text': block.text})}\n\n"
|
||||
|
||||
elif block.type == "tool_use":
|
||||
has_tool_use = True
|
||||
tool_name = block.name
|
||||
tool_input = block.input
|
||||
|
||||
yield f"data: {json.dumps({'type': 'tool_start', 'tool': tool_name})}\n\n"
|
||||
|
||||
try:
|
||||
result = await execute_tool(tool_name, tool_input, user, db)
|
||||
await db.flush()
|
||||
except Exception as exc:
|
||||
log.warning("assistant.tool_error", extra={"tool": tool_name, "error": str(exc)})
|
||||
result = {"error": str(exc)}
|
||||
|
||||
tool_calls_log.append({"tool": tool_name, "input": tool_input, "result": result})
|
||||
|
||||
yield f"data: {json.dumps({'type': 'tool_result', 'tool': tool_name, 'result': result})}\n\n"
|
||||
|
||||
# Add assistant tool_use + tool_result to messages for next round
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": [{"type": "tool_use", "id": block.id, "name": tool_name, "input": tool_input}],
|
||||
})
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": [{"type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result)}],
|
||||
})
|
||||
|
||||
if not has_tool_use:
|
||||
# No more tool calls — done
|
||||
break
|
||||
|
||||
if round_text:
|
||||
# Between-round text already streamed, add to messages
|
||||
messages.append({"role": "assistant", "content": round_text})
|
||||
|
||||
# Persist assistant response
|
||||
if full_response_text or tool_calls_log:
|
||||
asst_msg = AssistantMessage(
|
||||
user_id=user.id,
|
||||
role="assistant",
|
||||
content=full_response_text,
|
||||
tool_calls=tool_calls_log if tool_calls_log else None,
|
||||
)
|
||||
db.add(asst_msg)
|
||||
await db.commit()
|
||||
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
log.error("assistant.chat_error", extra={"user_id": user.id, "error": str(exc)})
|
||||
yield f"data: {json.dumps({'type': 'error', 'text': 'Assistant error — please try again.'})}\n\n"
|
||||
finally:
|
||||
yield "data: [DONE]\n\n"
|
||||
|
|
@ -37,6 +37,29 @@ async def _weekly_report_job() -> None:
|
|||
log.info("weekly_reports.completed")
|
||||
|
||||
|
||||
async def _anomaly_scan_job() -> None:
|
||||
"""Scan yesterday + today for all users and persist AI flags."""
|
||||
from datetime import date, timedelta, datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.database import AsyncSessionLocal
|
||||
from src.models import User
|
||||
from src.services.assistant import detect_day_anomalies, persist_flags
|
||||
|
||||
today = datetime.now(timezone.utc).date()
|
||||
yesterday = today - timedelta(days=1)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(User).where(User.is_active == True)) # noqa: E712
|
||||
for user in result.scalars().all():
|
||||
for d in (yesterday, today):
|
||||
anomalies = await detect_day_anomalies(user, d, db)
|
||||
await persist_flags(user, d, anomalies, db)
|
||||
await db.commit()
|
||||
log.info("anomaly_scan.completed")
|
||||
|
||||
|
||||
def setup_scheduler() -> None:
|
||||
if settings.ADO_PAT:
|
||||
scheduler.add_job(
|
||||
|
|
@ -64,3 +87,12 @@ def setup_scheduler() -> None:
|
|||
id="weekly_report",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# Scan for time-tracking anomalies every hour
|
||||
scheduler.add_job(
|
||||
_anomaly_scan_job,
|
||||
"interval",
|
||||
hours=1,
|
||||
id="anomaly_scan",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Toaster } from 'vue-sonner'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AssistantWidget from '@/components/assistant/AssistantWidget.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const showAssistant = computed(() => authStore.isAuthenticated)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<AssistantWidget v-if="showAssistant" />
|
||||
<Toaster
|
||||
position="top-right"
|
||||
:toast-options="{
|
||||
|
|
|
|||
420
web/src/components/assistant/AssistantWidget.vue
Normal file
420
web/src/components/assistant/AssistantWidget.vue
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
<template>
|
||||
<!-- Floating toggle button -->
|
||||
<button
|
||||
v-if="!isOpen"
|
||||
class="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-amber-400 text-gray-900 shadow-lg hover:bg-amber-300 transition-all duration-200 hover:scale-105 active:scale-95"
|
||||
title="AI Assistant"
|
||||
@click="open"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
||||
</svg>
|
||||
<!-- Unresolved flag badge -->
|
||||
<span
|
||||
v-if="flagCount > 0"
|
||||
class="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-white text-xs font-bold"
|
||||
>{{ flagCount > 9 ? '9+' : flagCount }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Chat panel -->
|
||||
<Transition name="slide-up">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed bottom-6 right-6 z-50 flex flex-col w-[380px] max-h-[600px] rounded-2xl bg-gray-900 border border-gray-700 shadow-2xl overflow-hidden"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-700 bg-gray-800 flex-shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-amber-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-900" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-white">Time Analyst</p>
|
||||
<p class="text-xs text-gray-400">AI assistant</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Flag count chip -->
|
||||
<button
|
||||
v-if="flagCount > 0"
|
||||
class="flex items-center gap-1 rounded-full bg-red-900/40 px-2 py-0.5 text-xs text-red-400 hover:bg-red-900/60 transition-colors"
|
||||
title="View anomalies"
|
||||
@click="loadFlagsMessage"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{{ flagCount }} issue{{ flagCount > 1 ? 's' : '' }}
|
||||
</button>
|
||||
<button class="p-1.5 text-gray-400 hover:text-white transition-colors" title="Clear history" @click="clearHistory">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="p-1.5 text-gray-400 hover:text-white transition-colors" @click="isOpen = false">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div ref="messagesEl" class="flex-1 overflow-y-auto p-3 space-y-3 min-h-0">
|
||||
<!-- Empty state -->
|
||||
<div v-if="messages.length === 0 && !streaming" class="flex flex-col items-center justify-center h-full py-8 text-center">
|
||||
<div class="h-12 w-12 rounded-full bg-amber-400/10 flex items-center justify-center mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-300">Time Analyst</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Ask me about your hours, gaps, or missing time entries.</p>
|
||||
<div class="mt-4 flex flex-wrap justify-center gap-2">
|
||||
<button
|
||||
v-for="hint in quickHints"
|
||||
:key="hint"
|
||||
class="rounded-full border border-gray-600 px-3 py-1.5 text-xs text-gray-400 hover:border-amber-400 hover:text-amber-400 transition-colors"
|
||||
@click="sendQuick(hint)"
|
||||
>{{ hint }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message list -->
|
||||
<template v-for="msg in messages" :key="msg.id">
|
||||
<!-- User message -->
|
||||
<div v-if="msg.role === 'user'" class="flex justify-end">
|
||||
<div class="max-w-[80%] rounded-2xl rounded-tr-sm bg-amber-400 px-3 py-2">
|
||||
<p class="text-sm text-gray-900">{{ msg.content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assistant message -->
|
||||
<div v-else class="flex justify-start">
|
||||
<div class="max-w-[90%] rounded-2xl rounded-tl-sm bg-gray-800 border border-gray-700 px-3 py-2">
|
||||
<div
|
||||
class="text-sm text-gray-200 prose prose-sm prose-invert max-w-none"
|
||||
v-html="renderMarkdown(msg.content)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Streaming assistant message -->
|
||||
<div v-if="streaming || streamingText" class="flex justify-start">
|
||||
<div class="max-w-[90%] rounded-2xl rounded-tl-sm bg-gray-800 border border-gray-700 px-3 py-2">
|
||||
<!-- Tool activity indicators -->
|
||||
<div v-if="activeTools.length > 0" class="mb-2 space-y-1">
|
||||
<div
|
||||
v-for="tool in activeTools"
|
||||
:key="tool"
|
||||
class="flex items-center gap-1.5 text-xs text-amber-400"
|
||||
>
|
||||
<svg class="h-3 w-3 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
{{ toolLabel(tool) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="streamingText"
|
||||
class="text-sm text-gray-200 prose prose-sm prose-invert max-w-none"
|
||||
v-html="renderMarkdown(streamingText)"
|
||||
/>
|
||||
<div v-else class="flex items-center gap-1">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-gray-500 animate-bounce" style="animation-delay: 0ms"/>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-gray-500 animate-bounce" style="animation-delay: 150ms"/>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-gray-500 animate-bounce" style="animation-delay: 300ms"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input area -->
|
||||
<div class="flex-shrink-0 border-t border-gray-700 bg-gray-800 p-3">
|
||||
<div class="flex items-end gap-2">
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="inputText"
|
||||
rows="1"
|
||||
placeholder="Ask about your time..."
|
||||
class="flex-1 resize-none rounded-xl bg-gray-700 border border-gray-600 px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-amber-400 transition-colors max-h-24 overflow-y-auto"
|
||||
:disabled="streaming"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
@keydown.enter.shift.exact="() => {}"
|
||||
@input="autoResize"
|
||||
/>
|
||||
<button
|
||||
:disabled="!inputText.trim() || streaming"
|
||||
class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl bg-amber-400 text-gray-900 transition-all hover:bg-amber-300 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
@click="send"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1.5 text-center text-xs text-gray-600">Enter to send · Shift+Enter for newline</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick, watch } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const messages = ref<Message[]>([])
|
||||
const inputText = ref('')
|
||||
const streaming = ref(false)
|
||||
const streamingText = ref('')
|
||||
const activeTools = ref<string[]>([])
|
||||
const flagCount = ref(0)
|
||||
const messagesEl = ref<HTMLElement>()
|
||||
const inputEl = ref<HTMLTextAreaElement>()
|
||||
|
||||
const quickHints = [
|
||||
'Check today for gaps',
|
||||
'How many hours this week?',
|
||||
'Any uncategorized sessions?',
|
||||
'Summarize yesterday',
|
||||
]
|
||||
|
||||
function toolLabel(tool: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
get_daily_summary: 'Loading daily summary…',
|
||||
get_sessions: 'Fetching sessions…',
|
||||
get_project_stats: 'Calculating project hours…',
|
||||
detect_anomalies: 'Scanning for gaps…',
|
||||
create_manual_entry: 'Creating manual entry…',
|
||||
set_session_category: 'Updating category…',
|
||||
get_unresolved_flags: 'Loading flags…',
|
||||
}
|
||||
return labels[tool] ?? `Running ${tool}…`
|
||||
}
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
// Simple markdown to HTML: bold, code, headers, lists, line breaks
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/### (.+)/g, '<h3 class="text-sm font-semibold text-white mt-2 mb-1">$1</h3>')
|
||||
.replace(/## (.+)/g, '<h3 class="text-sm font-semibold text-white mt-2 mb-1">$1</h3>')
|
||||
.replace(/# (.+)/g, '<h3 class="font-semibold text-white mt-2 mb-1">$1</h3>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong class="text-white">$1</strong>')
|
||||
.replace(/`(.+?)`/g, '<code class="rounded bg-gray-700 px-1 py-0.5 text-amber-400 text-xs font-mono">$1</code>')
|
||||
.replace(/^- (.+)/gm, '<li class="ml-3 list-disc">$1</li>')
|
||||
.replace(/^(\d+)\. (.+)/gm, '<li class="ml-3 list-decimal">$2</li>')
|
||||
.replace(/<\/li>\n<li/g, '</li><li')
|
||||
.replace(/\n\n/g, '</p><p class="mt-1">')
|
||||
.replace(/\n/g, '<br/>')
|
||||
}
|
||||
|
||||
async function loadHistory(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch('/cc-dashboard/api/assistant/history?limit=30', {
|
||||
headers: { Authorization: `Bearer ${authStore.token}` },
|
||||
})
|
||||
if (!res.ok) return
|
||||
messages.value = await res.json()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFlagCount(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch('/cc-dashboard/api/assistant/flags?days_back=7&resolved=false', {
|
||||
headers: { Authorization: `Bearer ${authStore.token}` },
|
||||
})
|
||||
if (!res.ok) return
|
||||
const flags = await res.json()
|
||||
flagCount.value = flags.length
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function clearHistory(): Promise<void> {
|
||||
await fetch('/cc-dashboard/api/assistant/history', {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${authStore.token}` },
|
||||
})
|
||||
messages.value = []
|
||||
}
|
||||
|
||||
function loadFlagsMessage(): void {
|
||||
sendQuick('Show me all unresolved time-tracking issues from the last 7 days')
|
||||
}
|
||||
|
||||
function sendQuick(text: string): void {
|
||||
inputText.value = text
|
||||
send()
|
||||
}
|
||||
|
||||
async function send(): Promise<void> {
|
||||
const text = inputText.value.trim()
|
||||
if (!text || streaming.value) return
|
||||
|
||||
inputText.value = ''
|
||||
resetTextareaHeight()
|
||||
|
||||
const userMsg: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: text,
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
messages.value.push(userMsg)
|
||||
scrollBottom()
|
||||
|
||||
streaming.value = true
|
||||
streamingText.value = ''
|
||||
activeTools.value = []
|
||||
|
||||
try {
|
||||
const res = await fetch('/cc-dashboard/api/assistant/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.token}`,
|
||||
},
|
||||
body: JSON.stringify({ message: text }),
|
||||
})
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
throw new Error(`HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
|
||||
const lines = buf.split('\n')
|
||||
buf = lines.pop() ?? ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
const payload = line.slice(6).trim()
|
||||
if (payload === '[DONE]') continue
|
||||
|
||||
try {
|
||||
const event = JSON.parse(payload)
|
||||
if (event.type === 'text') {
|
||||
streamingText.value += event.text
|
||||
scrollBottom()
|
||||
} else if (event.type === 'tool_start') {
|
||||
if (!activeTools.value.includes(event.tool)) {
|
||||
activeTools.value.push(event.tool)
|
||||
}
|
||||
} else if (event.type === 'tool_result') {
|
||||
activeTools.value = activeTools.value.filter(t => t !== event.tool)
|
||||
} else if (event.type === 'error') {
|
||||
streamingText.value = event.text
|
||||
}
|
||||
} catch {
|
||||
// non-JSON line, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finalise streaming message
|
||||
if (streamingText.value) {
|
||||
messages.value.push({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: streamingText.value,
|
||||
created_at: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
// Refresh flag count after AI might have found issues
|
||||
await loadFlagCount()
|
||||
|
||||
} catch (err) {
|
||||
messages.value.push({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: 'Failed to get response. Please try again.',
|
||||
created_at: new Date().toISOString(),
|
||||
})
|
||||
} finally {
|
||||
streaming.value = false
|
||||
streamingText.value = ''
|
||||
activeTools.value = []
|
||||
scrollBottom()
|
||||
}
|
||||
}
|
||||
|
||||
function scrollBottom(): void {
|
||||
nextTick(() => {
|
||||
if (messagesEl.value) {
|
||||
messagesEl.value.scrollTop = messagesEl.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function autoResize(e: Event): void {
|
||||
const el = e.target as HTMLTextAreaElement
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${Math.min(el.scrollHeight, 96)}px`
|
||||
}
|
||||
|
||||
function resetTextareaHeight(): void {
|
||||
if (inputEl.value) {
|
||||
inputEl.value.style.height = 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
async function open(): Promise<void> {
|
||||
isOpen.value = true
|
||||
await loadHistory()
|
||||
nextTick(() => inputEl.value?.focus())
|
||||
scrollBottom()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadFlagCount()
|
||||
// Refresh flag count every 5 minutes
|
||||
setInterval(loadFlagCount, 5 * 60 * 1000)
|
||||
})
|
||||
|
||||
watch(isOpen, (val) => {
|
||||
if (val) loadFlagCount()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
|
||||
.prose :deep(li) {
|
||||
margin: 0.125rem 0;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Reference in a new issue