mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
Merge pull request #1224 from AnishSarkar22/feat/token-calculation
feat: track and display per-message LLM token usage & UI/UX changes
This commit is contained in:
commit
ae0df70880
31 changed files with 1118 additions and 292 deletions
|
|
@ -0,0 +1,85 @@
|
||||||
|
"""125_add_token_usage_table
|
||||||
|
|
||||||
|
Revision ID: 125
|
||||||
|
Revises: 124
|
||||||
|
Create Date: 2026-04-14
|
||||||
|
|
||||||
|
Adds token_usage table for tracking LLM token consumption per message.
|
||||||
|
Supports future extension via usage_type for indexing, image gen, etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "125"
|
||||||
|
down_revision: str | None = "124"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
if sa.inspect(conn).has_table("token_usage"):
|
||||||
|
return
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"token_usage",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("prompt_tokens", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column(
|
||||||
|
"completion_tokens", sa.Integer(), nullable=False, server_default="0"
|
||||||
|
),
|
||||||
|
sa.Column("total_tokens", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("model_breakdown", JSONB, nullable=True),
|
||||||
|
sa.Column("call_details", JSONB, nullable=True),
|
||||||
|
sa.Column("usage_type", sa.String(50), nullable=False, server_default="chat"),
|
||||||
|
sa.Column(
|
||||||
|
"thread_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("new_chat_threads.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"message_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("new_chat_messages.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"search_space_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("searchspaces.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("user.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.TIMESTAMP(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index("ix_token_usage_thread_id", "token_usage", ["thread_id"])
|
||||||
|
op.create_index("ix_token_usage_message_id", "token_usage", ["message_id"])
|
||||||
|
op.create_index(
|
||||||
|
"ix_token_usage_search_space_id", "token_usage", ["search_space_id"]
|
||||||
|
)
|
||||||
|
op.create_index("ix_token_usage_user_id", "token_usage", ["user_id"])
|
||||||
|
op.create_index("ix_token_usage_usage_type", "token_usage", ["usage_type"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("token_usage")
|
||||||
|
|
@ -647,6 +647,11 @@ class NewChatThread(BaseModel, TimestampMixin):
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
foreign_keys="[PublicChatSnapshot.thread_id]",
|
foreign_keys="[PublicChatSnapshot.thread_id]",
|
||||||
)
|
)
|
||||||
|
token_usages = relationship(
|
||||||
|
"TokenUsage",
|
||||||
|
back_populates="thread",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class NewChatMessage(BaseModel, TimestampMixin):
|
class NewChatMessage(BaseModel, TimestampMixin):
|
||||||
|
|
@ -685,6 +690,63 @@ class NewChatMessage(BaseModel, TimestampMixin):
|
||||||
back_populates="message",
|
back_populates="message",
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
token_usage = relationship(
|
||||||
|
"TokenUsage",
|
||||||
|
back_populates="message",
|
||||||
|
uselist=False,
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenUsage(BaseModel, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Tracks LLM token consumption per assistant turn.
|
||||||
|
|
||||||
|
One row per usage event. For chat, linked to a specific message via message_id.
|
||||||
|
The usage_type column enables future extension to track non-chat usage
|
||||||
|
(indexing, image generation, podcasts, etc.) without schema changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "token_usage"
|
||||||
|
|
||||||
|
prompt_tokens = Column(Integer, nullable=False, default=0)
|
||||||
|
completion_tokens = Column(Integer, nullable=False, default=0)
|
||||||
|
total_tokens = Column(Integer, nullable=False, default=0)
|
||||||
|
model_breakdown = Column(JSONB, nullable=True)
|
||||||
|
call_details = Column(JSONB, nullable=True)
|
||||||
|
|
||||||
|
usage_type = Column(String(50), nullable=False, default="chat", index=True)
|
||||||
|
|
||||||
|
thread_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("new_chat_threads.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
message_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("new_chat_messages.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
search_space_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("searchspaces.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
user_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("user.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
thread = relationship("NewChatThread", back_populates="token_usages")
|
||||||
|
message = relationship("NewChatMessage", back_populates="token_usage")
|
||||||
|
search_space = relationship("SearchSpace")
|
||||||
|
user = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
class PublicChatSnapshot(BaseModel, TimestampMixin):
|
class PublicChatSnapshot(BaseModel, TimestampMixin):
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,9 @@ from app.schemas.new_chat import (
|
||||||
ThreadHistoryLoadResponse,
|
ThreadHistoryLoadResponse,
|
||||||
ThreadListItem,
|
ThreadListItem,
|
||||||
ThreadListResponse,
|
ThreadListResponse,
|
||||||
|
TokenUsageSummary,
|
||||||
)
|
)
|
||||||
|
from app.services.token_tracking_service import record_token_usage
|
||||||
from app.tasks.chat.stream_new_chat import stream_new_chat, stream_resume_chat
|
from app.tasks.chat.stream_new_chat import stream_new_chat, stream_resume_chat
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
from app.utils.rbac import check_permission
|
from app.utils.rbac import check_permission
|
||||||
|
|
@ -473,10 +475,13 @@ async def get_thread_messages(
|
||||||
# Check thread-level access based on visibility
|
# Check thread-level access based on visibility
|
||||||
await check_thread_access(session, thread, user)
|
await check_thread_access(session, thread, user)
|
||||||
|
|
||||||
# Get messages with their authors loaded
|
# Get messages with their authors and token usage loaded
|
||||||
messages_result = await session.execute(
|
messages_result = await session.execute(
|
||||||
select(NewChatMessage)
|
select(NewChatMessage)
|
||||||
.options(selectinload(NewChatMessage.author))
|
.options(
|
||||||
|
selectinload(NewChatMessage.author),
|
||||||
|
selectinload(NewChatMessage.token_usage),
|
||||||
|
)
|
||||||
.filter(NewChatMessage.thread_id == thread_id)
|
.filter(NewChatMessage.thread_id == thread_id)
|
||||||
.order_by(NewChatMessage.created_at)
|
.order_by(NewChatMessage.created_at)
|
||||||
)
|
)
|
||||||
|
|
@ -493,6 +498,9 @@ async def get_thread_messages(
|
||||||
author_id=msg.author_id,
|
author_id=msg.author_id,
|
||||||
author_display_name=msg.author.display_name if msg.author else None,
|
author_display_name=msg.author.display_name if msg.author else None,
|
||||||
author_avatar_url=msg.author.avatar_url if msg.author else None,
|
author_avatar_url=msg.author.avatar_url if msg.author else None,
|
||||||
|
token_usage=TokenUsageSummary.model_validate(msg.token_usage)
|
||||||
|
if msg.token_usage
|
||||||
|
else None,
|
||||||
)
|
)
|
||||||
for msg in db_messages
|
for msg in db_messages
|
||||||
]
|
]
|
||||||
|
|
@ -530,7 +538,11 @@ async def get_thread_full(
|
||||||
try:
|
try:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(NewChatThread)
|
select(NewChatThread)
|
||||||
.options(selectinload(NewChatThread.messages))
|
.options(
|
||||||
|
selectinload(NewChatThread.messages).selectinload(
|
||||||
|
NewChatMessage.token_usage
|
||||||
|
),
|
||||||
|
)
|
||||||
.filter(NewChatThread.id == thread_id)
|
.filter(NewChatThread.id == thread_id)
|
||||||
)
|
)
|
||||||
thread = result.scalars().first()
|
thread = result.scalars().first()
|
||||||
|
|
@ -935,11 +947,37 @@ async def append_message(
|
||||||
|
|
||||||
# flush assigns the PK/defaults without a round-trip SELECT
|
# flush assigns the PK/defaults without a round-trip SELECT
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
|
# Persist token usage if provided (for assistant messages)
|
||||||
|
token_usage_data = raw_body.get("token_usage")
|
||||||
|
if token_usage_data and message_role == NewChatMessageRole.ASSISTANT:
|
||||||
|
await record_token_usage(
|
||||||
|
session,
|
||||||
|
usage_type="chat",
|
||||||
|
search_space_id=thread.search_space_id,
|
||||||
|
user_id=user.id,
|
||||||
|
prompt_tokens=token_usage_data.get("prompt_tokens", 0),
|
||||||
|
completion_tokens=token_usage_data.get("completion_tokens", 0),
|
||||||
|
total_tokens=token_usage_data.get("total_tokens", 0),
|
||||||
|
model_breakdown=token_usage_data.get("usage"),
|
||||||
|
call_details=token_usage_data.get("call_details"),
|
||||||
|
thread_id=thread_id,
|
||||||
|
message_id=db_message.id,
|
||||||
|
)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# Return the in-memory object (already has id from flush) instead of
|
# Build response manually to avoid lazy-loading the token_usage
|
||||||
# doing an extra refresh() SELECT.
|
# relationship after commit (which would trigger MissingGreenlet).
|
||||||
return db_message
|
return NewChatMessageRead(
|
||||||
|
id=db_message.id,
|
||||||
|
thread_id=db_message.thread_id,
|
||||||
|
role=db_message.role,
|
||||||
|
content=db_message.content,
|
||||||
|
created_at=db_message.created_at,
|
||||||
|
author_id=db_message.author_id,
|
||||||
|
token_usage=None,
|
||||||
|
)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -1003,6 +1041,7 @@ async def list_messages(
|
||||||
# Get messages
|
# Get messages
|
||||||
query = (
|
query = (
|
||||||
select(NewChatMessage)
|
select(NewChatMessage)
|
||||||
|
.options(selectinload(NewChatMessage.token_usage))
|
||||||
.filter(NewChatMessage.thread_id == thread_id)
|
.filter(NewChatMessage.thread_id == thread_id)
|
||||||
.order_by(NewChatMessage.created_at)
|
.order_by(NewChatMessage.created_at)
|
||||||
.offset(skip)
|
.offset(skip)
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,14 @@ class NewChatMessageCreate(NewChatMessageBase):
|
||||||
thread_id: int
|
thread_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class TokenUsageSummary(BaseModel):
|
||||||
|
prompt_tokens: int = 0
|
||||||
|
completion_tokens: int = 0
|
||||||
|
total_tokens: int = 0
|
||||||
|
model_breakdown: dict | None = None
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
class NewChatMessageRead(NewChatMessageBase, IDModel, TimestampModel):
|
class NewChatMessageRead(NewChatMessageBase, IDModel, TimestampModel):
|
||||||
"""Schema for reading a message."""
|
"""Schema for reading a message."""
|
||||||
|
|
||||||
|
|
@ -41,6 +49,7 @@ class NewChatMessageRead(NewChatMessageBase, IDModel, TimestampModel):
|
||||||
author_id: UUID | None = None
|
author_id: UUID | None = None
|
||||||
author_display_name: str | None = None
|
author_display_name: str | None = None
|
||||||
author_avatar_url: str | None = None
|
author_avatar_url: str | None = None
|
||||||
|
token_usage: TokenUsageSummary | None = None
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -820,7 +820,9 @@ class ChatLiteLLMRouter(BaseChatModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Convert response to ChatResult with potential tool calls
|
# Convert response to ChatResult with potential tool calls
|
||||||
message = self._convert_response_to_message(response.choices[0].message)
|
message = self._convert_response_to_message(
|
||||||
|
response.choices[0].message, response=response
|
||||||
|
)
|
||||||
generation = ChatGeneration(message=message)
|
generation = ChatGeneration(message=message)
|
||||||
|
|
||||||
return ChatResult(generations=[generation])
|
return ChatResult(generations=[generation])
|
||||||
|
|
@ -886,7 +888,9 @@ class ChatLiteLLMRouter(BaseChatModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Convert response to ChatResult with potential tool calls
|
# Convert response to ChatResult with potential tool calls
|
||||||
message = self._convert_response_to_message(response.choices[0].message)
|
message = self._convert_response_to_message(
|
||||||
|
response.choices[0].message, response=response
|
||||||
|
)
|
||||||
generation = ChatGeneration(message=message)
|
generation = ChatGeneration(message=message)
|
||||||
|
|
||||||
return ChatResult(generations=[generation])
|
return ChatResult(generations=[generation])
|
||||||
|
|
@ -970,6 +974,7 @@ class ChatLiteLLMRouter(BaseChatModel):
|
||||||
messages=formatted_messages,
|
messages=formatted_messages,
|
||||||
stop=stop,
|
stop=stop,
|
||||||
stream=True,
|
stream=True,
|
||||||
|
stream_options={"include_usage": True},
|
||||||
**call_kwargs,
|
**call_kwargs,
|
||||||
)
|
)
|
||||||
except ContextWindowExceededError as e:
|
except ContextWindowExceededError as e:
|
||||||
|
|
@ -1075,7 +1080,9 @@ class ChatLiteLLMRouter(BaseChatModel):
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _convert_response_to_message(self, response_message: Any) -> AIMessage:
|
def _convert_response_to_message(
|
||||||
|
self, response_message: Any, response: Any = None
|
||||||
|
) -> AIMessage:
|
||||||
"""Convert a LiteLLM response message to a LangChain AIMessage."""
|
"""Convert a LiteLLM response message to a LangChain AIMessage."""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
|
@ -1098,9 +1105,22 @@ class ChatLiteLLMRouter(BaseChatModel):
|
||||||
tool_call["args"] = tc.function.arguments
|
tool_call["args"] = tc.function.arguments
|
||||||
tool_calls.append(tool_call)
|
tool_calls.append(tool_call)
|
||||||
|
|
||||||
|
extra_kwargs: dict[str, Any] = {}
|
||||||
|
if response:
|
||||||
|
usage = getattr(response, "usage", None)
|
||||||
|
if usage:
|
||||||
|
extra_kwargs["usage_metadata"] = {
|
||||||
|
"input_tokens": getattr(usage, "prompt_tokens", 0) or 0,
|
||||||
|
"output_tokens": getattr(usage, "completion_tokens", 0) or 0,
|
||||||
|
"total_tokens": getattr(usage, "total_tokens", 0) or 0,
|
||||||
|
}
|
||||||
|
extra_kwargs["response_metadata"] = {
|
||||||
|
"model_name": getattr(response, "model", "unknown"),
|
||||||
|
}
|
||||||
|
|
||||||
if tool_calls:
|
if tool_calls:
|
||||||
return AIMessage(content=content, tool_calls=tool_calls)
|
return AIMessage(content=content, tool_calls=tool_calls, **extra_kwargs)
|
||||||
return AIMessage(content=content)
|
return AIMessage(content=content, **extra_kwargs)
|
||||||
|
|
||||||
def _convert_delta_to_chunk(self, delta: Any) -> AIMessageChunk | None:
|
def _convert_delta_to_chunk(self, delta: Any) -> AIMessageChunk | None:
|
||||||
"""Convert a streaming delta to an AIMessageChunk."""
|
"""Convert a streaming delta to an AIMessageChunk."""
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from app.services.llm_router_service import (
|
||||||
get_auto_mode_llm,
|
get_auto_mode_llm,
|
||||||
is_auto_mode,
|
is_auto_mode,
|
||||||
)
|
)
|
||||||
|
from app.services.token_tracking_service import token_tracker
|
||||||
|
|
||||||
# Configure litellm to automatically drop unsupported parameters
|
# Configure litellm to automatically drop unsupported parameters
|
||||||
litellm.drop_params = True
|
litellm.drop_params = True
|
||||||
|
|
@ -22,10 +23,11 @@ litellm.drop_params = True
|
||||||
# Memory controls: prevent unbounded internal accumulation
|
# Memory controls: prevent unbounded internal accumulation
|
||||||
litellm.telemetry = False
|
litellm.telemetry = False
|
||||||
litellm.cache = None
|
litellm.cache = None
|
||||||
litellm.success_callback = []
|
|
||||||
litellm.failure_callback = []
|
litellm.failure_callback = []
|
||||||
litellm.input_callback = []
|
litellm.input_callback = []
|
||||||
|
|
||||||
|
litellm.callbacks = [token_tracker]
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
210
surfsense_backend/app/services/token_tracking_service.py
Normal file
210
surfsense_backend/app/services/token_tracking_service.py
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
"""
|
||||||
|
Token usage tracking via LiteLLM custom callback.
|
||||||
|
|
||||||
|
Uses a ContextVar-scoped accumulator to group all LLM calls within a single
|
||||||
|
async request/turn. The accumulated data is emitted via SSE and persisted
|
||||||
|
when the frontend calls appendMessage.
|
||||||
|
|
||||||
|
The module also provides ``record_token_usage``, a thin async helper that
|
||||||
|
creates a ``TokenUsage`` row for *any* usage type (chat, indexing, image
|
||||||
|
generation, podcasts, …). Call sites should prefer this helper over
|
||||||
|
constructing ``TokenUsage`` manually so that logging and error handling
|
||||||
|
stay consistent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import logging
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from litellm.integrations.custom_logger import CustomLogger
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db import TokenUsage
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TokenCallRecord:
|
||||||
|
model: str
|
||||||
|
prompt_tokens: int
|
||||||
|
completion_tokens: int
|
||||||
|
total_tokens: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TurnTokenAccumulator:
|
||||||
|
"""Accumulates token usage across all LLM calls within a single user turn."""
|
||||||
|
|
||||||
|
calls: list[TokenCallRecord] = field(default_factory=list)
|
||||||
|
|
||||||
|
def add(
|
||||||
|
self,
|
||||||
|
model: str,
|
||||||
|
prompt_tokens: int,
|
||||||
|
completion_tokens: int,
|
||||||
|
total_tokens: int,
|
||||||
|
) -> None:
|
||||||
|
self.calls.append(
|
||||||
|
TokenCallRecord(
|
||||||
|
model=model,
|
||||||
|
prompt_tokens=prompt_tokens,
|
||||||
|
completion_tokens=completion_tokens,
|
||||||
|
total_tokens=total_tokens,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def per_message_summary(self) -> dict[str, dict[str, int]]:
|
||||||
|
"""Return token counts grouped by model name."""
|
||||||
|
by_model: dict[str, dict[str, int]] = {}
|
||||||
|
for c in self.calls:
|
||||||
|
entry = by_model.setdefault(
|
||||||
|
c.model,
|
||||||
|
{"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
||||||
|
)
|
||||||
|
entry["prompt_tokens"] += c.prompt_tokens
|
||||||
|
entry["completion_tokens"] += c.completion_tokens
|
||||||
|
entry["total_tokens"] += c.total_tokens
|
||||||
|
return by_model
|
||||||
|
|
||||||
|
@property
|
||||||
|
def grand_total(self) -> int:
|
||||||
|
return sum(c.total_tokens for c in self.calls)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_prompt_tokens(self) -> int:
|
||||||
|
return sum(c.prompt_tokens for c in self.calls)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_completion_tokens(self) -> int:
|
||||||
|
return sum(c.completion_tokens for c in self.calls)
|
||||||
|
|
||||||
|
def serialized_calls(self) -> list[dict[str, Any]]:
|
||||||
|
return [dataclasses.asdict(c) for c in self.calls]
|
||||||
|
|
||||||
|
|
||||||
|
_turn_accumulator: ContextVar[TurnTokenAccumulator | None] = ContextVar(
|
||||||
|
"_turn_accumulator", default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def start_turn() -> TurnTokenAccumulator:
|
||||||
|
"""Create a fresh accumulator for the current async context and return it."""
|
||||||
|
acc = TurnTokenAccumulator()
|
||||||
|
_turn_accumulator.set(acc)
|
||||||
|
logger.info("[TokenTracking] start_turn: new accumulator created (id=%s)", id(acc))
|
||||||
|
return acc
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_accumulator() -> TurnTokenAccumulator | None:
|
||||||
|
return _turn_accumulator.get()
|
||||||
|
|
||||||
|
|
||||||
|
class TokenTrackingCallback(CustomLogger):
|
||||||
|
"""LiteLLM callback that captures token usage into the turn accumulator."""
|
||||||
|
|
||||||
|
async def async_log_success_event(
|
||||||
|
self,
|
||||||
|
kwargs: dict[str, Any],
|
||||||
|
response_obj: Any,
|
||||||
|
start_time: Any,
|
||||||
|
end_time: Any,
|
||||||
|
) -> None:
|
||||||
|
acc = _turn_accumulator.get()
|
||||||
|
if acc is None:
|
||||||
|
logger.debug(
|
||||||
|
"[TokenTracking] async_log_success_event fired but no accumulator in context"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
usage = getattr(response_obj, "usage", None)
|
||||||
|
if not usage:
|
||||||
|
logger.debug(
|
||||||
|
"[TokenTracking] async_log_success_event fired but response has no usage data"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0
|
||||||
|
completion_tokens = getattr(usage, "completion_tokens", 0) or 0
|
||||||
|
total_tokens = getattr(usage, "total_tokens", 0) or 0
|
||||||
|
|
||||||
|
model = kwargs.get("model", "unknown")
|
||||||
|
|
||||||
|
acc.add(
|
||||||
|
model=model,
|
||||||
|
prompt_tokens=prompt_tokens,
|
||||||
|
completion_tokens=completion_tokens,
|
||||||
|
total_tokens=total_tokens,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"[TokenTracking] Captured: model=%s prompt=%d completion=%d total=%d (accumulator now has %d calls)",
|
||||||
|
model,
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
total_tokens,
|
||||||
|
len(acc.calls),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
token_tracker = TokenTrackingCallback()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Persistence helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def record_token_usage(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
usage_type: str,
|
||||||
|
search_space_id: int,
|
||||||
|
user_id: UUID,
|
||||||
|
prompt_tokens: int = 0,
|
||||||
|
completion_tokens: int = 0,
|
||||||
|
total_tokens: int = 0,
|
||||||
|
model_breakdown: dict[str, Any] | None = None,
|
||||||
|
call_details: dict[str, Any] | None = None,
|
||||||
|
thread_id: int | None = None,
|
||||||
|
message_id: int | None = None,
|
||||||
|
) -> TokenUsage | None:
|
||||||
|
"""Persist a single ``TokenUsage`` row.
|
||||||
|
|
||||||
|
Returns the record on success, ``None`` if persistence failed (the
|
||||||
|
failure is logged but never propagated so callers don't need to
|
||||||
|
wrap this in try/except).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
record = TokenUsage(
|
||||||
|
usage_type=usage_type,
|
||||||
|
prompt_tokens=prompt_tokens,
|
||||||
|
completion_tokens=completion_tokens,
|
||||||
|
total_tokens=total_tokens,
|
||||||
|
model_breakdown=model_breakdown,
|
||||||
|
call_details=call_details,
|
||||||
|
thread_id=thread_id,
|
||||||
|
message_id=message_id,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
session.add(record)
|
||||||
|
logger.debug(
|
||||||
|
"[TokenTracking] recorded %s usage: prompt=%d completion=%d total=%d",
|
||||||
|
usage_type,
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
total_tokens,
|
||||||
|
)
|
||||||
|
return record
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"[TokenTracking] failed to record %s token usage",
|
||||||
|
usage_type,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
@ -51,7 +51,7 @@ from app.db import (
|
||||||
async_session_maker,
|
async_session_maker,
|
||||||
shielded_async_session,
|
shielded_async_session,
|
||||||
)
|
)
|
||||||
from app.prompts import TITLE_GENERATION_PROMPT_TEMPLATE
|
from app.prompts import TITLE_GENERATION_PROMPT
|
||||||
from app.services.chat_session_state_service import (
|
from app.services.chat_session_state_service import (
|
||||||
clear_ai_responding,
|
clear_ai_responding,
|
||||||
set_ai_responding,
|
set_ai_responding,
|
||||||
|
|
@ -1171,6 +1171,10 @@ async def stream_new_chat(
|
||||||
_t_total = time.perf_counter()
|
_t_total = time.perf_counter()
|
||||||
log_system_snapshot("stream_new_chat_START")
|
log_system_snapshot("stream_new_chat_START")
|
||||||
|
|
||||||
|
from app.services.token_tracking_service import start_turn
|
||||||
|
|
||||||
|
accumulator = start_turn()
|
||||||
|
|
||||||
session = async_session_maker()
|
session = async_session_maker()
|
||||||
try:
|
try:
|
||||||
# Mark AI as responding to this user for live collaboration
|
# Mark AI as responding to this user for live collaboration
|
||||||
|
|
@ -1456,22 +1460,71 @@ async def stream_new_chat(
|
||||||
)
|
)
|
||||||
is_first_response = (assistant_count_result.scalar() or 0) == 0
|
is_first_response = (assistant_count_result.scalar() or 0) == 0
|
||||||
|
|
||||||
title_task: asyncio.Task[str | None] | None = None
|
title_task: asyncio.Task[tuple[str | None, dict | None]] | None = None
|
||||||
if is_first_response:
|
if is_first_response:
|
||||||
|
|
||||||
async def _generate_title() -> str | None:
|
async def _generate_title() -> tuple[str | None, dict | None]:
|
||||||
|
"""Generate a short title via litellm.acompletion.
|
||||||
|
|
||||||
|
Returns (title, usage_dict). Usage is extracted directly from
|
||||||
|
the response object because litellm fires its async callback
|
||||||
|
via fire-and-forget ``create_task``, so the
|
||||||
|
``TokenTrackingCallback`` would run too late. We also blank
|
||||||
|
the accumulator in this child-task context so the late callback
|
||||||
|
doesn't double-count.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
title_chain = TITLE_GENERATION_PROMPT_TEMPLATE | llm
|
from litellm import acompletion
|
||||||
title_result = await title_chain.ainvoke(
|
|
||||||
{"user_query": user_query[:500]}
|
from app.services.llm_router_service import LLMRouterService
|
||||||
|
from app.services.token_tracking_service import _turn_accumulator
|
||||||
|
|
||||||
|
_turn_accumulator.set(None)
|
||||||
|
|
||||||
|
prompt = TITLE_GENERATION_PROMPT.replace(
|
||||||
|
"{user_query}", user_query[:500]
|
||||||
)
|
)
|
||||||
if title_result and hasattr(title_result, "content"):
|
messages = [{"role": "user", "content": prompt}]
|
||||||
raw_title = title_result.content.strip()
|
|
||||||
|
if getattr(llm, "model", None) == "auto":
|
||||||
|
router = LLMRouterService.get_router()
|
||||||
|
response = await router.acompletion(
|
||||||
|
model="auto", messages=messages
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = await acompletion(
|
||||||
|
model=llm.model,
|
||||||
|
messages=messages,
|
||||||
|
api_key=getattr(llm, "api_key", None),
|
||||||
|
api_base=getattr(llm, "api_base", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
usage_info = None
|
||||||
|
usage = getattr(response, "usage", None)
|
||||||
|
if usage:
|
||||||
|
raw_model = getattr(llm, "model", "") or ""
|
||||||
|
model_name = (
|
||||||
|
raw_model.split("/", 1)[-1]
|
||||||
|
if "/" in raw_model
|
||||||
|
else (raw_model or response.model or "unknown")
|
||||||
|
)
|
||||||
|
usage_info = {
|
||||||
|
"model": model_name,
|
||||||
|
"prompt_tokens": getattr(usage, "prompt_tokens", 0) or 0,
|
||||||
|
"completion_tokens": getattr(usage, "completion_tokens", 0)
|
||||||
|
or 0,
|
||||||
|
"total_tokens": getattr(usage, "total_tokens", 0) or 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
raw_title = response.choices[0].message.content.strip()
|
||||||
if raw_title and len(raw_title) <= 100:
|
if raw_title and len(raw_title) <= 100:
|
||||||
return raw_title.strip("\"'")
|
return raw_title.strip("\"'"), usage_info
|
||||||
|
return None, usage_info
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logging.getLogger(__name__).exception(
|
||||||
return None
|
"[TitleGen] _generate_title failed"
|
||||||
|
)
|
||||||
|
return None, None
|
||||||
|
|
||||||
title_task = asyncio.create_task(_generate_title())
|
title_task = asyncio.create_task(_generate_title())
|
||||||
|
|
||||||
|
|
@ -1503,7 +1556,9 @@ async def stream_new_chat(
|
||||||
|
|
||||||
# Inject title update mid-stream as soon as the background task finishes
|
# Inject title update mid-stream as soon as the background task finishes
|
||||||
if title_task is not None and title_task.done() and not title_emitted:
|
if title_task is not None and title_task.done() and not title_emitted:
|
||||||
generated_title = title_task.result()
|
generated_title, title_usage = title_task.result()
|
||||||
|
if title_usage:
|
||||||
|
accumulator.add(**title_usage)
|
||||||
if generated_title:
|
if generated_title:
|
||||||
async with shielded_async_session() as title_session:
|
async with shielded_async_session() as title_session:
|
||||||
title_thread_result = await title_session.execute(
|
title_thread_result = await title_session.execute(
|
||||||
|
|
@ -1528,6 +1583,26 @@ async def stream_new_chat(
|
||||||
if stream_result.is_interrupted:
|
if stream_result.is_interrupted:
|
||||||
if title_task is not None and not title_task.done():
|
if title_task is not None and not title_task.done():
|
||||||
title_task.cancel()
|
title_task.cancel()
|
||||||
|
|
||||||
|
usage_summary = accumulator.per_message_summary()
|
||||||
|
_perf_log.info(
|
||||||
|
"[token_usage] interrupted new_chat: calls=%d total=%d summary=%s",
|
||||||
|
len(accumulator.calls),
|
||||||
|
accumulator.grand_total,
|
||||||
|
usage_summary,
|
||||||
|
)
|
||||||
|
if usage_summary:
|
||||||
|
yield streaming_service.format_data(
|
||||||
|
"token-usage",
|
||||||
|
{
|
||||||
|
"usage": usage_summary,
|
||||||
|
"prompt_tokens": accumulator.total_prompt_tokens,
|
||||||
|
"completion_tokens": accumulator.total_completion_tokens,
|
||||||
|
"total_tokens": accumulator.grand_total,
|
||||||
|
"call_details": accumulator.serialized_calls(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
yield streaming_service.format_finish_step()
|
yield streaming_service.format_finish_step()
|
||||||
yield streaming_service.format_finish()
|
yield streaming_service.format_finish()
|
||||||
yield streaming_service.format_done()
|
yield streaming_service.format_done()
|
||||||
|
|
@ -1535,7 +1610,9 @@ async def stream_new_chat(
|
||||||
|
|
||||||
# If the title task didn't finish during streaming, await it now
|
# If the title task didn't finish during streaming, await it now
|
||||||
if title_task is not None and not title_emitted:
|
if title_task is not None and not title_emitted:
|
||||||
generated_title = await title_task
|
generated_title, title_usage = await title_task
|
||||||
|
if title_usage:
|
||||||
|
accumulator.add(**title_usage)
|
||||||
if generated_title:
|
if generated_title:
|
||||||
async with shielded_async_session() as title_session:
|
async with shielded_async_session() as title_session:
|
||||||
title_thread_result = await title_session.execute(
|
title_thread_result = await title_session.execute(
|
||||||
|
|
@ -1549,6 +1626,25 @@ async def stream_new_chat(
|
||||||
chat_id, generated_title
|
chat_id, generated_title
|
||||||
)
|
)
|
||||||
|
|
||||||
|
usage_summary = accumulator.per_message_summary()
|
||||||
|
_perf_log.info(
|
||||||
|
"[token_usage] normal new_chat: calls=%d total=%d summary=%s",
|
||||||
|
len(accumulator.calls),
|
||||||
|
accumulator.grand_total,
|
||||||
|
usage_summary,
|
||||||
|
)
|
||||||
|
if usage_summary:
|
||||||
|
yield streaming_service.format_data(
|
||||||
|
"token-usage",
|
||||||
|
{
|
||||||
|
"usage": usage_summary,
|
||||||
|
"prompt_tokens": accumulator.total_prompt_tokens,
|
||||||
|
"completion_tokens": accumulator.total_completion_tokens,
|
||||||
|
"total_tokens": accumulator.grand_total,
|
||||||
|
"call_details": accumulator.serialized_calls(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# Fire background memory extraction if the agent didn't handle it.
|
# Fire background memory extraction if the agent didn't handle it.
|
||||||
# Shared threads write to team memory; private threads write to user memory.
|
# Shared threads write to team memory; private threads write to user memory.
|
||||||
if not stream_result.agent_called_update_memory:
|
if not stream_result.agent_called_update_memory:
|
||||||
|
|
@ -1666,6 +1762,10 @@ async def stream_resume_chat(
|
||||||
stream_result = StreamResult()
|
stream_result = StreamResult()
|
||||||
_t_total = time.perf_counter()
|
_t_total = time.perf_counter()
|
||||||
|
|
||||||
|
from app.services.token_tracking_service import start_turn
|
||||||
|
|
||||||
|
accumulator = start_turn()
|
||||||
|
|
||||||
session = async_session_maker()
|
session = async_session_maker()
|
||||||
try:
|
try:
|
||||||
if user_id:
|
if user_id:
|
||||||
|
|
@ -1789,11 +1889,49 @@ async def stream_resume_chat(
|
||||||
chat_id,
|
chat_id,
|
||||||
)
|
)
|
||||||
if stream_result.is_interrupted:
|
if stream_result.is_interrupted:
|
||||||
|
usage_summary = accumulator.per_message_summary()
|
||||||
|
_perf_log.info(
|
||||||
|
"[token_usage] interrupted resume_chat: calls=%d total=%d summary=%s",
|
||||||
|
len(accumulator.calls),
|
||||||
|
accumulator.grand_total,
|
||||||
|
usage_summary,
|
||||||
|
)
|
||||||
|
if usage_summary:
|
||||||
|
yield streaming_service.format_data(
|
||||||
|
"token-usage",
|
||||||
|
{
|
||||||
|
"usage": usage_summary,
|
||||||
|
"prompt_tokens": accumulator.total_prompt_tokens,
|
||||||
|
"completion_tokens": accumulator.total_completion_tokens,
|
||||||
|
"total_tokens": accumulator.grand_total,
|
||||||
|
"call_details": accumulator.serialized_calls(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
yield streaming_service.format_finish_step()
|
yield streaming_service.format_finish_step()
|
||||||
yield streaming_service.format_finish()
|
yield streaming_service.format_finish()
|
||||||
yield streaming_service.format_done()
|
yield streaming_service.format_done()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
usage_summary = accumulator.per_message_summary()
|
||||||
|
_perf_log.info(
|
||||||
|
"[token_usage] normal resume_chat: calls=%d total=%d summary=%s",
|
||||||
|
len(accumulator.calls),
|
||||||
|
accumulator.grand_total,
|
||||||
|
usage_summary,
|
||||||
|
)
|
||||||
|
if usage_summary:
|
||||||
|
yield streaming_service.format_data(
|
||||||
|
"token-usage",
|
||||||
|
{
|
||||||
|
"usage": usage_summary,
|
||||||
|
"prompt_tokens": accumulator.total_prompt_tokens,
|
||||||
|
"completion_tokens": accumulator.total_completion_tokens,
|
||||||
|
"total_tokens": accumulator.grand_total,
|
||||||
|
"call_details": accumulator.serialized_calls(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
yield streaming_service.format_finish_step()
|
yield streaming_service.format_finish_step()
|
||||||
yield streaming_service.format_finish()
|
yield streaming_service.format_finish()
|
||||||
yield streaming_service.format_done()
|
yield streaming_service.format_done()
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,7 @@ export function LocalLoginForm() {
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||||
{t("password")}
|
{t("password")}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative mt-1">
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
|
|
@ -183,7 +183,7 @@ export function LocalLoginForm() {
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className={`mt-1 block w-full rounded-md border pr-10 px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${
|
className={`block w-full rounded-md border pr-10 px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${
|
||||||
error.title
|
error.title
|
||||||
? "border-destructive focus:border-destructive focus:ring-destructive/40"
|
? "border-destructive focus:border-destructive focus:ring-destructive/40"
|
||||||
: "border-border focus:border-primary focus:ring-primary/40"
|
: "border-border focus:border-primary focus:ring-primary/40"
|
||||||
|
|
@ -193,7 +193,7 @@ export function LocalLoginForm() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword((prev) => !prev)}
|
onClick={() => setShowPassword((prev) => !prev)}
|
||||||
className="absolute inset-y-0 right-0 flex items-center pr-3 mt-1 text-muted-foreground hover:text-foreground"
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground"
|
||||||
aria-label={showPassword ? t("hide_password") : t("show_password")}
|
aria-label={showPassword ? t("hide_password") : t("show_password")}
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
|
||||||
import { useMessagesSync } from "@/hooks/use-messages-sync";
|
import { useMessagesSync } from "@/hooks/use-messages-sync";
|
||||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||||
import { getBearerToken } from "@/lib/auth-utils";
|
import { getBearerToken } from "@/lib/auth-utils";
|
||||||
|
import { createTokenUsageStore, TokenUsageProvider, type TokenUsageData } from "@/components/assistant-ui/token-usage-context";
|
||||||
import { convertToThreadMessage } from "@/lib/chat/message-utils";
|
import { convertToThreadMessage } from "@/lib/chat/message-utils";
|
||||||
import {
|
import {
|
||||||
isPodcastGenerating,
|
isPodcastGenerating,
|
||||||
|
|
@ -66,6 +67,8 @@ import {
|
||||||
getRegenerateUrl,
|
getRegenerateUrl,
|
||||||
getThreadFull,
|
getThreadFull,
|
||||||
getThreadMessages,
|
getThreadMessages,
|
||||||
|
type ThreadListItem,
|
||||||
|
type ThreadListResponse,
|
||||||
type ThreadRecord,
|
type ThreadRecord,
|
||||||
} from "@/lib/chat/thread-persistence";
|
} from "@/lib/chat/thread-persistence";
|
||||||
import { NotFoundError } from "@/lib/error";
|
import { NotFoundError } from "@/lib/error";
|
||||||
|
|
@ -195,6 +198,7 @@ export default function NewChatPage() {
|
||||||
const [currentThread, setCurrentThread] = useState<ThreadRecord | null>(null);
|
const [currentThread, setCurrentThread] = useState<ThreadRecord | null>(null);
|
||||||
const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
|
const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
|
const [tokenUsageStore] = useState(() => createTokenUsageStore());
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
const [pendingInterrupt, setPendingInterrupt] = useState<{
|
const [pendingInterrupt, setPendingInterrupt] = useState<{
|
||||||
threadId: number;
|
threadId: number;
|
||||||
|
|
@ -307,6 +311,7 @@ export default function NewChatPage() {
|
||||||
setThreadId(null);
|
setThreadId(null);
|
||||||
setCurrentThread(null);
|
setCurrentThread(null);
|
||||||
setMentionedDocuments([]);
|
setMentionedDocuments([]);
|
||||||
|
tokenUsageStore.clear();
|
||||||
setSidebarDocuments([]);
|
setSidebarDocuments([]);
|
||||||
setMessageDocumentsMap({});
|
setMessageDocumentsMap({});
|
||||||
clearPlanOwnerRegistry();
|
clearPlanOwnerRegistry();
|
||||||
|
|
@ -330,6 +335,12 @@ export default function NewChatPage() {
|
||||||
const loadedMessages = messagesResponse.messages.map(convertToThreadMessage);
|
const loadedMessages = messagesResponse.messages.map(convertToThreadMessage);
|
||||||
setMessages(loadedMessages);
|
setMessages(loadedMessages);
|
||||||
|
|
||||||
|
for (const msg of messagesResponse.messages) {
|
||||||
|
if (msg.token_usage) {
|
||||||
|
tokenUsageStore.set(`msg-${msg.id}`, msg.token_usage as TokenUsageData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const restoredDocsMap: Record<string, MentionedDocumentInfo[]> = {};
|
const restoredDocsMap: Record<string, MentionedDocumentInfo[]> = {};
|
||||||
for (const msg of messagesResponse.messages) {
|
for (const msg of messagesResponse.messages) {
|
||||||
if (msg.role === "user") {
|
if (msg.role === "user") {
|
||||||
|
|
@ -374,6 +385,7 @@ export default function NewChatPage() {
|
||||||
closeEditorPanel,
|
closeEditorPanel,
|
||||||
removeChatTab,
|
removeChatTab,
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
|
tokenUsageStore,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Initialize on mount, and re-init when switching search spaces (even if urlChatId is the same)
|
// Initialize on mount, and re-init when switching search spaces (even if urlChatId is the same)
|
||||||
|
|
@ -624,6 +636,7 @@ export default function NewChatPage() {
|
||||||
};
|
};
|
||||||
const { contentParts, toolCallIndices } = contentPartsState;
|
const { contentParts, toolCallIndices } = contentPartsState;
|
||||||
let wasInterrupted = false;
|
let wasInterrupted = false;
|
||||||
|
let tokenUsageData: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
// Add placeholder assistant message
|
// Add placeholder assistant message
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
|
|
@ -759,9 +772,21 @@ export default function NewChatPage() {
|
||||||
if (titleData?.title && titleData?.threadId === currentThreadId) {
|
if (titleData?.title && titleData?.threadId === currentThreadId) {
|
||||||
setCurrentThread((prev) => (prev ? { ...prev, title: titleData.title } : prev));
|
setCurrentThread((prev) => (prev ? { ...prev, title: titleData.title } : prev));
|
||||||
updateChatTabTitle({ chatId: currentThreadId, title: titleData.title });
|
updateChatTabTitle({ chatId: currentThreadId, title: titleData.title });
|
||||||
queryClient.invalidateQueries({
|
queryClient.setQueriesData<ThreadListResponse>(
|
||||||
queryKey: ["threads", String(searchSpaceId)],
|
{ queryKey: ["threads", String(searchSpaceId)] },
|
||||||
});
|
(old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
const updateTitle = (list: ThreadListItem[]) =>
|
||||||
|
list.map((t) =>
|
||||||
|
t.id === titleData.threadId ? { ...t, title: titleData.title } : t
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
threads: updateTitle(old.threads),
|
||||||
|
archived_threads: updateTitle(old.archived_threads),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -821,6 +846,11 @@ export default function NewChatPage() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "data-token-usage":
|
||||||
|
tokenUsageData = parsed.data;
|
||||||
|
tokenUsageStore.set(assistantMsgId, parsed.data as TokenUsageData);
|
||||||
|
break;
|
||||||
|
|
||||||
case "error":
|
case "error":
|
||||||
throw new Error(parsed.errorText || "Server error");
|
throw new Error(parsed.errorText || "Server error");
|
||||||
}
|
}
|
||||||
|
|
@ -835,12 +865,14 @@ export default function NewChatPage() {
|
||||||
const savedMessage = await appendMessage(currentThreadId, {
|
const savedMessage = await appendMessage(currentThreadId, {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: finalContent,
|
content: finalContent,
|
||||||
|
token_usage: tokenUsageData ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update message ID from temporary to database ID so comments work immediately
|
// Update message ID from temporary to database ID so comments work immediately
|
||||||
const newMsgId = `msg-${savedMessage.id}`;
|
const newMsgId = `msg-${savedMessage.id}`;
|
||||||
|
tokenUsageStore.rename(assistantMsgId, newMsgId);
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
|
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update pending interrupt with the new persisted message ID
|
// Update pending interrupt with the new persisted message ID
|
||||||
|
|
@ -930,6 +962,7 @@ export default function NewChatPage() {
|
||||||
currentUser,
|
currentUser,
|
||||||
disabledTools,
|
disabledTools,
|
||||||
updateChatTabTitle,
|
updateChatTabTitle,
|
||||||
|
tokenUsageStore,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -965,6 +998,7 @@ export default function NewChatPage() {
|
||||||
toolCallIndices: new Map(),
|
toolCallIndices: new Map(),
|
||||||
};
|
};
|
||||||
const { contentParts, toolCallIndices } = contentPartsState;
|
const { contentParts, toolCallIndices } = contentPartsState;
|
||||||
|
let tokenUsageData: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
const existingMsg = messages.find((m) => m.id === assistantMsgId);
|
const existingMsg = messages.find((m) => m.id === assistantMsgId);
|
||||||
if (existingMsg && Array.isArray(existingMsg.content)) {
|
if (existingMsg && Array.isArray(existingMsg.content)) {
|
||||||
|
|
@ -1149,6 +1183,11 @@ export default function NewChatPage() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "data-token-usage":
|
||||||
|
tokenUsageData = parsed.data;
|
||||||
|
tokenUsageStore.set(assistantMsgId, parsed.data as TokenUsageData);
|
||||||
|
break;
|
||||||
|
|
||||||
case "error":
|
case "error":
|
||||||
throw new Error(parsed.errorText || "Server error");
|
throw new Error(parsed.errorText || "Server error");
|
||||||
}
|
}
|
||||||
|
|
@ -1162,10 +1201,12 @@ export default function NewChatPage() {
|
||||||
const savedMessage = await appendMessage(resumeThreadId, {
|
const savedMessage = await appendMessage(resumeThreadId, {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: finalContent,
|
content: finalContent,
|
||||||
|
token_usage: tokenUsageData ?? undefined,
|
||||||
});
|
});
|
||||||
const newMsgId = `msg-${savedMessage.id}`;
|
const newMsgId = `msg-${savedMessage.id}`;
|
||||||
|
tokenUsageStore.rename(assistantMsgId, newMsgId);
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
|
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m)),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to persist resumed assistant message:", err);
|
console.error("Failed to persist resumed assistant message:", err);
|
||||||
|
|
@ -1183,7 +1224,7 @@ export default function NewChatPage() {
|
||||||
abortControllerRef.current = null;
|
abortControllerRef.current = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[pendingInterrupt, messages, searchSpaceId]
|
[pendingInterrupt, messages, searchSpaceId, tokenUsageStore]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1319,6 +1360,7 @@ export default function NewChatPage() {
|
||||||
};
|
};
|
||||||
const { contentParts, toolCallIndices } = contentPartsState;
|
const { contentParts, toolCallIndices } = contentPartsState;
|
||||||
const batcher = new FrameBatchedUpdater();
|
const batcher = new FrameBatchedUpdater();
|
||||||
|
let tokenUsageData: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
// Add placeholder messages to UI
|
// Add placeholder messages to UI
|
||||||
// Always add back the user message (with new query for edit, or original content for reload)
|
// Always add back the user message (with new query for edit, or original content for reload)
|
||||||
|
|
@ -1428,6 +1470,11 @@ export default function NewChatPage() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "data-token-usage":
|
||||||
|
tokenUsageData = parsed.data;
|
||||||
|
tokenUsageStore.set(assistantMsgId, parsed.data as TokenUsageData);
|
||||||
|
break;
|
||||||
|
|
||||||
case "error":
|
case "error":
|
||||||
throw new Error(parsed.errorText || "Server error");
|
throw new Error(parsed.errorText || "Server error");
|
||||||
}
|
}
|
||||||
|
|
@ -1459,12 +1506,13 @@ export default function NewChatPage() {
|
||||||
const savedMessage = await appendMessage(threadId, {
|
const savedMessage = await appendMessage(threadId, {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: finalContent,
|
content: finalContent,
|
||||||
|
token_usage: tokenUsageData ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update assistant message ID to database ID
|
|
||||||
const newMsgId = `msg-${savedMessage.id}`;
|
const newMsgId = `msg-${savedMessage.id}`;
|
||||||
|
tokenUsageStore.rename(assistantMsgId, newMsgId);
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
|
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m)),
|
||||||
);
|
);
|
||||||
|
|
||||||
trackChatResponseReceived(searchSpaceId, threadId);
|
trackChatResponseReceived(searchSpaceId, threadId);
|
||||||
|
|
@ -1499,7 +1547,7 @@ export default function NewChatPage() {
|
||||||
abortControllerRef.current = null;
|
abortControllerRef.current = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[threadId, searchSpaceId, messages, disabledTools]
|
[threadId, searchSpaceId, messages, disabledTools, tokenUsageStore]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle editing a message - truncates history and regenerates with new query
|
// Handle editing a message - truncates history and regenerates with new query
|
||||||
|
|
@ -1568,6 +1616,7 @@ export default function NewChatPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<TokenUsageProvider store={tokenUsageStore}>
|
||||||
<AssistantRuntimeProvider runtime={runtime}>
|
<AssistantRuntimeProvider runtime={runtime}>
|
||||||
<ThinkingStepsDataUI />
|
<ThinkingStepsDataUI />
|
||||||
<div key={searchSpaceId} className="flex h-full overflow-hidden">
|
<div key={searchSpaceId} className="flex h-full overflow-hidden">
|
||||||
|
|
@ -1579,5 +1628,6 @@ export default function NewChatPage() {
|
||||||
<MobileHitlEditPanel />
|
<MobileHitlEditPanel />
|
||||||
</div>
|
</div>
|
||||||
</AssistantRuntimeProvider>
|
</AssistantRuntimeProvider>
|
||||||
|
</TokenUsageProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,9 @@ export function ApiKeyContent() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 min-w-0 overflow-hidden">
|
<div className="space-y-6 min-w-0 overflow-hidden">
|
||||||
<Alert className="border-border/60 bg-muted/30 text-muted-foreground">
|
<Alert className="bg-muted/50 py-3 md:py-4">
|
||||||
<Info className="h-4 w-4 text-muted-foreground" />
|
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||||
<AlertTitle className="text-muted-foreground">{t("api_key_warning_title")}</AlertTitle>
|
<AlertDescription className="text-xs md:text-sm">
|
||||||
<AlertDescription className="text-muted-foreground/60">
|
|
||||||
{t("api_key_warning_description")}
|
{t("api_key_warning_description")}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
|
||||||
|
|
@ -113,10 +113,10 @@ export function ProfileContent() {
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={isPending || !hasChanges}
|
disabled={isPending || !hasChanges}
|
||||||
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
|
className="relative gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
|
||||||
>
|
>
|
||||||
{isPending && <Spinner size="sm" className="mr-2" />}
|
<span className={isPending ? "opacity-0" : ""}>{t("profile_save")}</span>
|
||||||
{t("profile_save")}
|
{isPending && <Spinner size="sm" className="absolute" />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import {
|
import {
|
||||||
|
ActionBarMorePrimitive,
|
||||||
ActionBarPrimitive,
|
ActionBarPrimitive,
|
||||||
AuiIf,
|
AuiIf,
|
||||||
ErrorPrimitive,
|
ErrorPrimitive,
|
||||||
|
|
@ -15,12 +16,17 @@ import {
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Globe,
|
Globe,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
MoreHorizontalIcon,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
|
import {
|
||||||
|
globalNewLLMConfigsAtom,
|
||||||
|
newLLMConfigsAtom,
|
||||||
|
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
import {
|
import {
|
||||||
CitationMetadataProvider,
|
CitationMetadataProvider,
|
||||||
|
|
@ -39,9 +45,13 @@ import {
|
||||||
DrawerHeader,
|
DrawerHeader,
|
||||||
DrawerTitle,
|
DrawerTitle,
|
||||||
} from "@/components/ui/drawer";
|
} from "@/components/ui/drawer";
|
||||||
|
import { DropdownMenuLabel } from "@/components/ui/dropdown-menu";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { useComments } from "@/hooks/use-comments";
|
import { useComments } from "@/hooks/use-comments";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import { useElectronAPI } from "@/hooks/use-platform";
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
|
import { useTokenUsage } from "@/components/assistant-ui/token-usage-context";
|
||||||
|
import { getProviderIcon } from "@/lib/provider-icons";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { openSafeNavigationHref, resolveSafeNavigationHref } from "@/components/tool-ui/shared/media";
|
import { openSafeNavigationHref, resolveSafeNavigationHref } from "@/components/tool-ui/shared/media";
|
||||||
|
|
||||||
|
|
@ -367,6 +377,94 @@ export const MessageError: FC = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function formatMessageDate(date: Date): string {
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const MessageInfoDropdown: FC = () => {
|
||||||
|
const messageId = useAuiState(({ message }) => message?.id);
|
||||||
|
const createdAt = useAuiState(({ message }) => message?.createdAt);
|
||||||
|
const usage = useTokenUsage(messageId);
|
||||||
|
|
||||||
|
const { data: localConfigs } = useAtomValue(newLLMConfigsAtom);
|
||||||
|
const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
|
||||||
|
|
||||||
|
const configByModel = useMemo(() => {
|
||||||
|
const map = new Map<string, { name: string; provider: string }>();
|
||||||
|
for (const c of [...(globalConfigs ?? []), ...(localConfigs ?? [])]) {
|
||||||
|
map.set(c.model_name, { name: c.name, provider: c.provider });
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [localConfigs, globalConfigs]);
|
||||||
|
|
||||||
|
const resolveModel = (modelKey: string) => {
|
||||||
|
const parts = modelKey.split("/");
|
||||||
|
const bare = parts[parts.length - 1] ?? modelKey;
|
||||||
|
const config = configByModel.get(modelKey) ?? configByModel.get(bare);
|
||||||
|
return config
|
||||||
|
? { name: config.name, icon: getProviderIcon(config.provider, { className: "size-3.5" }) }
|
||||||
|
: { name: modelKey, icon: null };
|
||||||
|
};
|
||||||
|
|
||||||
|
const modelBreakdown = usage ? (usage.usage ?? usage.model_breakdown) : undefined;
|
||||||
|
const models = modelBreakdown ? Object.entries(modelBreakdown) : [];
|
||||||
|
const hasUsage = usage && usage.total_tokens > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionBarMorePrimitive.Root>
|
||||||
|
<ActionBarMorePrimitive.Trigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="aui-button-icon size-6 p-1">
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</Button>
|
||||||
|
</ActionBarMorePrimitive.Trigger>
|
||||||
|
<ActionBarMorePrimitive.Content
|
||||||
|
align="start"
|
||||||
|
className="bg-muted text-popover-foreground z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[180px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border dark:border-neutral-700 p-1 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
||||||
|
>
|
||||||
|
{createdAt && (
|
||||||
|
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal select-none">
|
||||||
|
{formatMessageDate(createdAt)}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
)}
|
||||||
|
{hasUsage && (
|
||||||
|
<>
|
||||||
|
<ActionBarMorePrimitive.Separator className="bg-border mx-2 my-1 h-px" />
|
||||||
|
{models.length > 0 ? (
|
||||||
|
models.map(([model, counts]) => {
|
||||||
|
const { name, icon } = resolveModel(model);
|
||||||
|
return (
|
||||||
|
<ActionBarMorePrimitive.Item key={model} className="focus:bg-neutral-200 dark:focus:bg-neutral-700 relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none" onSelect={(e) => e.preventDefault()}>
|
||||||
|
<span className="flex items-center gap-1.5 text-xs font-medium">
|
||||||
|
{icon}
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{counts.total_tokens.toLocaleString()} tokens
|
||||||
|
</span>
|
||||||
|
</ActionBarMorePrimitive.Item>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<ActionBarMorePrimitive.Item className="focus:bg-neutral-200 dark:focus:bg-neutral-700 relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none" onSelect={(e) => e.preventDefault()}>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{usage.total_tokens.toLocaleString()} tokens
|
||||||
|
</span>
|
||||||
|
</ActionBarMorePrimitive.Item>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ActionBarMorePrimitive.Content>
|
||||||
|
</ActionBarMorePrimitive.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const AssistantMessageInner: FC = () => {
|
const AssistantMessageInner: FC = () => {
|
||||||
const isMobile = !useMediaQuery("(min-width: 768px)");
|
const isMobile = !useMediaQuery("(min-width: 768px)");
|
||||||
|
|
||||||
|
|
@ -429,7 +527,7 @@ const AssistantMessageInner: FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex">
|
<div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex items-center gap-2">
|
||||||
<AssistantActionBar />
|
<AssistantActionBar />
|
||||||
</div>
|
</div>
|
||||||
</CitationMetadataProvider>
|
</CitationMetadataProvider>
|
||||||
|
|
@ -626,6 +724,7 @@ const AssistantActionBar: FC = () => {
|
||||||
<ClipboardPaste />
|
<ClipboardPaste />
|
||||||
</TooltipIconButton>
|
</TooltipIconButton>
|
||||||
)}
|
)}
|
||||||
|
<MessageInfoDropdown />
|
||||||
</ActionBarPrimitive.Root>
|
</ActionBarPrimitive.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useCallback, useSyncExternalStore, type FC, type ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface TokenUsageData {
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
usage?: Record<string, { prompt_tokens: number; completion_tokens: number; total_tokens: number }>;
|
||||||
|
model_breakdown?: Record<string, { prompt_tokens: number; completion_tokens: number; total_tokens: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
class TokenUsageStore {
|
||||||
|
private data = new Map<string, TokenUsageData>();
|
||||||
|
private listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
get(messageId: string): TokenUsageData | undefined {
|
||||||
|
return this.data.get(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
set(messageId: string, usage: TokenUsageData): void {
|
||||||
|
this.data.set(messageId, usage);
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
rename(oldId: string, newId: string): void {
|
||||||
|
const usage = this.data.get(oldId);
|
||||||
|
if (usage) {
|
||||||
|
this.data.delete(oldId);
|
||||||
|
this.data.set(newId, usage);
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.data.clear();
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe = (listener: Listener): (() => void) => {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
|
||||||
|
private notify(): void {
|
||||||
|
for (const l of this.listeners) l();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TokenUsageContext = createContext<TokenUsageStore | null>(null);
|
||||||
|
|
||||||
|
export const TokenUsageProvider: FC<{ store: TokenUsageStore; children: ReactNode }> = ({ store, children }) => (
|
||||||
|
<TokenUsageContext.Provider value={store}>{children}</TokenUsageContext.Provider>
|
||||||
|
);
|
||||||
|
|
||||||
|
export function useTokenUsageStore(): TokenUsageStore {
|
||||||
|
const store = useContext(TokenUsageContext);
|
||||||
|
if (!store) throw new Error("useTokenUsageStore must be used within TokenUsageProvider");
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTokenUsage(messageId: string | undefined): TokenUsageData | undefined {
|
||||||
|
const store = useContext(TokenUsageContext);
|
||||||
|
const getSnapshot = useCallback(
|
||||||
|
() => (store && messageId ? store.get(messageId) : undefined),
|
||||||
|
[store, messageId],
|
||||||
|
);
|
||||||
|
const subscribe = useCallback(
|
||||||
|
(onStoreChange: () => void) => (store ? store.subscribe(onStoreChange) : () => {}),
|
||||||
|
[store],
|
||||||
|
);
|
||||||
|
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTokenUsageStore(): TokenUsageStore {
|
||||||
|
return new TokenUsageStore();
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import { IconBinaryTree, IconBinaryTreeFilled } from "@tabler/icons-react";
|
import { IconBinaryTree, IconBinaryTreeFilled } from "@tabler/icons-react";
|
||||||
import { FolderPlus, ListFilter, Search, Upload, X } from "lucide-react";
|
import { FolderPlus, ListFilter, Search, Upload, X } from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||||
|
|
@ -10,6 +9,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -74,14 +74,14 @@ export function DocumentsFilters({
|
||||||
return (
|
return (
|
||||||
<div className="flex select-none">
|
<div className="flex select-none">
|
||||||
<div className="flex items-center gap-2 w-full">
|
<div className="flex items-center gap-2 w-full">
|
||||||
{/* New Folder + Filter Toggle Group */}
|
{/* New Folder + AI Sort + Filter Toggle Group */}
|
||||||
<ToggleGroup type="multiple" variant="outline" value={[]} className="overflow-visible">
|
<ToggleGroup type="multiple" variant="outline" value={[]} className="overflow-visible">
|
||||||
{onCreateFolder && (
|
{onCreateFolder && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<ToggleGroupItem
|
<ToggleGroupItem
|
||||||
value="folder"
|
value="folder"
|
||||||
className="h-9 w-9 shrink-0 border-sidebar-border text-sidebar-foreground/60 hover:text-sidebar-foreground hover:border-sidebar-border bg-sidebar"
|
className="h-9 w-9 shrink-0 border-sidebar-border text-muted-foreground hover:text-foreground hover:border-sidebar-border bg-sidebar"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onCreateFolder();
|
onCreateFolder();
|
||||||
|
|
@ -94,13 +94,52 @@ export function DocumentsFilters({
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{onToggleAiSort && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<ToggleGroupItem
|
||||||
|
value="ai-sort"
|
||||||
|
disabled={aiSortBusy}
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-9 shrink-0 border-sidebar-border bg-sidebar",
|
||||||
|
"disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
aiSortEnabled
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:border-sidebar-border"
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onToggleAiSort();
|
||||||
|
}}
|
||||||
|
aria-label={aiSortEnabled ? "Disable AI sort" : "Enable AI sort"}
|
||||||
|
aria-pressed={aiSortEnabled}
|
||||||
|
>
|
||||||
|
{aiSortBusy ? (
|
||||||
|
<Spinner size="xs" />
|
||||||
|
) : aiSortEnabled ? (
|
||||||
|
<IconBinaryTreeFilled size={16} />
|
||||||
|
) : (
|
||||||
|
<IconBinaryTree size={16} />
|
||||||
|
)}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{aiSortBusy
|
||||||
|
? "AI sort in progress..."
|
||||||
|
: aiSortEnabled
|
||||||
|
? "AI sort active — click to disable"
|
||||||
|
: "Enable AI sort"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
<Popover>
|
<Popover>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<ToggleGroupItem
|
<ToggleGroupItem
|
||||||
value="filter"
|
value="filter"
|
||||||
className="relative h-9 w-9 shrink-0 border-sidebar-border text-sidebar-foreground/60 hover:text-sidebar-foreground hover:border-sidebar-border bg-sidebar overflow-visible"
|
className="relative h-9 w-9 shrink-0 border-sidebar-border text-muted-foreground hover:text-foreground hover:border-sidebar-border bg-sidebar overflow-visible"
|
||||||
>
|
>
|
||||||
<ListFilter size={14} />
|
<ListFilter size={14} />
|
||||||
{activeTypes.length > 0 && (
|
{activeTypes.length > 0 && (
|
||||||
|
|
@ -182,70 +221,6 @@ export function DocumentsFilters({
|
||||||
</Popover>
|
</Popover>
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
|
|
||||||
{/* AI Sort Toggle */}
|
|
||||||
{onToggleAiSort && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={aiSortBusy}
|
|
||||||
onClick={onToggleAiSort}
|
|
||||||
className={cn(
|
|
||||||
"relative h-9 w-9 shrink-0 rounded-md border inline-flex items-center justify-center transition-all duration-300 ease-out",
|
|
||||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none",
|
|
||||||
"disabled:pointer-events-none disabled:opacity-50",
|
|
||||||
aiSortEnabled
|
|
||||||
? "border-violet-400/60 bg-violet-50 text-violet-600 shadow-[0_0_8px_-1px_rgba(139,92,246,0.3)] hover:bg-violet-100 dark:border-violet-500/40 dark:bg-violet-500/15 dark:text-violet-400 dark:shadow-[0_0_8px_-1px_rgba(139,92,246,0.2)] dark:hover:bg-violet-500/25"
|
|
||||||
: "border-sidebar-border bg-sidebar text-sidebar-foreground/60 hover:text-sidebar-foreground hover:border-sidebar-border hover:bg-accent"
|
|
||||||
)}
|
|
||||||
aria-label={aiSortEnabled ? "Disable AI sort" : "Enable AI sort"}
|
|
||||||
aria-pressed={aiSortEnabled}
|
|
||||||
>
|
|
||||||
<AnimatePresence mode="wait" initial={false}>
|
|
||||||
{aiSortBusy ? (
|
|
||||||
<motion.div
|
|
||||||
key="busy"
|
|
||||||
initial={{ opacity: 0, scale: 0.6, rotate: -90 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, rotate: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.6, rotate: 90 }}
|
|
||||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
|
||||||
>
|
|
||||||
<IconBinaryTree size={16} className="animate-pulse" />
|
|
||||||
</motion.div>
|
|
||||||
) : aiSortEnabled ? (
|
|
||||||
<motion.div
|
|
||||||
key="on"
|
|
||||||
initial={{ opacity: 0, scale: 0.6, rotate: -90 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, rotate: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.6, rotate: 90 }}
|
|
||||||
transition={{ duration: 0.25, ease: "easeInOut" }}
|
|
||||||
>
|
|
||||||
<IconBinaryTreeFilled size={16} />
|
|
||||||
</motion.div>
|
|
||||||
) : (
|
|
||||||
<motion.div
|
|
||||||
key="off"
|
|
||||||
initial={{ opacity: 0, scale: 0.6, rotate: 90 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, rotate: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.6, rotate: -90 }}
|
|
||||||
transition={{ duration: 0.25, ease: "easeInOut" }}
|
|
||||||
>
|
|
||||||
<IconBinaryTree size={16} />
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{aiSortBusy
|
|
||||||
? "AI sort in progress..."
|
|
||||||
: aiSortEnabled
|
|
||||||
? "AI sort active — click to disable"
|
|
||||||
: "Enable AI sort"}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Search Input */}
|
{/* Search Input */}
|
||||||
<div className="relative flex-1 min-w-0">
|
<div className="relative flex-1 min-w-0">
|
||||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground">
|
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground">
|
||||||
|
|
|
||||||
1
surfsense_web/components/icons/providers/github.svg
Normal file
1
surfsense_web/components/icons/providers/github.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Github</title><path d="M12 0c6.63 0 12 5.276 12 11.79-.001 5.067-3.29 9.567-8.175 11.187-.6.118-.825-.25-.825-.56 0-.398.015-1.665.015-3.242 0-1.105-.375-1.813-.81-2.181 2.67-.295 5.475-1.297 5.475-5.822 0-1.297-.465-2.344-1.23-3.169.12-.295.54-1.503-.12-3.125 0 0-1.005-.324-3.3 1.209a11.32 11.32 0 00-3-.398c-1.02 0-2.04.133-3 .398-2.295-1.518-3.3-1.209-3.3-1.209-.66 1.622-.24 2.83-.12 3.125-.765.825-1.23 1.887-1.23 3.169 0 4.51 2.79 5.527 5.46 5.822-.345.294-.66.81-.765 1.577-.69.31-2.415.81-3.495-.973-.225-.354-.9-1.223-1.845-1.209-1.005.015-.405.56.015.781.51.28 1.095 1.327 1.23 1.666.24.663 1.02 1.93 4.035 1.385 0 .988.015 1.916.015 2.196 0 .31-.225.664-.825.56C3.303 21.374-.003 16.867 0 11.791 0 5.276 5.37 0 12 0z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 907 B |
|
|
@ -10,6 +10,7 @@ export { default as DeepInfraIcon } from "./deepinfra.svg";
|
||||||
export { default as DeepSeekIcon } from "./deepseek.svg";
|
export { default as DeepSeekIcon } from "./deepseek.svg";
|
||||||
export { default as FireworksAiIcon } from "./fireworksai.svg";
|
export { default as FireworksAiIcon } from "./fireworksai.svg";
|
||||||
export { default as GeminiIcon } from "./gemini.svg";
|
export { default as GeminiIcon } from "./gemini.svg";
|
||||||
|
export { default as GitHubModelsIcon } from "./github.svg";
|
||||||
export { default as GroqIcon } from "./groq.svg";
|
export { default as GroqIcon } from "./groq.svg";
|
||||||
export { default as HuggingFaceIcon } from "./huggingface.svg";
|
export { default as HuggingFaceIcon } from "./huggingface.svg";
|
||||||
export { default as MiniMaxIcon } from "./minimax.svg";
|
export { default as MiniMaxIcon } from "./minimax.svg";
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,12 @@ import {
|
||||||
Bot,
|
Bot,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronUp,
|
||||||
Edit3,
|
Edit3,
|
||||||
Eye,
|
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
|
ScanEye,
|
||||||
Layers,
|
Layers,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
|
|
@ -69,6 +72,7 @@ const PROVIDER_NAMES: Record<string, string> = {
|
||||||
DEEPSEEK: "DeepSeek",
|
DEEPSEEK: "DeepSeek",
|
||||||
MISTRAL: "Mistral",
|
MISTRAL: "Mistral",
|
||||||
COHERE: "Cohere",
|
COHERE: "Cohere",
|
||||||
|
GITHUB_MODELS: "GitHub Models",
|
||||||
GROQ: "Groq",
|
GROQ: "Groq",
|
||||||
OLLAMA: "Ollama",
|
OLLAMA: "Ollama",
|
||||||
TOGETHER_AI: "Together AI",
|
TOGETHER_AI: "Together AI",
|
||||||
|
|
@ -274,17 +278,40 @@ export function ModelSelector({
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [selectedProvider, setSelectedProvider] = useState<string>("all");
|
const [selectedProvider, setSelectedProvider] = useState<string>("all");
|
||||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||||
const [showScrollIndicator, setShowScrollIndicator] = useState(true);
|
const [modelScrollPos, setModelScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||||
|
const [sidebarScrollPos, setSidebarScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||||
const providerSidebarRef = useRef<HTMLDivElement>(null);
|
const providerSidebarRef = useRef<HTMLDivElement>(null);
|
||||||
const modelListRef = useRef<HTMLDivElement>(null);
|
const modelListRef = useRef<HTMLDivElement>(null);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const handleModelListScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
const atTop = el.scrollTop <= 2;
|
||||||
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||||
|
setModelScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSidebarScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
if (isMobile) {
|
||||||
|
const atStart = el.scrollLeft <= 2;
|
||||||
|
const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
|
||||||
|
setSidebarScrollPos(atStart ? "top" : atEnd ? "bottom" : "middle");
|
||||||
|
} else {
|
||||||
|
const atTop = el.scrollTop <= 2;
|
||||||
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||||
|
setSidebarScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||||
|
}
|
||||||
|
}, [isMobile]);
|
||||||
|
|
||||||
// Reset search + provider when tab changes
|
// Reset search + provider when tab changes
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: activeTab is intentionally used as a trigger
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedProvider("all");
|
setSelectedProvider("all");
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
setFocusedIndex(-1);
|
setFocusedIndex(-1);
|
||||||
|
setModelScrollPos("top");
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
// Reset on open
|
// Reset on open
|
||||||
|
|
@ -295,8 +322,9 @@ export function ModelSelector({
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
// Cmd/Ctrl+M shortcut
|
// Cmd/Ctrl+M shortcut (desktop only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isMobile) return;
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "m") {
|
if ((e.metaKey || e.ctrlKey) && e.key === "m") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -305,9 +333,10 @@ export function ModelSelector({
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", handler);
|
document.addEventListener("keydown", handler);
|
||||||
return () => document.removeEventListener("keydown", handler);
|
return () => document.removeEventListener("keydown", handler);
|
||||||
}, []);
|
}, [isMobile]);
|
||||||
|
|
||||||
// Focus search input on open
|
// Focus search input on open
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: activeTab is intentionally used as a trigger to re-focus on tab switch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && !isMobile) {
|
if (open && !isMobile) {
|
||||||
requestAnimationFrame(() => searchInputRef.current?.focus());
|
requestAnimationFrame(() => searchInputRef.current?.focus());
|
||||||
|
|
@ -677,6 +706,7 @@ export function ModelSelector({
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Keyboard navigation ───
|
// ─── Keyboard navigation ───
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: searchQuery and selectedProvider are intentional triggers to reset focus
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFocusedIndex(-1);
|
setFocusedIndex(-1);
|
||||||
}, [searchQuery, selectedProvider]);
|
}, [searchQuery, selectedProvider]);
|
||||||
|
|
@ -767,24 +797,35 @@ export function ModelSelector({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 border-border/50 relative flex flex-col",
|
"shrink-0 border-border/50 flex",
|
||||||
!isMobile && "w-10 border-r",
|
isMobile ? "flex-row items-center border-b border-border/40" : "flex-col w-10 border-r",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{!isMobile && sidebarScrollPos !== "top" && (
|
||||||
|
<div className="flex items-center justify-center py-0.5 pointer-events-none">
|
||||||
|
<ChevronUp className="size-3 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isMobile && sidebarScrollPos !== "top" && (
|
||||||
|
<div className="flex items-center justify-center px-0.5 shrink-0 pointer-events-none">
|
||||||
|
<ChevronLeft className="size-3 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
ref={providerSidebarRef}
|
ref={providerSidebarRef}
|
||||||
onScroll={(e) => {
|
onScroll={handleSidebarScroll}
|
||||||
const t = e.currentTarget;
|
|
||||||
setShowScrollIndicator(
|
|
||||||
t.scrollHeight - t.scrollTop >
|
|
||||||
t.clientHeight + 10,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
isMobile
|
isMobile
|
||||||
? "flex flex-row gap-0.5 px-2 py-1.5 overflow-x-auto border-b border-border/40"
|
? "flex flex-row gap-0.5 px-1 py-1.5 overflow-x-auto [&::-webkit-scrollbar]:h-0 [&::-webkit-scrollbar-track]:bg-transparent"
|
||||||
: "flex flex-col gap-0.5 p-1 overflow-y-auto flex-1 [&::-webkit-scrollbar]:w-0 [&::-webkit-scrollbar-track]:bg-transparent",
|
: "flex flex-col gap-0.5 p-1 overflow-y-auto flex-1 [&::-webkit-scrollbar]:w-0 [&::-webkit-scrollbar-track]:bg-transparent",
|
||||||
)}
|
)}
|
||||||
|
style={isMobile ? {
|
||||||
|
maskImage: `linear-gradient(to right, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
WebkitMaskImage: `linear-gradient(to right, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
} : {
|
||||||
|
maskImage: `linear-gradient(to bottom, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 32px, black calc(100% - 32px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
WebkitMaskImage: `linear-gradient(to bottom, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 32px, black calc(100% - 32px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{activeProviders.map((provider, idx) => {
|
{activeProviders.map((provider, idx) => {
|
||||||
const isAll = provider === "all";
|
const isAll = provider === "all";
|
||||||
|
|
@ -849,18 +890,23 @@ export function ModelSelector({
|
||||||
)}
|
)}
|
||||||
{isConfigured
|
{isConfigured
|
||||||
? ` (${count})`
|
? ` (${count})`
|
||||||
: " — not configured"}
|
: " (not configured)"}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{!isMobile && showScrollIndicator && (
|
{!isMobile && sidebarScrollPos !== "bottom" && (
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-6 bg-gradient-to-t from-background to-transparent pointer-events-none flex items-end justify-center pb-0.5">
|
<div className="flex items-center justify-center py-0.5 pointer-events-none">
|
||||||
<ChevronDown className="size-3 text-muted-foreground" />
|
<ChevronDown className="size-3 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isMobile && sidebarScrollPos !== "bottom" && (
|
||||||
|
<div className="flex items-center justify-center px-0.5 shrink-0 pointer-events-none">
|
||||||
|
<ChevronRight className="size-3 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -889,19 +935,26 @@ export function ModelSelector({
|
||||||
key={`${activeTab}-${item.isGlobal ? "g" : "u"}-${config.id}`}
|
key={`${activeTab}-${item.isGlobal ? "g" : "u"}-${config.id}`}
|
||||||
data-model-index={index}
|
data-model-index={index}
|
||||||
role="option"
|
role="option"
|
||||||
|
tabIndex={isMobile ? -1 : 0}
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
onClick={() => handleSelectItem(item)}
|
onClick={() => handleSelectItem(item)}
|
||||||
|
onKeyDown={isMobile ? undefined : (e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelectItem(item);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onMouseEnter={() => setFocusedIndex(index)}
|
onMouseEnter={() => setFocusedIndex(index)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex items-start gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer",
|
"group flex items-center gap-2.5 px-3 py-2 rounded-xl cursor-pointer",
|
||||||
"transition-all duration-150 mx-1",
|
"transition-all duration-150 mx-2",
|
||||||
"hover:bg-accent/40 active:scale-[0.99]",
|
"hover:bg-accent/40",
|
||||||
isSelected && "bg-primary/6 dark:bg-primary/8",
|
isSelected && "bg-primary/6 dark:bg-primary/8",
|
||||||
isFocused && "bg-accent/50 ring-1 ring-primary/20",
|
isFocused && "bg-accent/50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Provider icon */}
|
{/* Provider icon */}
|
||||||
<div className="shrink-0 mt-0.5">
|
<div className="shrink-0">
|
||||||
{getProviderIcon(config.provider as string, {
|
{getProviderIcon(config.provider as string, {
|
||||||
isAutoMode,
|
isAutoMode,
|
||||||
className: "size-5",
|
className: "size-5",
|
||||||
|
|
@ -917,7 +970,7 @@ export function ModelSelector({
|
||||||
{isAutoMode && (
|
{isAutoMode && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="text-[9px] px-1 py-0 h-3.5 bg-violet-800 text-white dark:bg-violet-800 dark:text-white border-0"
|
className="text-[9px] px-1 py-0 h-3.5 bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 border-0"
|
||||||
>
|
>
|
||||||
Recommended
|
Recommended
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
@ -931,8 +984,8 @@ export function ModelSelector({
|
||||||
</span>
|
</span>
|
||||||
{!isAutoMode && hasCitations && (
|
{!isAutoMode && hasCitations && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
className="text-[9px] px-1 py-0 h-3.5 bg-primary/10 text-primary border-primary/20"
|
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
|
||||||
>
|
>
|
||||||
Citations
|
Citations
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
@ -981,7 +1034,7 @@ export function ModelSelector({
|
||||||
: "Add Vision Model";
|
: "Add Vision Model";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full">
|
<div className="flex flex-col w-full overflow-hidden">
|
||||||
{/* Tab header */}
|
{/* Tab header */}
|
||||||
<div className="border-b border-border/80 dark:border-neutral-800">
|
<div className="border-b border-border/80 dark:border-neutral-800">
|
||||||
<div className="w-full grid grid-cols-3 h-11">
|
<div className="w-full grid grid-cols-3 h-11">
|
||||||
|
|
@ -999,7 +1052,7 @@ export function ModelSelector({
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "vision" as const,
|
value: "vision" as const,
|
||||||
icon: Eye,
|
icon: ScanEye,
|
||||||
label: "Vision",
|
label: "Vision",
|
||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|
@ -1028,7 +1081,7 @@ export function ModelSelector({
|
||||||
"flex",
|
"flex",
|
||||||
isMobile
|
isMobile
|
||||||
? "flex-col h-[60vh]"
|
? "flex-col h-[60vh]"
|
||||||
: "flex-row h-[420px]",
|
: "flex-row h-[380px]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Provider sidebar */}
|
{/* Provider sidebar */}
|
||||||
|
|
@ -1037,33 +1090,30 @@ export function ModelSelector({
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="flex flex-col min-w-0 min-h-0 flex-1 overflow-hidden">
|
<div className="flex flex-col min-w-0 min-h-0 flex-1 overflow-hidden">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="relative px-3 py-2">
|
<div className="relative">
|
||||||
<Search className="absolute left-5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50 pointer-events-none" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/100 pointer-events-none" />
|
||||||
<input
|
<input
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
placeholder="Search models..."
|
placeholder="Search models"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setSearchQuery(e.target.value)
|
setSearchQuery(e.target.value)
|
||||||
}
|
}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={isMobile ? undefined : handleKeyDown}
|
||||||
autoFocus={!isMobile}
|
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={true}
|
aria-expanded={true}
|
||||||
aria-controls="model-selector-list"
|
aria-controls="model-selector-list"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full pl-8 pr-3 py-1.5 text-xs rounded-lg",
|
"w-full pl-8 pr-3 py-2.5 text-sm bg-transparent",
|
||||||
"bg-secondary/30 border border-border/40",
|
"focus:outline-none",
|
||||||
"focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/40",
|
"placeholder:text-muted-foreground",
|
||||||
"placeholder:text-muted-foreground/50",
|
|
||||||
"transition-[box-shadow,border-color] duration-200",
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Provider header when filtered */}
|
{/* Provider header when filtered */}
|
||||||
{selectedProvider !== "all" && (
|
{selectedProvider !== "all" && (
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border/40">
|
<div className="flex items-center gap-2 px-3 py-1.5">
|
||||||
{getProviderIcon(selectedProvider, {
|
{getProviderIcon(selectedProvider, {
|
||||||
className: "size-4",
|
className: "size-4",
|
||||||
})}
|
})}
|
||||||
|
|
@ -1085,10 +1135,15 @@ export function ModelSelector({
|
||||||
id="model-selector-list"
|
id="model-selector-list"
|
||||||
ref={modelListRef}
|
ref={modelListRef}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
className="overflow-y-auto flex-1 py-1"
|
className="overflow-y-auto flex-1 py-1 space-y-1 flex flex-col"
|
||||||
|
onScroll={handleModelListScroll}
|
||||||
|
style={{
|
||||||
|
maskImage: `linear-gradient(to bottom, ${modelScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${modelScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
WebkitMaskImage: `linear-gradient(to bottom, ${modelScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${modelScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{currentDisplayItems.length === 0 ? (
|
{currentDisplayItems.length === 0 ? (
|
||||||
<div className="py-8 flex flex-col items-center gap-3 px-4">
|
<div className="flex-1 flex flex-col items-center justify-center gap-3 px-4">
|
||||||
{selectedProvider !== "all" &&
|
{selectedProvider !== "all" &&
|
||||||
!configuredProviderSet.has(
|
!configuredProviderSet.has(
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
|
|
@ -1116,22 +1171,21 @@ export function ModelSelector({
|
||||||
</p>
|
</p>
|
||||||
{addHandler && (
|
{addHandler && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="mt-1 gap-2"
|
className="mt-1"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
addHandler(selectedProvider !== "all" ? selectedProvider : undefined);
|
addHandler(selectedProvider !== "all" ? selectedProvider : undefined);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="size-3.5" />
|
|
||||||
{addLabel}
|
{addLabel}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : searchQuery ? (
|
||||||
<>
|
<>
|
||||||
<Search className="size-8 text-muted-foreground/40" />
|
<Search className="size-8 text-muted-foreground" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No models found
|
No models found
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -1140,13 +1194,22 @@ export function ModelSelector({
|
||||||
term
|
term
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
No models configured
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground/60 text-center">
|
||||||
|
Configure models in your search space settings
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{globalItems.length > 0 && (
|
{globalItems.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-wider">
|
<div className="flex items-center gap-2 px-3 py-1.5 text-[12px] font-semibold text-muted-foreground tracking-wider">
|
||||||
Global Models
|
Global Models
|
||||||
</div>
|
</div>
|
||||||
{globalItems.map((item, i) =>
|
{globalItems.map((item, i) =>
|
||||||
|
|
@ -1163,7 +1226,7 @@ export function ModelSelector({
|
||||||
)}
|
)}
|
||||||
{userItems.length > 0 && (
|
{userItems.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-wider">
|
<div className="flex items-center gap-2 px-3 py-1.5 text-[12px] font-semibold text-muted-foreground tracking-wider">
|
||||||
Your Configurations
|
Your Configurations
|
||||||
</div>
|
</div>
|
||||||
{userItems.map((item, i) =>
|
{userItems.map((item, i) =>
|
||||||
|
|
@ -1180,7 +1243,7 @@ export function ModelSelector({
|
||||||
|
|
||||||
{/* Add model button */}
|
{/* Add model button */}
|
||||||
{addHandler && (
|
{addHandler && (
|
||||||
<div className="p-2 border-t border-border/40 bg-muted/20 dark:bg-neutral-900">
|
<div className="p-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -1271,7 +1334,7 @@ export function ModelSelector({
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Eye className="size-4 text-muted-foreground" />
|
<ScanEye className="size-4 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1301,7 +1364,7 @@ export function ModelSelector({
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
|
<PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="w-[340px] md:w-[440px] p-0 rounded-lg shadow-lg bg-white border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 select-none"
|
className="w-[300px] md:w-[380px] p-0 rounded-lg shadow-lg overflow-hidden bg-white border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 select-none"
|
||||||
align="start"
|
align="start"
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,22 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Check, Copy, Dot, ExternalLink, MessageSquare, Trash2 } from "lucide-react";
|
import { Copy, Dot, ExternalLink, MessageSquare, MoreHorizontal, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.types";
|
import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.types";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function getInitials(name: string): string {
|
function getInitials(name: string): string {
|
||||||
const parts = name.trim().split(/\s+/);
|
const parts = name.trim().split(/\s+/);
|
||||||
|
|
@ -35,15 +43,12 @@ export function PublicChatSnapshotRow({
|
||||||
isDeleting = false,
|
isDeleting = false,
|
||||||
memberMap,
|
memberMap,
|
||||||
}: PublicChatSnapshotRowProps) {
|
}: PublicChatSnapshotRowProps) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
|
|
||||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||||
|
|
||||||
const handleCopyClick = useCallback(() => {
|
const handleCopyClick = useCallback(() => {
|
||||||
onCopy(snapshot);
|
onCopy(snapshot);
|
||||||
setCopied(true);
|
toast.success("Link copied to clipboard");
|
||||||
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
|
|
||||||
copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000);
|
|
||||||
}, [onCopy, snapshot]);
|
}, [onCopy, snapshot]);
|
||||||
|
|
||||||
const formattedDate = new Date(snapshot.created_at).toLocaleDateString(undefined, {
|
const formattedDate = new Date(snapshot.created_at).toLocaleDateString(undefined, {
|
||||||
|
|
@ -58,96 +63,66 @@ export function PublicChatSnapshotRow({
|
||||||
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
|
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
|
||||||
<CardContent className="p-4 flex flex-col gap-3 h-full">
|
<CardContent className="p-4 flex flex-col gap-3 h-full">
|
||||||
{/* Header: Title + Actions */}
|
{/* Header: Title + Actions */}
|
||||||
<div className="relative">
|
<div className="relative flex items-center">
|
||||||
<div className="min-w-0 pr-16 sm:pr-0 sm:group-hover:pr-16">
|
|
||||||
<h4
|
<h4
|
||||||
className="text-sm font-semibold tracking-tight truncate"
|
className={cn(
|
||||||
|
"text-sm font-semibold tracking-tight truncate",
|
||||||
|
dropdownOpen ? "pr-8" : "sm:group-hover:pr-8"
|
||||||
|
)}
|
||||||
title={snapshot.thread_title}
|
title={snapshot.thread_title}
|
||||||
>
|
>
|
||||||
{snapshot.thread_title}
|
{snapshot.thread_title}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||||
<div className="flex items-center gap-0.5 shrink-0 sm:hidden sm:group-hover:flex absolute right-0 top-0">
|
<DropdownMenuTrigger asChild>
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip open={isDesktop ? undefined : false}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
asChild
|
className={cn(
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
"absolute right-0 h-6 w-6 shrink-0 hover:bg-transparent",
|
||||||
|
dropdownOpen
|
||||||
|
? "opacity-100"
|
||||||
|
: "sm:opacity-0 sm:group-hover:opacity-100"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<a href={snapshot.public_url} target="_blank" rel="noopener noreferrer">
|
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
</a>
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</DropdownMenuTrigger>
|
||||||
<TooltipContent>Open link</TooltipContent>
|
<DropdownMenuContent align="end" className="w-40">
|
||||||
</Tooltip>
|
<DropdownMenuItem onClick={handleCopyClick}>
|
||||||
</TooltipProvider>
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
Copy link
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<a href={snapshot.public_url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
Open link
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<TooltipProvider>
|
<DropdownMenuItem
|
||||||
<Tooltip open={isDesktop ? undefined : false}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => onDelete(snapshot)}
|
onClick={() => onDelete(snapshot)}
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
</Button>
|
Delete
|
||||||
</TooltipTrigger>
|
</DropdownMenuItem>
|
||||||
<TooltipContent>Delete</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Message count badge */}
|
{/* Message count badge */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
className="text-[10px] px-1.5 py-0.5 border-muted-foreground/20 text-muted-foreground"
|
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
|
||||||
>
|
>
|
||||||
<MessageSquare className="h-2.5 w-2.5 mr-1" />
|
<MessageSquare className="h-2.5 w-2.5 mr-1" />
|
||||||
{snapshot.message_count} messages
|
{snapshot.message_count} messages
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Public URL – selectable fallback for manual copy */}
|
|
||||||
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
|
|
||||||
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
|
|
||||||
<p
|
|
||||||
className="text-[10px] font-mono text-muted-foreground whitespace-nowrap select-all cursor-text"
|
|
||||||
title={snapshot.public_url}
|
|
||||||
>
|
|
||||||
{snapshot.public_url}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip open={isDesktop ? undefined : false}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={handleCopyClick}
|
|
||||||
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<Check className="h-3 w-3 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{copied ? "Copied!" : "Copy link"}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer: Date + Creator */}
|
{/* Footer: Date + Creator */}
|
||||||
<div className="flex items-center gap-2 pt-2 border-t border-border/40 mt-auto">
|
<div className="flex items-center gap-2 pt-2 border-t border-border/40 mt-auto">
|
||||||
<span className="text-[11px] text-muted-foreground/60">{formattedDate}</span>
|
<span className="text-[11px] text-muted-foreground/60">{formattedDate}</span>
|
||||||
|
|
|
||||||
|
|
@ -181,10 +181,10 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={!hasChanges || saving || !name.trim()}
|
disabled={!hasChanges || saving || !name.trim()}
|
||||||
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
|
className="relative gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
|
||||||
>
|
>
|
||||||
{saving ? <Spinner size="sm" /> : null}
|
<span className={saving ? "opacity-0" : ""}>{t("general_save")}</span>
|
||||||
{saving ? t("general_saving") : t("general_save")}
|
{saving && <Spinner size="sm" className="absolute" />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -395,6 +395,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
||||||
key={config.id}
|
key={config.id}
|
||||||
value={config.id.toString()}
|
value={config.id.toString()}
|
||||||
className="text-xs md:text-sm py-1.5 md:py-2"
|
className="text-xs md:text-sm py-1.5 md:py-2"
|
||||||
|
textValue={config.name}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1 md:gap-1.5 flex-wrap min-w-0">
|
<div className="flex items-center gap-1 md:gap-1.5 flex-wrap min-w-0">
|
||||||
<span className="truncate text-xs md:text-sm">
|
<span className="truncate text-xs md:text-sm">
|
||||||
|
|
@ -403,7 +404,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
||||||
{isAuto && (
|
{isAuto && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="text-[8px] md:text-[9px] shrink-0 bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300"
|
className="text-[8px] md:text-[9px] shrink-0 bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 [[data-slot=select-trigger]_&]:hidden"
|
||||||
>
|
>
|
||||||
Recommended
|
Recommended
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ export function useTypewriter(text: string, speed = 35, skipFor = "New Chat"): s
|
||||||
}
|
}
|
||||||
|
|
||||||
let i = 0;
|
let i = 0;
|
||||||
setDisplayed("");
|
|
||||||
intervalRef.current = setInterval(() => {
|
intervalRef.current = setInterval(() => {
|
||||||
i++;
|
i++;
|
||||||
setDisplayed(text.slice(0, i));
|
setDisplayed(text.slice(0, i));
|
||||||
|
|
|
||||||
|
|
@ -39,13 +39,16 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
|
||||||
content = [{ type: "text", text: String(msg.content) }];
|
content = [{ type: "text", text: String(msg.content) }];
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata = msg.author_id
|
const metadata = (msg.author_id || msg.token_usage)
|
||||||
? {
|
? {
|
||||||
custom: {
|
custom: {
|
||||||
|
...(msg.author_id && {
|
||||||
author: {
|
author: {
|
||||||
displayName: msg.author_display_name ?? null,
|
displayName: msg.author_display_name ?? null,
|
||||||
avatarUrl: msg.author_avatar_url ?? null,
|
avatarUrl: msg.author_avatar_url ?? null,
|
||||||
},
|
},
|
||||||
|
}),
|
||||||
|
...(msg.token_usage && { usage: msg.token_usage }),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,16 @@ export type SSEEvent =
|
||||||
| { type: "data-thread-title-update"; data: { threadId: number; title: string } }
|
| { type: "data-thread-title-update"; data: { threadId: number; title: string } }
|
||||||
| { type: "data-interrupt-request"; data: Record<string, unknown> }
|
| { type: "data-interrupt-request"; data: Record<string, unknown> }
|
||||||
| { type: "data-documents-updated"; data: Record<string, unknown> }
|
| { type: "data-documents-updated"; data: Record<string, unknown> }
|
||||||
|
| {
|
||||||
|
type: "data-token-usage";
|
||||||
|
data: {
|
||||||
|
usage: Record<string, { prompt_tokens: number; completion_tokens: number; total_tokens: number }>;
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
call_details: Array<{ model: string; prompt_tokens: number; completion_tokens: number; total_tokens: number }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
| { type: "error"; errorText: string };
|
| { type: "error"; errorText: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,13 @@ export interface ThreadRecord {
|
||||||
has_comments?: boolean;
|
has_comments?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TokenUsageSummary {
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
model_breakdown?: Record<string, { prompt_tokens: number; completion_tokens: number; total_tokens: number }> | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MessageRecord {
|
export interface MessageRecord {
|
||||||
id: number;
|
id: number;
|
||||||
thread_id: number;
|
thread_id: number;
|
||||||
|
|
@ -35,6 +42,7 @@ export interface MessageRecord {
|
||||||
author_id?: string | null;
|
author_id?: string | null;
|
||||||
author_display_name?: string | null;
|
author_display_name?: string | null;
|
||||||
author_avatar_url?: string | null;
|
author_avatar_url?: string | null;
|
||||||
|
token_usage?: TokenUsageSummary | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThreadListResponse {
|
export interface ThreadListResponse {
|
||||||
|
|
@ -111,11 +119,11 @@ export async function getThreadMessages(threadId: number): Promise<ThreadHistory
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append a message to a thread
|
* Append a message to a thread.
|
||||||
*/
|
*/
|
||||||
export async function appendMessage(
|
export async function appendMessage(
|
||||||
threadId: number,
|
threadId: number,
|
||||||
message: { role: "user" | "assistant" | "system"; content: unknown }
|
message: { role: "user" | "assistant" | "system"; content: unknown; token_usage?: unknown }
|
||||||
): Promise<MessageRecord> {
|
): Promise<MessageRecord> {
|
||||||
return baseApiService.post<MessageRecord>(`/api/v1/threads/${threadId}/messages`, undefined, {
|
return baseApiService.post<MessageRecord>(`/api/v1/threads/${threadId}/messages`, undefined, {
|
||||||
body: message,
|
body: message,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
DeepSeekIcon,
|
DeepSeekIcon,
|
||||||
FireworksAiIcon,
|
FireworksAiIcon,
|
||||||
GeminiIcon,
|
GeminiIcon,
|
||||||
|
GitHubModelsIcon,
|
||||||
GroqIcon,
|
GroqIcon,
|
||||||
HuggingFaceIcon,
|
HuggingFaceIcon,
|
||||||
MiniMaxIcon,
|
MiniMaxIcon,
|
||||||
|
|
@ -82,6 +83,8 @@ export function getProviderIcon(
|
||||||
return <FireworksAiIcon className={cn(className)} />;
|
return <FireworksAiIcon className={cn(className)} />;
|
||||||
case "GOOGLE":
|
case "GOOGLE":
|
||||||
return <GeminiIcon className={cn(className)} />;
|
return <GeminiIcon className={cn(className)} />;
|
||||||
|
case "GITHUB_MODELS":
|
||||||
|
return <GitHubModelsIcon className={cn(className)} />;
|
||||||
case "GROQ":
|
case "GROQ":
|
||||||
return <GroqIcon className={cn(className)} />;
|
return <GroqIcon className={cn(className)} />;
|
||||||
case "HUGGINGFACE":
|
case "HUGGINGFACE":
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,6 @@
|
||||||
"api_key_nav_description": "Manage your API access token",
|
"api_key_nav_description": "Manage your API access token",
|
||||||
"api_key_title": "API Key",
|
"api_key_title": "API Key",
|
||||||
"api_key_description": "Use this key to authenticate API requests",
|
"api_key_description": "Use this key to authenticate API requests",
|
||||||
"api_key_warning_title": "Keep it secret",
|
|
||||||
"api_key_warning_description": "Your API key grants full access to your account. Never share it publicly or commit it to version control.",
|
"api_key_warning_description": "Your API key grants full access to your account. Never share it publicly or commit it to version control.",
|
||||||
"your_api_key": "Your API Key",
|
"your_api_key": "Your API Key",
|
||||||
"copied": "Copied!",
|
"copied": "Copied!",
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,6 @@
|
||||||
"api_key_nav_description": "Administra tu token de acceso a la API",
|
"api_key_nav_description": "Administra tu token de acceso a la API",
|
||||||
"api_key_title": "Clave API",
|
"api_key_title": "Clave API",
|
||||||
"api_key_description": "Usa esta clave para autenticar las solicitudes de la API",
|
"api_key_description": "Usa esta clave para autenticar las solicitudes de la API",
|
||||||
"api_key_warning_title": "Mantenla en secreto",
|
|
||||||
"api_key_warning_description": "Tu clave API otorga acceso completo a tu cuenta. Nunca la compartas públicamente ni la incluyas en el control de versiones.",
|
"api_key_warning_description": "Tu clave API otorga acceso completo a tu cuenta. Nunca la compartas públicamente ni la incluyas en el control de versiones.",
|
||||||
"your_api_key": "Tu clave API",
|
"your_api_key": "Tu clave API",
|
||||||
"copied": "¡Copiado!",
|
"copied": "¡Copiado!",
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,6 @@
|
||||||
"api_key_nav_description": "अपना API एक्सेस टोकन प्रबंधित करें",
|
"api_key_nav_description": "अपना API एक्सेस टोकन प्रबंधित करें",
|
||||||
"api_key_title": "API कुंजी",
|
"api_key_title": "API कुंजी",
|
||||||
"api_key_description": "API अनुरोधों को प्रमाणित करने के लिए इस कुंजी का उपयोग करें",
|
"api_key_description": "API अनुरोधों को प्रमाणित करने के लिए इस कुंजी का उपयोग करें",
|
||||||
"api_key_warning_title": "इसे गुप्त रखें",
|
|
||||||
"api_key_warning_description": "आपकी API कुंजी आपके खाते तक पूर्ण पहुंच प्रदान करती है। इसे कभी सार्वजनिक रूप से साझा न करें या संस्करण नियंत्रण में शामिल न करें।",
|
"api_key_warning_description": "आपकी API कुंजी आपके खाते तक पूर्ण पहुंच प्रदान करती है। इसे कभी सार्वजनिक रूप से साझा न करें या संस्करण नियंत्रण में शामिल न करें।",
|
||||||
"your_api_key": "आपकी API कुंजी",
|
"your_api_key": "आपकी API कुंजी",
|
||||||
"copied": "कॉपी किया गया!",
|
"copied": "कॉपी किया गया!",
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,6 @@
|
||||||
"api_key_nav_description": "Gerencie seu token de acesso à API",
|
"api_key_nav_description": "Gerencie seu token de acesso à API",
|
||||||
"api_key_title": "Chave API",
|
"api_key_title": "Chave API",
|
||||||
"api_key_description": "Use esta chave para autenticar solicitações da API",
|
"api_key_description": "Use esta chave para autenticar solicitações da API",
|
||||||
"api_key_warning_title": "Mantenha em segredo",
|
|
||||||
"api_key_warning_description": "Sua chave API concede acesso total à sua conta. Nunca a compartilhe publicamente nem a inclua no controle de versão.",
|
"api_key_warning_description": "Sua chave API concede acesso total à sua conta. Nunca a compartilhe publicamente nem a inclua no controle de versão.",
|
||||||
"your_api_key": "Sua chave API",
|
"your_api_key": "Sua chave API",
|
||||||
"copied": "Copiado!",
|
"copied": "Copiado!",
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,6 @@
|
||||||
"api_key_nav_description": "管理您的API访问令牌",
|
"api_key_nav_description": "管理您的API访问令牌",
|
||||||
"api_key_title": "API密钥",
|
"api_key_title": "API密钥",
|
||||||
"api_key_description": "使用此密钥验证API请求",
|
"api_key_description": "使用此密钥验证API请求",
|
||||||
"api_key_warning_title": "请保密",
|
|
||||||
"api_key_warning_description": "您的API密钥可以完全访问您的账户。请勿公开分享或提交到版本控制。",
|
"api_key_warning_description": "您的API密钥可以完全访问您的账户。请勿公开分享或提交到版本控制。",
|
||||||
"your_api_key": "您的API密钥",
|
"your_api_key": "您的API密钥",
|
||||||
"copied": "已复制!",
|
"copied": "已复制!",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue