Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note_extension
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note_extension
Commits
2fec4891
Commit
2fec4891
authored
Mar 01, 2026
by
root
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Update backend agent, API routes, frontend components, docker config & .gitignore
parent
fe844925
Changes
39
Hide whitespace changes
Inline
Side-by-side
Showing
39 changed files
with
1429 additions
and
981 deletions
+1429
-981
.gitignore
.gitignore
+17
-0
controller.py
backend/agent/controller.py
+176
-7
graph.py
backend/agent/graph.py
+13
-11
helper.py
backend/agent/helper.py
+2
-2
prompt.py
backend/agent/prompt.py
+162
-29
system_prompt.txt
backend/agent/system_prompt.txt
+0
-123
memo_retrieval_tool.py
backend/agent/tools/memo_retrieval_tool.py
+148
-139
chat_route.py
backend/api/chatbot/chat_route.py
+90
-19
history_route.py
backend/api/chatbot/history_route.py
+32
-0
memo_routes.py
backend/api/memos/memo_routes.py
+21
-0
shortcut_routes.py
backend/api/memos/shortcut_routes.py
+3
-18
test_chat_route.py
backend/api/test_chat_route.py
+95
-0
clerk_auth.py
backend/common/clerk_auth.py
+2
-2
conversation_manager.py
backend/common/conversation_manager.py
+36
-436
embedding_service.py
backend/common/embedding_service.py
+5
-5
encryption.py
backend/common/encryption.py
+62
-38
llm_factory.py
backend/common/llm_factory.py
+2
-9
query_parser.py
backend/common/memos_core/query_parser.py
+12
-5
schemas.py
backend/common/memos_core/schemas.py
+1
-1
services.py
backend/common/memos_core/services.py
+61
-8
middleware.py
backend/common/middleware.py
+1
-0
mongo_client.py
backend/common/mongo_client.py
+8
-0
rate_limit.py
backend/common/rate_limit.py
+1
-1
timezone_config.py
backend/common/timezone_config.py
+9
-0
config.py
backend/config.py
+3
-5
entrypoint.sh
backend/entrypoint.sh
+8
-15
requirements.txt
backend/requirements.txt
+7
-8
server.py
backend/server.py
+22
-21
docker-compose.yml
docker-compose.yml
+16
-11
.env
frontend/.env
+2
-1
Dockerfile.dev
frontend/Dockerfile.dev
+1
-1
Dockerfile.prod
frontend/Dockerfile.prod
+1
-1
nginx.conf
frontend/nginx.conf
+11
-0
ChatbotPanel.tsx
frontend/src/components/ChatbotPanel.tsx
+222
-24
ChatbotWidget.tsx
frontend/src/components/ChatbotWidget.tsx
+24
-7
CreateShortcutDialog.tsx
frontend/src/components/CreateShortcutDialog.tsx
+146
-28
MonthNavigator.tsx
frontend/src/components/StatisticsView/MonthNavigator.tsx
+1
-1
Home.tsx
frontend/src/pages/Home.tsx
+2
-2
apiClient.ts
frontend/src/service/apiClient.ts
+4
-3
No files found.
.gitignore
View file @
2fec4891
...
@@ -69,3 +69,20 @@ run.txt
...
@@ -69,3 +69,20 @@ run.txt
# Ralph config (may contain API key)
# Ralph config (may contain API key)
.cursor/ralph-config.json
.cursor/ralph-config.json
# Docker cache
.docker/
# Environment
.env
# Frontend dist probes
frontend/dist-*/
# Temp configs
frontend/vite.probe*.config.mjs
frontend/vite.temp*.mjs
frontend/vite.trace*.config.mjs
frontend/vite.no-mermaid.config.mjs
frontend/src/__build_probe*.ts
frontend/src/__probe_*.ts
backend/agent/controller.py
View file @
2fec4891
...
@@ -3,6 +3,7 @@ Fashion Q&A Agent Controller
...
@@ -3,6 +3,7 @@ Fashion Q&A Agent Controller
Langfuse will auto-trace via LangChain integration (no code changes needed).
Langfuse will auto-trace via LangChain integration (no code changes needed).
"""
"""
import
json
import
logging
import
logging
import
time
import
time
import
uuid
import
uuid
...
@@ -78,19 +79,31 @@ async def chat_controller(
...
@@ -78,19 +79,31 @@ async def chat_controller(
memory
=
await
get_conversation_manager
()
memory
=
await
get_conversation_manager
()
# Load History
# Load History
history_dicts
=
await
memory
.
get_chat_history
(
effective_identity_key
,
limit
=
20
)
history_dicts
=
await
memory
.
get_chat_history
(
effective_identity_key
,
limit
=
5
)
history_messages
:
list
[
BaseMessage
]
=
[
history_messages
:
list
[
BaseMessage
]
=
[]
HumanMessage
(
content
=
m
[
"message"
])
if
m
[
"is_human"
]
else
AIMessage
(
content
=
m
[
"message"
])
for
m
in
history_dicts
:
for
m
in
history_dicts
if
m
[
"is_human"
]:
]
history_messages
.
append
(
HumanMessage
(
content
=
m
[
"message"
]))
else
:
# AI responses may be saved as JSON — extract readable text
ai_content
=
m
[
"message"
]
try
:
parsed
=
json
.
loads
(
ai_content
)
if
isinstance
(
parsed
,
dict
)
and
"ai_response"
in
parsed
:
ai_content
=
parsed
[
"ai_response"
]
except
(
json
.
JSONDecodeError
,
TypeError
):
pass
history_messages
.
append
(
AIMessage
(
content
=
ai_content
))
# Prepare State
# Prepare State
# - history: previous conversation messages (for context)
# - messages: starts with current query only (tool calls will be appended by LangGraph)
# - user_query: current user message
user_query_message
:
BaseMessage
=
HumanMessage
(
content
=
query
)
user_query_message
:
BaseMessage
=
HumanMessage
(
content
=
query
)
messages_with_query
:
list
[
BaseMessage
]
=
[
*
history_messages
,
user_query_message
]
initial_state
:
AgentState
=
{
initial_state
:
AgentState
=
{
"user_query"
:
user_query_message
,
"user_query"
:
user_query_message
,
"messages"
:
messages_with_query
,
"messages"
:
[
user_query_message
]
,
"history"
:
history_messages
,
"history"
:
history_messages
,
"user_id"
:
user_id
,
"user_id"
:
user_id
,
"images_embedding"
:
[],
"images_embedding"
:
[],
...
@@ -124,7 +137,9 @@ async def chat_controller(
...
@@ -124,7 +137,9 @@ async def chat_controller(
# Parse Response
# Parse Response
all_product_ids
=
extract_product_ids
(
result
.
get
(
"messages"
,
[]))
all_product_ids
=
extract_product_ids
(
result
.
get
(
"messages"
,
[]))
ai_raw_content
=
result
.
get
(
"ai_response"
)
.
content
if
result
.
get
(
"ai_response"
)
else
""
ai_raw_content
=
result
.
get
(
"ai_response"
)
.
content
if
result
.
get
(
"ai_response"
)
else
""
logger
.
info
(
"RAW LLM output (
%
d chars):
%
s"
,
len
(
ai_raw_content
),
ai_raw_content
[:
500
])
ai_text_response
,
final_product_ids
=
parse_ai_response
(
ai_raw_content
,
all_product_ids
)
ai_text_response
,
final_product_ids
=
parse_ai_response
(
ai_raw_content
,
all_product_ids
)
logger
.
info
(
"PARSED ai_response (
%
d chars):
%
s"
,
len
(
ai_text_response
),
ai_text_response
[:
300
])
response_payload
=
{
response_payload
=
{
"ai_response"
:
ai_text_response
,
"ai_response"
:
ai_text_response
,
...
@@ -153,3 +168,157 @@ async def chat_controller(
...
@@ -153,3 +168,157 @@ async def chat_controller(
logger
.
info
(
"chat_controller finished in
%.2
fs"
,
duration
)
logger
.
info
(
"chat_controller finished in
%.2
fs"
,
duration
)
return
{
**
response_payload
,
"cached"
:
False
}
return
{
**
response_payload
,
"cached"
:
False
}
async
def
chat_controller_stream
(
query
:
str
,
user_id
:
str
,
model_name
:
str
=
DEFAULT_MODEL
,
images
:
list
[
str
]
|
None
=
None
,
identity_key
:
str
|
None
=
None
,
):
"""
Streaming controller — yields SSE events with token chunks.
Each yield is a JSON string:
{"token": "partial text"} — during streaming
{"done": true, "ai_response": "full text"} — final event
History is saved after stream completes.
"""
effective_identity_key
=
identity_key
or
user_id
logger
.
info
(
"chat_controller_stream start: model=
%
s, user_id=
%
s"
,
model_name
,
user_id
,
)
# ====================== CACHE LAYER ======================
if
REDIS_CACHE_TURN_ON
:
cached_response
=
await
redis_cache
.
get_response
(
user_id
=
effective_identity_key
,
query
=
query
)
if
cached_response
:
logger
.
info
(
f
"⚡ CACHE HIT (stream) for identity_key={effective_identity_key}"
)
# Stream cached response as one chunk
ai_text
=
cached_response
.
get
(
"ai_response"
,
""
)
yield
json
.
dumps
({
"token"
:
ai_text
},
ensure_ascii
=
False
)
yield
json
.
dumps
({
"done"
:
True
,
"ai_response"
:
ai_text
},
ensure_ascii
=
False
)
# Save history
memory
=
await
get_conversation_manager
()
await
handle_post_chat_async
(
memory
=
memory
,
identity_key
=
effective_identity_key
,
human_query
=
query
,
ai_response
=
cached_response
,
)
return
# ====================== STREAM LLM FLOW ======================
graph
=
build_graph
()
memory
=
await
get_conversation_manager
()
# Load History
history_dicts
=
await
memory
.
get_chat_history
(
effective_identity_key
,
limit
=
5
)
history_messages
:
list
[
BaseMessage
]
=
[]
for
m
in
history_dicts
:
if
m
[
"is_human"
]:
history_messages
.
append
(
HumanMessage
(
content
=
m
[
"message"
]))
else
:
ai_content
=
m
[
"message"
]
try
:
parsed
=
json
.
loads
(
ai_content
)
if
isinstance
(
parsed
,
dict
)
and
"ai_response"
in
parsed
:
ai_content
=
parsed
[
"ai_response"
]
except
(
json
.
JSONDecodeError
,
TypeError
):
pass
history_messages
.
append
(
AIMessage
(
content
=
ai_content
))
user_query_message
:
BaseMessage
=
HumanMessage
(
content
=
query
)
initial_state
:
AgentState
=
{
"user_query"
:
user_query_message
,
"messages"
:
[
user_query_message
],
"history"
:
history_messages
,
"user_id"
:
user_id
,
"images_embedding"
:
[],
"ai_response"
:
None
,
}
run_uuid
=
uuid
.
uuid4
()
run_id_str
=
str
(
run_uuid
)
langfuse_handler
=
get_callback_handler
()
exec_config
=
RunnableConfig
(
configurable
=
{
"user_id"
:
user_id
,
"transient_images"
:
images
or
[],
"run_id"
:
run_id_str
,
},
run_id
=
run_uuid
,
metadata
=
{
"run_id"
:
run_id_str
,
"tags"
:
"chatbot,production,stream"
},
callbacks
=
[
langfuse_handler
]
if
langfuse_handler
else
[],
)
# Stream using astream_events
start_time
=
time
.
time
()
full_response
=
""
is_final_response
=
False
session_id
=
f
"{user_id}-{run_id_str[:8]}"
with
propagate_attributes
(
user_id
=
user_id
,
session_id
=
session_id
):
async
for
event
in
graph
.
astream_events
(
initial_state
,
config
=
exec_config
,
version
=
"v2"
):
kind
=
event
.
get
(
"event"
,
""
)
# Only stream tokens from the chat model (not tool calls)
if
kind
==
"on_chat_model_stream"
:
chunk
=
event
.
get
(
"data"
,
{})
.
get
(
"chunk"
)
if
chunk
and
hasattr
(
chunk
,
"content"
)
and
chunk
.
content
:
# Skip if this is a tool_call chunk (no text content)
if
hasattr
(
chunk
,
"tool_calls"
)
and
chunk
.
tool_calls
:
continue
# Only stream the FINAL response (after tool execution)
# We detect this by tracking: if tools were called,
# the final agent response comes after tool results
token
=
chunk
.
content
full_response
+=
token
is_final_response
=
True
yield
json
.
dumps
({
"token"
:
token
},
ensure_ascii
=
False
)
# When the agent finishes and we got tokens, prepare to end
elif
kind
==
"on_chain_end"
and
event
.
get
(
"name"
)
==
"agent"
:
if
is_final_response
:
# Reset for potential next agent iteration
pass
duration
=
time
.
time
()
-
start_time
logger
.
info
(
"chat_controller_stream finished in
%.2
fs (
%
d chars)"
,
duration
,
len
(
full_response
))
# Parse and yield final event
ai_text_response
,
_
=
parse_ai_response
(
full_response
,
[])
yield
json
.
dumps
({
"done"
:
True
,
"ai_response"
:
ai_text_response
},
ensure_ascii
=
False
)
# Build response payload for caching and history
response_payload
=
{
"ai_response"
:
ai_text_response
,
"product_ids"
:
[],
}
# Save to cache
if
REDIS_CACHE_TURN_ON
:
await
redis_cache
.
set_response
(
user_id
=
effective_identity_key
,
query
=
query
,
response_data
=
response_payload
,
ttl
=
300
,
)
# Save to history
await
handle_post_chat_async
(
memory
=
memory
,
identity_key
=
effective_identity_key
,
human_query
=
query
,
ai_response
=
response_payload
,
)
backend/agent/graph.py
View file @
2fec4891
...
@@ -46,29 +46,31 @@ class CANIFAGraph:
...
@@ -46,29 +46,31 @@ class CANIFAGraph:
self
.
retrieval_tools
=
self
.
all_tools
self
.
retrieval_tools
=
self
.
all_tools
self
.
llm_with_tools
=
self
.
llm
.
bind_tools
(
self
.
all_tools
,
strict
=
True
)
self
.
llm_with_tools
=
self
.
llm
.
bind_tools
(
self
.
all_tools
,
strict
=
True
)
self
.
system_prompt
=
get_system_prompt
()
# NOTE: prompt is NOT cached here — fetched fresh each request
self
.
prompt_template
=
ChatPromptTemplate
.
from_messages
(
# so Langfuse updates take effect immediately.
self
.
cache
=
InMemoryCache
()
def
_build_chain
(
self
):
"""Build chain with fresh system prompt (from Langfuse or local fallback)."""
system_prompt
=
get_system_prompt
()
prompt_template
=
ChatPromptTemplate
.
from_messages
(
[
[
(
"system"
,
s
elf
.
s
ystem_prompt
),
(
"system"
,
system_prompt
),
MessagesPlaceholder
(
variable_name
=
"history"
),
MessagesPlaceholder
(
variable_name
=
"history"
),
MessagesPlaceholder
(
variable_name
=
"user_query"
),
MessagesPlaceholder
(
variable_name
=
"user_query"
),
MessagesPlaceholder
(
variable_name
=
"messages"
),
MessagesPlaceholder
(
variable_name
=
"messages"
),
]
]
)
)
self
.
chain
=
self
.
prompt_template
|
self
.
llm_with_tools
return
prompt_template
|
self
.
llm_with_tools
self
.
cache
=
InMemoryCache
()
async
def
_agent_node
(
self
,
state
:
AgentState
,
config
:
RunnableConfig
)
->
dict
:
async
def
_agent_node
(
self
,
state
:
AgentState
,
config
:
RunnableConfig
)
->
dict
:
"""Agent node
- Chỉ việc đổ dữ liệu riêng vào khuôn đã có sẵn
."""
"""Agent node
— rebuilds chain each call for realtime prompt updates
."""
messages
=
state
.
get
(
"messages"
,
[])
messages
=
state
.
get
(
"messages"
,
[])
history
=
state
.
get
(
"history"
,
[])
history
=
state
.
get
(
"history"
,
[])
user_query
=
state
.
get
(
"user_query"
)
user_query
=
state
.
get
(
"user_query"
)
transient_images
=
config
.
get
(
"configurable"
,
{})
.
get
(
"transient_images"
,
[])
chain
=
self
.
_build_chain
()
if
transient_images
and
messages
:
response
=
await
chain
.
ainvoke
({
pass
# Invoke chain with user_query, history, and messages
response
=
await
self
.
chain
.
ainvoke
({
"user_query"
:
[
user_query
]
if
user_query
else
[],
"user_query"
:
[
user_query
]
if
user_query
else
[],
"history"
:
history
,
"history"
:
history
,
"messages"
:
messages
"messages"
:
messages
...
...
backend/agent/helper.py
View file @
2fec4891
...
@@ -10,7 +10,7 @@ import uuid
...
@@ -10,7 +10,7 @@ import uuid
from
langchain_core.messages
import
HumanMessage
,
ToolMessage
from
langchain_core.messages
import
HumanMessage
,
ToolMessage
from
langchain_core.runnables
import
RunnableConfig
from
langchain_core.runnables
import
RunnableConfig
from
common.conversation_manager
import
ConversationManager
from
common.conversation_manager
import
MongoDB
ConversationManager
from
common.langfuse_client
import
get_callback_handler
from
common.langfuse_client
import
get_callback_handler
from
.models
import
AgentState
from
.models
import
AgentState
...
@@ -137,7 +137,7 @@ def prepare_execution_context(query: str, user_id: str, history: list, images: l
...
@@ -137,7 +137,7 @@ def prepare_execution_context(query: str, user_id: str, history: list, images: l
async
def
handle_post_chat_async
(
async
def
handle_post_chat_async
(
memory
:
ConversationManager
,
memory
:
MongoDB
ConversationManager
,
identity_key
:
str
,
identity_key
:
str
,
human_query
:
str
,
human_query
:
str
,
ai_response
:
dict
|
None
ai_response
:
dict
|
None
...
...
backend/agent/prompt.py
View file @
2fec4891
"""
"""
CiCi Fashion Consultant - System Prompt
CuCu Assistant - System Prompt
Version 3.0 - Dynamic from File
Supports two modes:
1. Langfuse prompt management (realtime, editable from Langfuse dashboard)
2. Local fallback (inline template)
"""
"""
import
os
import
logging
from
datetime
import
datetime
from
datetime
import
datetime
from
functools
import
lru_cache
PROMPT_FILE_PATH
=
os
.
path
.
join
(
os
.
path
.
dirname
(
__file__
),
"system_prompt.txt"
)
from
common.timezone_config
import
VIETNAM_TZ
logger
=
logging
.
getLogger
(
__name__
)
def
_ensure_json_instruction
(
prompt_text
:
str
)
->
str
:
# Vietnamese weekday names
if
"json"
in
prompt_text
.
lower
():
_WEEKDAY_MAP
=
{
return
prompt_text
0
:
"Thứ 2"
,
return
f
"{prompt_text}
\n\n
Return JSON (json) object with keys: ai_response, product_ids."
1
:
"Thứ 3"
,
2
:
"Thứ 4"
,
3
:
"Thứ 5"
,
4
:
"Thứ 6"
,
5
:
"Thứ 7"
,
6
:
"Chủ nhật"
,
}
def
get_system_prompt
()
->
str
:
def
_get_weekday_str
()
->
str
:
"""
return
_WEEKDAY_MAP
[
datetime
.
now
(
VIETNAM_TZ
)
.
weekday
()]
System prompt for CiCi Fashion Agent.
# ──────────────────────────── Local template ────────────────────────────
# This is the SAME prompt pushed to Langfuse via scripts/push_prompt_to_langfuse.py
# {{date_str}} is the only variable, replaced at runtime.
_PROMPT_TEMPLATE
=
"""# VAI TRÒ
Bạn là **CuCu Assistant** - Trợ lý quản lý ghi chú cá nhân (Memos).
- Thông minh, ngắn gọn, đi thẳng vào vấn đề.
- NHIỆM VỤ DUY NHẤT: Giúp người dùng tìm kiếm và truy vấn lại các ghi chú (memos) họ đã lưu.
- Hôm nay: {{date_str}} ({{weekday_str}})
---
# QUY TẮC SỬ DỤNG TOOL "memo_retrieval_tool"
## 0. KHI NÀO GỌI TOOL vs KHÔNG GỌI
### KHÔNG gọi tool (chỉ chào lại):
- Câu CHỈ có lời chào, KHÔNG nhắc gì đến note/ghi chú/chủ đề: "hello", "hi bro", "chào em"
### CÓ gọi tool (ưu tiên tìm kiếm):
- Câu có nhắc đến **bất kỳ từ khóa nào** liên quan note/ghi chú/chủ đề, DÙ CÓ LỜI CHÀO đi kèm:
- "chào em, tao note kafka hôm nào ấy" → GỌI TOOL tìm kafka
- "hello, hôm qua tao note gì" → GỌI TOOL tìm theo ngày
- "ê bro, tìm note về meeting" → GỌI TOOL tìm meeting
**NGUYÊN TẮC: Nếu câu có chứa từ khóa/chủ đề/topic → LUÔN GỌI TOOL, bỏ qua phần chào hỏi.**
## 1. TỰ TÍNH TOÁN NGÀY THÁNG
Bạn PHẢI tự tính ngày cụ thể (YYYY-MM-DD) dựa trên "Hôm nay: {{date_str}} ({{weekday_str}})".
Quy ước thứ: Thứ 2 = Monday, Thứ 3 = Tuesday, Thứ 4 = Wednesday, Thứ 5 = Thursday, Thứ 6 = Friday, Thứ 7 = Saturday, Chủ nhật = Sunday.
### CÁC MỐC THỜI GIAN THÔNG DỤNG:
- "Hôm nay" → `start_date` = `end_date` = {{date_str}}
- "Hôm qua" → `start_date` = `end_date` = {{date_str}} - 1 ngày
- "Tuần trước" (không nói ngày cụ thể) → `start_date` = thứ 2 tuần trước, `end_date` = chủ nhật tuần trước
- "Tuần này" → `start_date` = thứ 2 tuần này, `end_date` = {{date_str}}
- "Tháng này" → `start_date` = ngày đầu tháng, `end_date` = {{date_str}}
- "Năm nay" → `start_date` = ngày 1/1, `end_date` = {{date_str}}
### CỰC KỲ QUAN TRỌNG — "THỨ X TUẦN TRƯỚC/NÀY" = ĐÚNG 1 NGÀY:
Khi user nói "thứ X tuần trước" hoặc "thứ X tuần này", tính ra ĐÚNG 1 NGÀY cụ thể:
- `start_date` = `end_date` = ngày đó (YYYY-MM-DD)
- Ví dụ: Nếu hôm nay là 2026-02-25 (Thứ 4), thì:
- "thứ 5 tuần trước" → 2026-02-19 (chỉ 1 ngày!)
- "thứ 2 tuần này" → 2026-02-23 (chỉ 1 ngày!)
- "thứ 6 tuần trước" → 2026-02-20 (chỉ 1 ngày!)
- **KHÔNG ĐƯỢC dùng range cả tuần** — user hỏi đúng 1 ngày thì trả đúng 1 ngày!
### KHI USER HỎI "HÔM NÀO / NGÀY NÀO / BAO GIỜ":
- "Kafka note hôm nào ấy nhỉ?" = User ĐANG HỎI ngày → tìm ALL dates
- "Tao note cái đó khi nào?" = User ĐANG HỎI ngày → tìm ALL dates
- → Dùng range rộng: `start_date` = "2020-01-01", `end_date` = {{date_str}}
- **KHÔNG ĐƯỢC HỎI LẠI "ngày nào?"** — vì user đang nhờ bot tìm ngày!
- → **CHỈ TRẢ VỀ NGÀY**, ví dụ: "Bạn note cái đó vào ngày **2026-02-05** (Thứ 5) nhé!"
- **KHÔNG cần trích dẫn toàn bộ nội dung** khi user hỏi "hôm nào/khi nào" — user chỉ cần biết NGÀY.
- Nếu tìm thấy nhiều memo khớp, liệt kê ngày của từng memo.
### KHI KHÔNG NHẮC THỜI GIAN:
- "Tìm note về X" → range rộng: `start_date` = "2020-01-01", `end_date` = {{date_str}}
## 2. PHÂN TÍCH PARAMETERS
### NGUYÊN TẮC: CHỈ THÊM PARAMETER KHI USER NÓI RÕ
- **`content_search`**: Khi user nhắc từ khóa: "về Kafka", "pass wifi", "meeting"
- **`tag`**: Khi user nhắc tag: "#work", "#idea"
- **KHÔNG THÊM** content_search/tag nếu user chỉ hỏi theo ngày
Returns:
### VÍ DỤ:
str: System prompt with the current date.
- "Hôm qua note gì?" → ✅ date only
- "Note kafka hôm nào?" → ✅ `content_search="kafka"` + range rộng
- "Tìm note #work tuần này" → ✅ `tag="work"` + date tuần này
## 3. KHI NÀO HỎI LẠI USER
- **CHỈ hỏi lại khi THẬT SỰ không có thông tin gì**: "Tìm note", "Tìm cái đó"
- **KHÔNG HỎI LẠI** nếu có bất kỳ keyword nào: "note kafka hôm nào" → đủ rồi, GỌI TOOL
- **KHÔNG BAO GIỜ hỏi lại ngày** nếu user đang hỏi "hôm nào/khi nào" → dùng range rộng
---
# QUY TẮC TRẢ LỜI (CỰC KỲ QUAN TRỌNG)
1. **NGẮN GỌN DƯỚI 100 TỪ**: Trả lời súc tích, đi thẳng vấn đề. KHÔNG dài dòng.
2. **TÓM TẮT NỘI DUNG**: Mỗi memo chỉ hiển thị **tóm tắt ngắn gọn** (tối đa 15 từ), KHÔNG trích dẫn toàn bộ nội dung.
3. **FORMAT**:
- **📝 (YYYY-MM-DD):** [tóm tắt ngắn gọn nội dung]
- Nếu nhiều memo, liệt kê dạng danh sách bullet
4. **KHÔNG BỊA ĐẶT**: Không tự chế nội dung.
5. **NGÔN NGỮ**: Thân thiện, tự nhiên, như nói chuyện với bạn.
6. Nếu count=0: "Không tìm thấy ghi chú nào 🤷"
7. **Trả lời bằng text thuần/markdown**, KHÔNG wrap JSON.
8. **CHỈ HIỂN THỊ ĐẦY ĐỦ** khi user yêu cầu rõ: "cho xem chi tiết", "đọc full nội dung"."""
def
get_system_prompt_template
()
->
str
:
"""Return the raw prompt template with {{date_str}} placeholder.
Used by the push script to upload to Langfuse.
"""
"""
date_str
=
datetime
.
now
()
.
strftime
(
"
%
d/
%
m/
%
Y"
)
return
_PROMPT_TEMPLATE
def
_fetch_langfuse_prompt
()
->
str
|
None
:
"""
Try to fetch the latest prompt from Langfuse.
Returns the compiled prompt string, or None if unavailable.
Uses Langfuse's built-in caching (default TTL=60s).
"""
try
:
try
:
if
os
.
path
.
exists
(
PROMPT_FILE_PATH
):
from
common.langfuse_client
import
get_langfuse_client
with
open
(
PROMPT_FILE_PATH
,
"r"
,
encoding
=
"utf-8"
)
as
handle
:
client
=
get_langfuse_client
()
prompt_template
=
handle
.
read
()
if
not
client
:
rendered
=
prompt_template
.
replace
(
"{date_str}"
,
date_str
)
return
None
return
_ensure_json_instruction
(
rendered
)
except
Exception
as
exc
:
prompt
=
client
.
get_prompt
(
print
(
f
"Error reading system prompt file: {exc}"
)
name
=
"cucu-system-prompt"
,
label
=
"production"
,
fallback
=
f
"""# ROLE
cache_ttl_seconds
=
60
,
# Re-fetch every 60s
You are CiCi, a CANIFA fashion assistant.
)
Today: {date_str}
date_str
=
datetime
.
now
(
VIETNAM_TZ
)
.
strftime
(
"
%
Y-
%
m-
%
d"
)
weekday_str
=
_get_weekday_str
()
Never fabricate. Keep responses concise.
compiled
=
prompt
.
compile
(
date_str
=
date_str
,
weekday_str
=
weekday_str
)
"""
logger
.
info
(
"✅ Prompt fetched from Langfuse (version=
%
s)"
,
prompt
.
version
)
return
_ensure_json_instruction
(
fallback
)
return
compiled
except
Exception
as
e
:
logger
.
warning
(
"⚠️ Langfuse prompt fetch failed:
%
s — using local fallback"
,
e
)
return
None
def
get_system_prompt
()
->
str
:
"""
Get the system prompt. Priority:
1. Langfuse prompt management (realtime, editable)
2. Local fallback template
"""
# Try Langfuse first
langfuse_prompt
=
_fetch_langfuse_prompt
()
if
langfuse_prompt
:
return
langfuse_prompt
# Fallback to local template
date_str
=
datetime
.
now
(
VIETNAM_TZ
)
.
strftime
(
"
%
Y-
%
m-
%
d"
)
weekday_str
=
_get_weekday_str
()
prompt
=
_PROMPT_TEMPLATE
.
replace
(
"{{date_str}}"
,
date_str
)
.
replace
(
"{{weekday_str}}"
,
weekday_str
)
logger
.
info
(
"📝 Using local prompt fallback (date=
%
s, weekday=
%
s)"
,
date_str
,
weekday_str
)
return
prompt
backend/agent/system_prompt.txt
deleted
100644 → 0
View file @
fe844925
# VAI TRÒ
Bạn là **CuCu Assistant** - Trợ lý quản lý ghi chú cá nhân (Memos).
- Thông minh, ngắn gọn, đi thẳng vào vấn đề.
- NHIỆM VỤ DUY NHẤT: Giúp người dùng tìm kiếm và truy vấn lại các ghi chú (memos) họ đã lưu.
- Hôm nay: {date_str}
---
# QUY TẮC SỬ DỤNG TOOL "memo_retrieval_tool"
Bạn chỉ có 1 tool duy nhất là `memo_retrieval_tool`. Hãy sử dụng nó thông minh.
## 0. KHI NÀO **KHÔNG** ĐƯỢC GỌI TOOL (QUAN TRỌNG)
- Nếu user **chỉ chào hỏi / small talk** (VD: "chào em", "hello", "hi bro", "alo"), hãy:
- Trả lời lại một câu chào ngắn gọn, KHÔNG gọi `memo_retrieval_tool`.
- Chỉ gọi tool khi user hỏi RÕ về **ghi chú / note / ngày / nội dung / tag**.
- Không được "đoán" là user đang hỏi về Kafka, work, v.v. nếu câu hiện tại **chỉ là lời chào**.
- Không dùng **câu hỏi cũ** để tự ý gọi tool cho câu mới nếu câu mới chỉ là lời chào.
## 1. TỰ TÍNH TOÁN NGÀY THÁNG (QUAN TRỌNG)
Người dùng sẽ hỏi ngày tương đối (hôm qua, tuần trước...). Bạn PHẢI tự tính ra ngày cụ thể (YYYY-MM-DD) dựa trên "Hôm nay: {date_str}".
- **"Hôm nay note gì?"**
→ `start_date` = {date_str}, `end_date` = {date_str}
- **"Hôm qua note gì?"**
→ `start_date` = {date_str} - 1 ngày
- **"Hôm kia note gì?"**
→ `start_date` = {date_str} - 2 ngày
- **"Tuần trước note gì?"**
→ `start_date` = {date_str} - 7 ngày, `end_date` = {date_str} (hoặc range cụ thể của tuần trước)
- **"Tháng 1 note gì?"**
→ `start_date` = "2026-01-01", `end_date` = "2026-01-31"
## 2. PHÂN TÍCH PARAMETERS
- **`content_search`**: Dùng khi user hỏi về nội dung (VD: "dự án A", "pass wifi", "số điện thoại"). Dùng Regex nên hãy chọn keyword đặc trưng.
- **`tag`**: Dùng khi user nhắc đến tag/chủ đề (VD: "#work", "#idea"). Chỉ điền nếu user KHẲNG ĐỊNH là tag.
### 2.1. XỬ LÝ "TOPIC" THÀNH TAG / CONTENT_SEARCH
- Nếu user nhắc đến **một chủ đề ngắn gọn** (VD: "Kafka", "English", "health") nhưng **không có dấu #**:
- Hãy coi đó là một **topic**.
- Nếu topic là **một từ đơn, không có khoảng trắng** (VD: "Kafka", "work"):
- ƯU TIÊN map thành `tag` (VD: `tag="kafka"` hoặc `tag="work"` — KHÔNG cần dấu `#`).
- Nếu topic là **cụm từ dài** (VD: "Kafka performance", "meeting tuần trước"):
- Map thành `content_search` (VD: `content_search="Kafka performance"`).
- Nếu không chắc đó là tag hay chỉ là từ khóa nội dung:
- Có thể điền **cả hai**:
- `tag="kafka"`
- `content_search="Kafka"`
- Khi đó MongoDB sẽ lọc theo tag (nếu note có tag) và/hoặc nội dung có chứa keyword.
## 3. VÍ DỤ GỌI TOOL
**Case 1: Hỏi theo ngày**
*Input: "Hôm qua tao có note gì không?" (Giả sử hôm nay 2026-01-24)*
→ Bot tính: Hôm qua = 2026-01-23
→ Tool call: `memo_retrieval_tool(start_date="2026-01-23")`
**Case 2: Tìm nội dung + Ngày**
*Input: "Tuần này tao note gì về 'meeting'?" (Hôm nay 2026-01-24)*
→ Bot tính: Tuần này ~ 2026-01-19 đến 2026-01-25
→ Tool call:
```python
memo_retrieval_tool(
start_date="2026-01-19",
end_date="2026-01-25",
content_search="meeting"
)
```
**Case 3: Tìm theo tag**
*Input: "Tìm mấy cái note #idea tháng trước"*
→ Tool call:
```python
memo_retrieval_tool(
start_date="2025-12-01",
end_date="2025-12-31",
tag="idea"
)
```
**Case 4: Tìm nội dung chung chung (Không rõ ngày)**
*Input: "Tìm lại pass wifi"*
→ Bot tự chọn range rộng hoặc không giới hạn (tùy tool support, ở đây tool bắt buộc start_date thì lấy ngày xa xưa hoặc 1 tháng gần nhất tùy ngữ cảnh, hoặc hỏi lại user. NHƯNG tốt nhất cứ search 1 năm gần đây).
→ Tool call: `memo_retrieval_tool(start_date="2025-01-01", content_search="pass wifi")`
---
# QUY TẮC TRẢ LỜI (RESPONSE)
1. **DỰA TRÊN KẾT QUẢ TOOL**:
- Nếu có memos: Liệt kê ngắn gọn, trích dẫn nội dung chính.
- Nếu `count` = 0: Trả lời "Không tìm thấy ghi chú nào trong khoảng thời gian này/với từ khóa này."
2. **KHÔNG BỊA ĐẶT**: Không tự chế ra nội dung memo không có trong data.
3. **FORMAT MENU**:
- Ghi chú 1 (2026-01-24): [Nội dung tóm tắt]
- Ghi chú 2 (2026-01-23): [Nội dung tóm tắt]
4. **NGÔN NGỮ**: Giao tiếp tự nhiên, thân thiện (bro-style nếu user thích, hoặc lịch sự mặc định).
---
# FORMAT ĐẦU RA (JSON)
Bot trả lời dưới dạng JSON (để Frontend render hoặc parse):
```json
{{
"ai_response": "Đây là các ghi chú mình tìm thấy hôm qua...",
"found_memos": [
{{
"id": "...",
"content": "...",
"created_at": "..."
}}
]
}}
```
*Lưu ý: Nếu tool trả về data, hãy tóm tắt vào `ai_response` và dán raw data vào `found_memos` nếu cần.*
\ No newline at end of file
backend/agent/tools/memo_retrieval_tool.py
View file @
2fec4891
...
@@ -16,6 +16,8 @@ import os
...
@@ -16,6 +16,8 @@ import os
from
datetime
import
datetime
,
time
,
timedelta
,
timezone
from
datetime
import
datetime
,
time
,
timedelta
,
timezone
from
typing
import
Optional
,
Any
from
typing
import
Optional
,
Any
from
langchain_core.runnables
import
RunnableConfig
from
langchain_core.tools
import
tool
from
langchain_core.tools
import
tool
from
langchain_openai
import
OpenAIEmbeddings
from
langchain_openai
import
OpenAIEmbeddings
from
pydantic
import
SecretStr
from
pydantic
import
SecretStr
...
@@ -26,6 +28,40 @@ from common.mongo_client import mongodb_client, serialize_doc
...
@@ -26,6 +28,40 @@ from common.mongo_client import mongodb_client, serialize_doc
logger
=
logging
.
getLogger
(
__name__
)
logger
=
logging
.
getLogger
(
__name__
)
# ---- Cached singletons to avoid re-init per request ----
_embedder
:
OpenAIEmbeddings
|
None
=
None
_cached_embeddings
:
list
[
dict
]
|
None
=
None
_cache_timestamp
:
float
=
0
_CACHE_TTL
=
300
# 5 minutes
def
_get_embedder
()
->
OpenAIEmbeddings
|
None
:
global
_embedder
if
_embedder
is
not
None
:
return
_embedder
api_key
=
os
.
getenv
(
"OPENAI_API_KEY"
)
if
not
api_key
:
return
None
_embedder
=
OpenAIEmbeddings
(
model
=
"text-embedding-3-small"
,
api_key
=
SecretStr
(
api_key
),
)
return
_embedder
async
def
_get_cached_embeddings
()
->
list
[
dict
]:
"""Load embeddings from MongoDB with in-memory cache."""
import
time
as
_time
global
_cached_embeddings
,
_cache_timestamp
now
=
_time
.
time
()
if
_cached_embeddings
is
not
None
and
(
now
-
_cache_timestamp
)
<
_CACHE_TTL
:
return
_cached_embeddings
cursor
=
mongodb_client
.
memo_embeddings
.
find
({})
_cached_embeddings
=
await
cursor
.
to_list
(
length
=
5000
)
_cache_timestamp
=
now
logger
.
info
(
"Refreshed embeddings cache:
%
d docs"
,
len
(
_cached_embeddings
))
return
_cached_embeddings
@
tool
@
tool
async
def
memo_retrieval_tool
(
async
def
memo_retrieval_tool
(
...
@@ -33,6 +69,8 @@ async def memo_retrieval_tool(
...
@@ -33,6 +69,8 @@ async def memo_retrieval_tool(
end_date
:
Optional
[
str
]
=
None
,
end_date
:
Optional
[
str
]
=
None
,
content_search
:
Optional
[
str
]
=
None
,
content_search
:
Optional
[
str
]
=
None
,
tag
:
Optional
[
str
]
=
None
,
tag
:
Optional
[
str
]
=
None
,
*
,
config
:
RunnableConfig
,
)
->
str
:
)
->
str
:
"""
"""
Truy vấn các memo từ database MongoDB (cuccu_memos).
Truy vấn các memo từ database MongoDB (cuccu_memos).
...
@@ -47,156 +85,127 @@ async def memo_retrieval_tool(
...
@@ -47,156 +85,127 @@ async def memo_retrieval_tool(
JSON string chứa danh sách memo tìm được.
JSON string chứa danh sách memo tìm được.
"""
"""
try
:
try
:
# Extract user_id from RunnableConfig to filter by creator
user_id
=
(
config
.
get
(
"configurable"
)
or
{})
.
get
(
"user_id"
)
logger
.
info
(
logger
.
info
(
"memo_retrieval_tool started: start=
%
s, end=
%
s, content=
%
s, tag=
%
s"
,
"memo_retrieval_tool started:
user_id=
%
s,
start=
%
s, end=
%
s, content=
%
s, tag=
%
s"
,
start_date
,
end_date
,
content_search
,
tag
user_id
,
start_date
,
end_date
,
content_search
,
tag
)
)
#
1. Build Date Filter
#
──────────────────────────────────────────────
query
:
dict
[
str
,
Any
]
=
{}
# Step 1: Parse dates
# ──────────────────────────────────────────────
try
:
try
:
# Parse start_date key
from
common.timezone_config
import
VIETNAM_TZ
dt_start
=
datetime
.
strptime
(
start_date
,
"
%
Y-
%
m-
%
d"
)
.
replace
(
tzinfo
=
timezone
.
utc
)
dt_start
=
datetime
.
strptime
(
start_date
,
"
%
Y-
%
m-
%
d"
)
.
replace
(
tzinfo
=
VIETNAM_TZ
)
dt_end
=
(
if
end_date
:
datetime
.
strptime
(
end_date
,
"
%
Y-
%
m-
%
d"
)
.
replace
(
tzinfo
=
VIETNAM_TZ
)
# Range query: start_date 00:00:00 -> end_date 23:59:59
if
end_date
else
dt_start
dt_end_str
=
end_date
)
dt_end
=
datetime
.
strptime
(
end_date
,
"
%
Y-
%
m-
%
d"
)
.
replace
(
tzinfo
=
timezone
.
utc
)
begin_of_start_day
=
datetime
.
combine
(
dt_start
,
time
.
min
)
.
replace
(
tzinfo
=
VIETNAM_TZ
)
.
astimezone
(
timezone
.
utc
)
else
:
end_of_end_day
=
datetime
.
combine
(
dt_end
,
time
.
max
)
.
replace
(
tzinfo
=
VIETNAM_TZ
)
.
astimezone
(
timezone
.
utc
)
# Single day query: start_date 00:00:00 -> start_date 23:59:59
dt_end_str
=
start_date
dt_end
=
dt_start
# Tạo mốc thời gian đầu ngày và cuối ngày
# Lưu ý: MongoDB lưu UTC. Cần đảm bảo logic query phù hợp.
# Ở đây giả sử start_date nhận vào là Local nhưng backend lưu UTC.
# Tạm thời query range rộng (start 00:00 -> end 23:59:59)
# Helper to get begin of day and end of day
begin_of_start_day
=
datetime
.
combine
(
dt_start
,
time
.
min
)
.
replace
(
tzinfo
=
timezone
.
utc
)
end_of_end_day
=
datetime
.
combine
(
dt_end
,
time
.
max
)
.
replace
(
tzinfo
=
timezone
.
utc
)
query
[
"created_at"
]
=
{
"$gte"
:
begin_of_start_day
,
"$lte"
:
end_of_end_day
}
except
ValueError
as
e
:
except
ValueError
as
e
:
return
json
.
dumps
({
return
json
.
dumps
({
"status"
:
"error"
,
"status"
:
"error"
,
"message"
:
f
"Invalid date format. Use YYYY-MM-DD. Error: {e}"
"message"
:
f
"Invalid date format. Use YYYY-MM-DD. Error: {e}"
},
ensure_ascii
=
False
)
},
ensure_ascii
=
False
)
# 2. Content Search - Use Vector Search if content_search provided
# ──────────────────────────────────────────────
memo_ids_from_vector_search
=
[]
# Step 2: Query MongoDB FIRST (date + tag filter)
if
content_search
:
# → Narrow down memos before any semantic search
# Use vector search for semantic understanding
# ──────────────────────────────────────────────
try
:
base_query
:
dict
[
str
,
Any
]
=
{
api_key
=
os
.
getenv
(
"OPENAI_API_KEY"
)
"created_at"
:
{
if
api_key
:
"$gte"
:
begin_of_start_day
,
logger
.
info
(
f
"🔍 Using vector search for: '{content_search}'"
)
"$lte"
:
end_of_end_day
,
embedder
=
OpenAIEmbeddings
(
}
model
=
"text-embedding-3-small"
,
}
api_key
=
SecretStr
(
api_key
),
# Filter by creator_id — only return current user's memos
)
if
user_id
:
# Use async embedding to avoid blocking the event loop
base_query
[
"creator_id"
]
=
user_id
query_embedding
=
await
embedder
.
aembed_query
(
content_search
)
# Search embeddings collection
import
numpy
as
np
cursor_emb
=
mongodb_client
.
memo_embeddings
.
find
({})
emb_docs
=
await
cursor_emb
.
to_list
(
length
=
1000
)
results
=
[]
q
=
np
.
array
(
query_embedding
,
dtype
=
float
)
for
emb_doc
in
emb_docs
:
emb
=
emb_doc
.
get
(
"embedding"
,
[])
if
not
emb
:
continue
v
=
np
.
array
(
emb
,
dtype
=
float
)
if
v
.
shape
!=
q
.
shape
:
continue
# Cosine similarity
denom
=
np
.
linalg
.
norm
(
q
)
*
np
.
linalg
.
norm
(
v
)
sim
=
float
(
np
.
dot
(
q
,
v
)
/
denom
)
if
denom
!=
0
else
0.0
if
sim
>
0.5
:
# Threshold for relevance
results
.
append
({
"memo_id"
:
emb_doc
.
get
(
"memo_id"
),
"score"
:
sim
,
})
# Sort by similarity score
results
.
sort
(
key
=
lambda
r
:
r
[
"score"
],
reverse
=
True
)
memo_ids_from_vector_search
=
[
r
[
"memo_id"
]
for
r
in
results
[:
20
]]
logger
.
info
(
f
"✅ Vector search found {len(memo_ids_from_vector_search)} memos"
)
else
:
logger
.
warning
(
"⚠️ OPENAI_API_KEY not set, falling back to regex search"
)
# Fallback to regex if no API key
query
[
"content"
]
=
{
"$regex"
:
content_search
,
"$options"
:
"i"
}
except
Exception
as
e
:
logger
.
warning
(
f
"⚠️ Vector search failed: {e}, falling back to regex"
)
# Fallback to regex on error
query
[
"content"
]
=
{
"$regex"
:
content_search
,
"$options"
:
"i"
}
else
:
# No content search, use regex fallback if needed
pass
# 3. Tag Filter
if
tag
:
if
tag
:
# Tags trong mongo lưu là array ["tag1", "tag2"]
base_query
[
"payload.tags"
]
=
tag
# Query exact match trong array
query
[
"payload.tags"
]
=
tag
# If NO content_search → just run date/tag query directly
if
not
content_search
:
# 4. Execute Query
logger
.
info
(
"MongoDB Query (date-only):
%
s"
,
base_query
)
if
memo_ids_from_vector_search
:
cursor
=
mongodb_client
.
memos
.
find
(
base_query
)
.
sort
(
"created_at"
,
-
1
)
.
limit
(
20
)
# Use vector search results + date filter
docs
=
await
cursor
.
to_list
(
length
=
20
)
# Convert string IDs to ObjectId
object_ids
:
list
[
ObjectId
]
=
[]
# If content_search with short keywords (≤2 words) → regex on MongoDB
for
mid
in
memo_ids_from_vector_search
:
elif
len
(
content_search
.
strip
()
.
split
())
<=
2
or
not
_get_embedder
():
try
:
if
not
_get_embedder
():
if
ObjectId
.
is_valid
(
mid
):
logger
.
warning
(
"⚠️ OPENAI_API_KEY not set, using regex search"
)
object_ids
.
append
(
ObjectId
(
mid
))
logger
.
info
(
f
"🔍 Using regex search for: '{content_search}'"
)
except
Exception
:
# pragma: no cover - defensive
base_query
[
"content"
]
=
{
"$regex"
:
content_search
,
"$options"
:
"i"
}
continue
logger
.
info
(
"MongoDB Query (date+regex):
%
s"
,
base_query
)
cursor
=
mongodb_client
.
memos
.
find
(
base_query
)
.
sort
(
"created_at"
,
-
1
)
.
limit
(
20
)
if
not
object_ids
:
docs
=
await
cursor
.
to_list
(
length
=
20
)
# Vector search said there were matches but all IDs were invalid.
# To avoid returning a broad set of memos by date/tag only,
# If content_search with longer queries → date query FIRST, then vector re-rank
# short‑circuit and return an empty result set.
logger
.
warning
(
"Vector search returned only invalid memo IDs. "
"Returning empty result instead of broad date/tag query."
)
empty_payload
=
{
"status"
:
"success"
,
"query"
:
{
"start_date"
:
start_date
,
"end_date"
:
end_date
or
start_date
,
"content_search"
:
content_search
,
"tag"
:
tag
,
},
"count"
:
0
,
"memos"
:
[],
}
return
json
.
dumps
(
empty_payload
,
ensure_ascii
=
False
)
query
[
"_id"
]
=
{
"$in"
:
object_ids
}
logger
.
info
(
"MongoDB Query (with vector search):
%
s"
,
query
)
else
:
else
:
logger
.
info
(
"MongoDB Query:
%
s"
,
query
)
# Step 2a: Get all memos in date range (+ tag) from MongoDB
logger
.
info
(
"MongoDB Pre-filter Query:
%
s"
,
base_query
)
cursor
=
mongodb_client
.
memos
.
find
(
query
)
.
sort
(
"created_at"
,
-
1
)
.
limit
(
20
)
cursor
=
mongodb_client
.
memos
.
find
(
base_query
)
.
sort
(
"created_at"
,
-
1
)
.
limit
(
100
)
docs
=
await
cursor
.
to_list
(
length
=
20
)
candidate_docs
=
await
cursor
.
to_list
(
length
=
100
)
logger
.
info
(
f
"📋 Pre-filter found {len(candidate_docs)} memos in date range"
)
# 5. Format Result
memos
=
[]
if
not
candidate_docs
:
for
doc
in
docs
:
docs
=
[]
memos
.
append
(
serialize_doc
(
doc
))
else
:
# Step 2b: Get embeddings ONLY for these candidate memos
candidate_ids
=
[
str
(
doc
[
"_id"
])
for
doc
in
candidate_docs
]
all_embeddings
=
await
_get_cached_embeddings
()
# Filter embeddings to only candidates
candidate_emb_map
=
{}
for
emb_doc
in
all_embeddings
:
mid
=
str
(
emb_doc
.
get
(
"memo_id"
,
""
))
if
mid
in
candidate_ids
and
emb_doc
.
get
(
"embedding"
):
candidate_emb_map
[
mid
]
=
emb_doc
[
"embedding"
]
logger
.
info
(
f
"🧠 Found {len(candidate_emb_map)} embeddings for candidates"
)
if
not
candidate_emb_map
:
# No embeddings for these memos → fallback to regex
logger
.
info
(
"No embeddings found, falling back to regex"
)
base_query
[
"content"
]
=
{
"$regex"
:
content_search
,
"$options"
:
"i"
}
cursor
=
mongodb_client
.
memos
.
find
(
base_query
)
.
sort
(
"created_at"
,
-
1
)
.
limit
(
20
)
docs
=
await
cursor
.
to_list
(
length
=
20
)
else
:
# Step 2c: Semantic re-rank using vector similarity
try
:
embedder
=
_get_embedder
()
query_embedding
=
await
embedder
.
aembed_query
(
content_search
)
import
numpy
as
np
q
=
np
.
array
(
query_embedding
,
dtype
=
float
)
scored
:
list
[
tuple
[
str
,
float
]]
=
[]
for
mid
,
emb
in
candidate_emb_map
.
items
():
v
=
np
.
array
(
emb
,
dtype
=
float
)
if
v
.
shape
!=
q
.
shape
:
continue
denom
=
np
.
linalg
.
norm
(
q
)
*
np
.
linalg
.
norm
(
v
)
sim
=
float
(
np
.
dot
(
q
,
v
)
/
denom
)
if
denom
!=
0
else
0.0
if
sim
>
0.4
:
scored
.
append
((
mid
,
sim
))
scored
.
sort
(
key
=
lambda
x
:
x
[
1
],
reverse
=
True
)
top_ids
=
{
mid
for
mid
,
_
in
scored
[:
20
]}
logger
.
info
(
f
"✅ Semantic re-rank: {len(top_ids)} memos above threshold"
)
# Re-order candidate_docs by semantic score
docs
=
[
d
for
d
in
candidate_docs
if
str
(
d
[
"_id"
])
in
top_ids
]
except
Exception
as
e
:
logger
.
warning
(
f
"⚠️ Vector search failed: {e}, falling back to regex"
)
base_query
[
"content"
]
=
{
"$regex"
:
content_search
,
"$options"
:
"i"
}
cursor
=
mongodb_client
.
memos
.
find
(
base_query
)
.
sort
(
"created_at"
,
-
1
)
.
limit
(
20
)
docs
=
await
cursor
.
to_list
(
length
=
20
)
# ──────────────────────────────────────────────
# Step 3: Format Result
# ──────────────────────────────────────────────
memos
=
[
serialize_doc
(
doc
)
for
doc
in
docs
]
return
json
.
dumps
(
return
json
.
dumps
(
{
{
...
@@ -205,7 +214,7 @@ async def memo_retrieval_tool(
...
@@ -205,7 +214,7 @@ async def memo_retrieval_tool(
"start_date"
:
start_date
,
"start_date"
:
start_date
,
"end_date"
:
end_date
or
start_date
,
"end_date"
:
end_date
or
start_date
,
"content_search"
:
content_search
,
"content_search"
:
content_search
,
"tag"
:
tag
"tag"
:
tag
,
},
},
"count"
:
len
(
memos
),
"count"
:
len
(
memos
),
"memos"
:
memos
,
"memos"
:
memos
,
...
...
backend/api/chatbot/chat_route.py
View file @
2fec4891
"""
"""
Chatbot API Route
Chatbot API Route
-----------------
-----------------
`POST /api/agent/chat` - chính là endpoint chat cho CiCi Assistant.
`POST /api/agent/chat` - endpoint chat cho CuCu Assistant (non-streaming).
Logic xử lý nằm ở `agent.controller.chat_controller`.
`POST /api/agent/chat/stream` - endpoint chat SSE streaming.
Logic xử lý nằm ở `agent.controller`.
"""
"""
import
logging
import
logging
from
dataclasses
import
dataclass
from
dataclasses
import
dataclass
from
typing
import
AsyncGenerator
from
fastapi
import
APIRouter
,
BackgroundTasks
,
HTTPException
,
Request
from
fastapi
import
APIRouter
,
BackgroundTasks
,
HTTPException
,
Request
from
fastapi.responses
import
StreamingResponse
from
opentelemetry
import
trace
from
opentelemetry
import
trace
from
agent.controller
import
chat_controller
# Lazy imports - defer heavy AI modules to first use
from
agent.models
import
QueryRequest
# from agent.controller import chat_controller, chat_controller_stream
# from agent.models import QueryRequest
from
common.message_limit
import
message_limit_service
from
common.message_limit
import
message_limit_service
from
config
import
DEFAULT_MODEL
from
config
import
DEFAULT_MODEL
...
@@ -20,6 +24,26 @@ logger = logging.getLogger(__name__)
...
@@ -20,6 +24,26 @@ logger = logging.getLogger(__name__)
tracer
=
trace
.
get_tracer
(
__name__
)
tracer
=
trace
.
get_tracer
(
__name__
)
router
=
APIRouter
()
router
=
APIRouter
()
# Cache for lazy-loaded modules
_agent_modules
=
{}
def
_get_agent_controller
():
"""Lazy-load agent.controller to defer LangChain/LangGraph import."""
if
"controller"
not
in
_agent_modules
:
from
agent.controller
import
chat_controller
,
chat_controller_stream
_agent_modules
[
"controller"
]
=
chat_controller
_agent_modules
[
"controller_stream"
]
=
chat_controller_stream
return
_agent_modules
[
"controller"
],
_agent_modules
[
"controller_stream"
]
def
_get_query_request_model
():
"""Lazy-load agent.models.QueryRequest."""
if
"QueryRequest"
not
in
_agent_modules
:
from
agent.models
import
QueryRequest
_agent_modules
[
"QueryRequest"
]
=
QueryRequest
return
_agent_modules
[
"QueryRequest"
]
@
dataclass
@
dataclass
class
Identity
:
class
Identity
:
...
@@ -41,7 +65,6 @@ def _get_identity(request: Request) -> Identity:
...
@@ -41,7 +65,6 @@ def _get_identity(request: Request) -> Identity:
history_key
=
primary_id
history_key
=
primary_id
rate_limit_key
=
primary_id
rate_limit_key
=
primary_id
else
:
else
:
# Guest: dùng device_id, fallback 'anonymous'
primary_id
=
device_id
or
"anonymous"
primary_id
=
device_id
or
"anonymous"
history_key
=
device_id
or
"anonymous"
history_key
=
device_id
or
"anonymous"
rate_limit_key
=
device_id
or
"anonymous"
rate_limit_key
=
device_id
or
"anonymous"
...
@@ -54,38 +77,35 @@ def _get_identity(request: Request) -> Identity:
...
@@ -54,38 +77,35 @@ def _get_identity(request: Request) -> Identity:
)
)
@
router
.
post
(
"/api/agent/chat"
,
summary
=
"Chat with CiCi Assistant"
)
@
router
.
post
(
"/api/agent/chat"
,
summary
=
"Chat with CuCu Assistant"
)
async
def
cici_chat
(
request
:
Request
,
req
:
QueryRequest
,
background_tasks
:
BackgroundTasks
):
async
def
cucu_chat
(
request
:
Request
,
background_tasks
:
BackgroundTasks
):
"""
"""Endpoint chat không stream - trả về response JSON đầy đủ một lần."""
Endpoint chat không stream - trả về response JSON đầy đủ một lần.
# Lazy-load AI modules on first call
QueryRequest
=
_get_query_request_model
()
chat_controller
,
_
=
_get_agent_controller
()
body
=
await
request
.
json
()
req
=
QueryRequest
(
**
body
)
- Tự lấy user/device từ middleware (`get_user_identity`)
- Gọi `chat_controller` để xử lý toàn bộ logic LLM + tools
- Tự tăng counter rate limit sau khi trả lời xong
"""
# 1. Xác định identity
identity
=
_get_identity
(
request
)
identity
=
_get_identity
(
request
)
user_id
=
identity
.
primary_id
user_id
=
identity
.
primary_id
logger
.
info
(
"📥 [Incoming Chat] User=
%
s | Query=
%
s"
,
user_id
,
req
.
user_query
)
logger
.
info
(
"📥 [Incoming Chat] User=
%
s | Query=
%
s"
,
user_id
,
req
.
user_query
)
# Span cho tracing (optional)
span
=
trace
.
get_current_span
()
span
=
trace
.
get_current_span
()
span
.
set_attribute
(
"user.id"
,
user_id
)
span
.
set_attribute
(
"user.id"
,
user_id
)
span
.
set_attribute
(
"chat.user_query"
,
req
.
user_query
)
span
.
set_attribute
(
"chat.user_query"
,
req
.
user_query
)
try
:
try
:
# 2. Gọi controller xử lý
result
=
await
chat_controller
(
result
=
await
chat_controller
(
query
=
req
.
user_query
,
query
=
req
.
user_query
,
user_id
=
user_id
,
user_id
=
user_id
,
background_tasks
=
background_tasks
,
background_tasks
=
background_tasks
,
model_name
=
DEFAULT_MODEL
,
model_name
=
DEFAULT_MODEL
,
images
=
req
.
images
,
images
=
req
.
images
,
identity_key
=
identity
.
history_key
,
# Guest: device_id, User: user_id
identity_key
=
identity
.
history_key
,
)
)
# 3. Tăng usage info (rate limit) sau khi thành công
usage_info
=
await
message_limit_service
.
increment
(
usage_info
=
await
message_limit_service
.
increment
(
identity_key
=
identity
.
rate_limit_key
,
identity_key
=
identity
.
rate_limit_key
,
is_authenticated
=
identity
.
is_authenticated
,
is_authenticated
=
identity
.
is_authenticated
,
...
@@ -104,7 +124,58 @@ async def cici_chat(request: Request, req: QueryRequest, background_tasks: Backg
...
@@ -104,7 +124,58 @@ async def cici_chat(request: Request, req: QueryRequest, background_tasks: Backg
},
},
}
}
except
Exception
as
e
:
except
Exception
as
e
:
logger
.
error
(
"Error in c
ici
_chat:
%
s"
,
e
,
exc_info
=
True
)
logger
.
error
(
"Error in c
ucu
_chat:
%
s"
,
e
,
exc_info
=
True
)
raise
HTTPException
(
status_code
=
500
,
detail
=
str
(
e
))
from
e
raise
HTTPException
(
status_code
=
500
,
detail
=
str
(
e
))
from
e
@
router
.
post
(
"/api/agent/chat/stream"
,
summary
=
"Chat with CuCu Assistant (SSE Streaming)"
)
async
def
cucu_chat_stream
(
request
:
Request
):
"""
Endpoint chat SSE streaming — trả về token-by-token qua Server-Sent Events.
SSE format:
data: {"token": "partial"}
\n\n
— mỗi token chunk
data: {"done": true, "ai_response": "full text"}
\n\n
— kết thúc
"""
# Lazy-load AI modules on first call
QueryRequest
=
_get_query_request_model
()
_
,
chat_controller_stream
=
_get_agent_controller
()
body
=
await
request
.
json
()
req
=
QueryRequest
(
**
body
)
identity
=
_get_identity
(
request
)
user_id
=
identity
.
primary_id
logger
.
info
(
"📥 [Incoming Stream] User=
%
s | Query=
%
s"
,
user_id
,
req
.
user_query
)
async
def
sse_generator
()
->
AsyncGenerator
[
str
,
None
]:
try
:
async
for
chunk_json
in
chat_controller_stream
(
query
=
req
.
user_query
,
user_id
=
user_id
,
model_name
=
DEFAULT_MODEL
,
images
=
req
.
images
,
identity_key
=
identity
.
history_key
,
):
yield
f
"data: {chunk_json}
\n\n
"
# Increment rate limit after stream completes
await
message_limit_service
.
increment
(
identity_key
=
identity
.
rate_limit_key
,
is_authenticated
=
identity
.
is_authenticated
,
)
logger
.
info
(
"📤 [Stream Done] User=
%
s"
,
user_id
)
except
Exception
as
e
:
logger
.
error
(
"Error in stream:
%
s"
,
e
,
exc_info
=
True
)
yield
f
'data: {{"error": "{str(e)}"}}
\n\n
'
return
StreamingResponse
(
sse_generator
(),
media_type
=
"text/event-stream"
,
headers
=
{
"Cache-Control"
:
"no-cache"
,
"Connection"
:
"keep-alive"
,
"X-Accel-Buffering"
:
"no"
,
},
)
backend/api/chatbot/history_route.py
View file @
2fec4891
...
@@ -30,6 +30,14 @@ class ClearHistoryResponse(BaseModel):
...
@@ -30,6 +30,14 @@ class ClearHistoryResponse(BaseModel):
message
:
str
message
:
str
@
router
.
get
(
"/api/history/me"
,
summary
=
"Get My Chat History"
,
response_model
=
ChatHistoryResponse
)
async
def
get_my_chat_history
(
request
:
Request
,
limit
:
int
|
None
=
50
,
before_id
:
int
|
None
=
None
):
"""
Shortcut: lấy lịch sử chat của chính mình (identity từ middleware).
"""
return
await
get_chat_history
(
request
,
identity_key
=
"me"
,
limit
=
limit
,
before_id
=
before_id
)
@
router
.
get
(
"/api/history/{identity_key}"
,
summary
=
"Get Chat History"
,
response_model
=
ChatHistoryResponse
)
@
router
.
get
(
"/api/history/{identity_key}"
,
summary
=
"Get Chat History"
,
response_model
=
ChatHistoryResponse
)
async
def
get_chat_history
(
request
:
Request
,
identity_key
:
str
,
limit
:
int
|
None
=
50
,
before_id
:
int
|
None
=
None
):
async
def
get_chat_history
(
request
:
Request
,
identity_key
:
str
,
limit
:
int
|
None
=
50
,
before_id
:
int
|
None
=
None
):
"""
"""
...
@@ -59,6 +67,30 @@ async def get_chat_history(request: Request, identity_key: str, limit: int | Non
...
@@ -59,6 +67,30 @@ async def get_chat_history(request: Request, identity_key: str, limit: int | Non
raise
HTTPException
(
status_code
=
500
,
detail
=
"Failed to fetch chat history"
)
raise
HTTPException
(
status_code
=
500
,
detail
=
"Failed to fetch chat history"
)
@
router
.
delete
(
"/api/history/me"
,
summary
=
"Clear My Chat History"
,
response_model
=
ClearHistoryResponse
)
async
def
clear_my_chat_history
(
request
:
Request
):
"""
Shortcut: xóa lịch sử chat của chính mình (identity từ middleware).
"""
try
:
user_id
=
getattr
(
request
.
state
,
"user_id"
,
None
)
device_id
=
getattr
(
request
.
state
,
"device_id"
,
""
)
or
""
is_authenticated
=
bool
(
getattr
(
request
.
state
,
"is_authenticated"
,
False
))
if
is_authenticated
and
user_id
:
resolved_key
=
str
(
user_id
)
else
:
resolved_key
=
device_id
or
"anonymous"
manager
=
await
get_conversation_manager
()
await
manager
.
clear_history
(
resolved_key
)
logger
.
info
(
"✅ Cleared chat history for
%
s"
,
resolved_key
)
return
{
"success"
:
True
,
"message"
:
f
"Đã xóa lịch sử chat"
}
except
Exception
as
e
:
logger
.
error
(
"Error clearing chat history:
%
s"
,
e
,
exc_info
=
True
)
raise
HTTPException
(
status_code
=
500
,
detail
=
"Failed to clear chat history"
)
@
router
.
delete
(
"/api/history/{identity_key}"
,
summary
=
"Clear Chat History"
,
response_model
=
ClearHistoryResponse
)
@
router
.
delete
(
"/api/history/{identity_key}"
,
summary
=
"Clear Chat History"
,
response_model
=
ClearHistoryResponse
)
async
def
clear_chat_history
(
identity_key
:
str
):
async
def
clear_chat_history
(
identity_key
:
str
):
"""
"""
...
...
backend/api/memos/memo_routes.py
View file @
2fec4891
...
@@ -55,12 +55,25 @@ async def list_memos(
...
@@ -55,12 +55,25 @@ async def list_memos(
filter_str
=
filter
,
filter_str
=
filter
,
)
)
# Parse creator_id from filter string (e.g. "creator_id == user_xxx")
creator_id
=
None
if
filter
:
if
filter
:
logger
.
debug
(
"List memos GET with filter=
%
r"
,
filter
)
logger
.
debug
(
"List memos GET with filter=
%
r"
,
filter
)
pattern_creator
=
r"creator_id\s*==\s*([a-zA-Z0-9_\-\.]+)"
match_creator
=
re
.
search
(
pattern_creator
,
filter
)
if
match_creator
:
creator_id
=
match_creator
.
group
(
1
)
# Parse pinned from filter string (e.g. "... && pinned")
pinned
=
None
if
filter
and
re
.
search
(
r'\bpinned\b(?!\s*==\s*false)'
,
filter
):
pinned
=
True
return
await
memo_service
.
list_memos
(
return
await
memo_service
.
list_memos
(
user_id
=
user_id
,
user_id
=
user_id
,
creator_id
=
creator_id
,
tag
=
tag
,
tag
=
tag
,
pinned
=
pinned
,
row_status
=
row_status
,
row_status
=
row_status
,
start_date
=
dt_start
,
start_date
=
dt_start
,
end_date
=
dt_end
end_date
=
dt_end
...
@@ -118,10 +131,18 @@ async def create_memo_or_list_memos(
...
@@ -118,10 +131,18 @@ async def create_memo_or_list_memos(
if
match_creator
:
if
match_creator
:
creator_id
=
match_creator
.
group
(
1
)
creator_id
=
match_creator
.
group
(
1
)
# Parse pinned filter — frontend sends "pinned" in filter string for bookmarks
pinned
=
None
if
raw_filter
and
isinstance
(
raw_filter
,
str
):
# Match standalone "pinned" (not "pinned == false")
if
re
.
search
(
r'\bpinned\b(?!\s*==\s*false)'
,
raw_filter
):
pinned
=
True
return
await
memo_service
.
list_memos
(
return
await
memo_service
.
list_memos
(
user_id
=
user_id
,
user_id
=
user_id
,
creator_id
=
creator_id
,
creator_id
=
creator_id
,
tag
=
tag
,
tag
=
tag
,
pinned
=
pinned
,
start_date
=
start_date
,
start_date
=
start_date
,
end_date
=
end_date
end_date
=
end_date
)
)
...
...
backend/api/memos/shortcut_routes.py
View file @
2fec4891
...
@@ -4,13 +4,12 @@ Shortcut service routes for Memos-style backend.
...
@@ -4,13 +4,12 @@ Shortcut service routes for Memos-style backend.
from
typing
import
List
from
typing
import
List
from
fastapi
import
APIRouter
,
Body
,
Depends
,
HTTPException
from
fastapi
import
APIRouter
,
Depends
,
HTTPException
from
common.memos_core.schemas
import
(
from
common.memos_core.schemas
import
(
ShortcutCreate
,
ShortcutCreate
,
ShortcutUpdate
,
ShortcutUpdate
,
ShortcutResponse
,
ShortcutResponse
,
ListShortcutsResponse
,
)
)
from
common.memos_core.services
import
get_shortcut_service
from
common.memos_core.services
import
get_shortcut_service
...
@@ -26,20 +25,6 @@ async def list_shortcuts(shortcut_service=Depends(get_shortcut_service)):
...
@@ -26,20 +25,6 @@ async def list_shortcuts(shortcut_service=Depends(get_shortcut_service)):
raise
HTTPException
(
status_code
=
500
,
detail
=
str
(
exc
))
from
exc
raise
HTTPException
(
status_code
=
500
,
detail
=
str
(
exc
))
from
exc
@
router
.
post
(
""
,
summary
=
"List shortcuts (Connect compatibility)"
,
response_model
=
ListShortcutsResponse
)
async
def
list_shortcuts_connect_compat
(
payload
:
dict
=
Body
(
default_factory
=
dict
),
# noqa: B008
shortcut_service
=
Depends
(
get_shortcut_service
),
):
# Connect RPC ListShortcuts is proxied as POST /api/v1/shortcuts in dev.
# Ignore payload (parent, pagination...) and return empty list for now.
try
:
_
=
payload
shortcuts
=
await
shortcut_service
.
list_shortcuts
()
return
ListShortcutsResponse
(
shortcuts
=
shortcuts
)
except
Exception
as
exc
:
# pragma: no cover
raise
HTTPException
(
status_code
=
500
,
detail
=
str
(
exc
))
from
exc
@
router
.
post
(
""
,
summary
=
"Create shortcut"
,
response_model
=
ShortcutResponse
)
@
router
.
post
(
""
,
summary
=
"Create shortcut"
,
response_model
=
ShortcutResponse
)
async
def
create_shortcut
(
async
def
create_shortcut
(
...
@@ -54,7 +39,7 @@ async def create_shortcut(
...
@@ -54,7 +39,7 @@ async def create_shortcut(
@
router
.
patch
(
"/{shortcut_id}"
,
summary
=
"Update shortcut"
,
response_model
=
ShortcutResponse
)
@
router
.
patch
(
"/{shortcut_id}"
,
summary
=
"Update shortcut"
,
response_model
=
ShortcutResponse
)
async
def
update_shortcut
(
async
def
update_shortcut
(
shortcut_id
:
int
,
shortcut_id
:
str
,
payload
:
ShortcutUpdate
,
payload
:
ShortcutUpdate
,
shortcut_service
=
Depends
(
get_shortcut_service
),
shortcut_service
=
Depends
(
get_shortcut_service
),
):
):
...
@@ -66,7 +51,7 @@ async def update_shortcut(
...
@@ -66,7 +51,7 @@ async def update_shortcut(
@
router
.
delete
(
"/{shortcut_id}"
,
summary
=
"Delete shortcut"
)
@
router
.
delete
(
"/{shortcut_id}"
,
summary
=
"Delete shortcut"
)
async
def
delete_shortcut
(
async
def
delete_shortcut
(
shortcut_id
:
int
,
shortcut_id
:
str
,
shortcut_service
=
Depends
(
get_shortcut_service
),
shortcut_service
=
Depends
(
get_shortcut_service
),
):
):
try
:
try
:
...
...
backend/api/test_chat_route.py
0 → 100644
View file @
2fec4891
"""
Test Chat Route - NO AUTH REQUIRED
------------------------------------
`POST /api/test/chat` - endpoint thô để kiểm tra AI API key còn sống không.
Không dùng graph, không dùng tools, không dùng history.
Gọi thẳng OpenAI / Gemini / Groq tùy DEFAULT_MODEL.
Dùng để debug: nếu endpoint này work → vấn đề ở auth/middleware.
nếu không work → vấn đề API key hoặc model.
"""
import
logging
import
time
from
fastapi
import
APIRouter
from
pydantic
import
BaseModel
from
config
import
DEFAULT_MODEL
,
GOOGLE_API_KEY
,
GROQ_API_KEY
,
OPENAI_API_KEY
logger
=
logging
.
getLogger
(
__name__
)
router
=
APIRouter
(
tags
=
[
"test"
])
class
TestChatRequest
(
BaseModel
):
message
:
str
model
:
str
|
None
=
None
# override DEFAULT_MODEL nếu muốn
class
TestChatResponse
(
BaseModel
):
status
:
str
model_used
:
str
response
:
str
latency_ms
:
int
@
router
.
post
(
"/api/test/chat"
,
response_model
=
TestChatResponse
,
summary
=
"[NO AUTH] Test AI API key trực tiếp"
,
description
=
(
"Endpoint debug - gọi thẳng LLM không qua graph/tools/auth. "
"Dùng để kiểm tra API key còn sống và model đang hoạt động."
),
)
async
def
test_chat
(
req
:
TestChatRequest
)
->
TestChatResponse
:
model_name
=
req
.
model
or
DEFAULT_MODEL
logger
.
info
(
"[TEST CHAT] model=
%
s | msg=
%
s"
,
model_name
,
req
.
message
)
t0
=
time
.
monotonic
()
try
:
response_text
=
await
_call_llm
(
model_name
=
model_name
,
message
=
req
.
message
)
except
Exception
as
exc
:
logger
.
error
(
"[TEST CHAT] LLM error:
%
s"
,
exc
,
exc_info
=
True
)
return
TestChatResponse
(
status
=
"error"
,
model_used
=
model_name
,
response
=
f
"LỖI: {type(exc).__name__}: {exc}"
,
latency_ms
=
int
((
time
.
monotonic
()
-
t0
)
*
1000
),
)
latency
=
int
((
time
.
monotonic
()
-
t0
)
*
1000
)
logger
.
info
(
"[TEST CHAT] OK latency=
%
dms"
,
latency
)
return
TestChatResponse
(
status
=
"ok"
,
model_used
=
model_name
,
response
=
response_text
,
latency_ms
=
latency
,
)
async
def
_call_llm
(
model_name
:
str
,
message
:
str
)
->
str
:
"""Gọi LLM tương ứng với model_name, trả về string response."""
# ── Gemini / Google ──────────────────────────────────────────────────────
if
model_name
.
startswith
(
"gemini"
):
from
langchain_google_genai
import
ChatGoogleGenerativeAI
llm
=
ChatGoogleGenerativeAI
(
model
=
model_name
,
google_api_key
=
GOOGLE_API_KEY
)
result
=
await
llm
.
ainvoke
(
message
)
return
result
.
content
# ── Groq ─────────────────────────────────────────────────────────────────
if
model_name
.
startswith
(
"llama"
)
or
model_name
.
startswith
(
"mixtral"
)
or
"groq"
in
model_name
:
from
langchain_groq
import
ChatGroq
llm
=
ChatGroq
(
model
=
model_name
,
groq_api_key
=
GROQ_API_KEY
)
result
=
await
llm
.
ainvoke
(
message
)
return
result
.
content
# ── OpenAI (default) ─────────────────────────────────────────────────────
from
langchain_openai
import
ChatOpenAI
llm
=
ChatOpenAI
(
model
=
model_name
,
openai_api_key
=
OPENAI_API_KEY
)
result
=
await
llm
.
ainvoke
(
message
)
return
result
.
content
backend/common/clerk_auth.py
View file @
2fec4891
...
@@ -35,13 +35,13 @@ def verify_clerk_jwt(token: str) -> dict[str, Any]:
...
@@ -35,13 +35,13 @@ def verify_clerk_jwt(token: str) -> dict[str, Any]:
signing_key
=
_jwks_client
()
.
get_signing_key_from_jwt
(
token
)
.
key
signing_key
=
_jwks_client
()
.
get_signing_key_from_jwt
(
token
)
.
key
# Clerk tokens are typically RS256.
# Clerk tokens are typically RS256.
# leeway=
60 tolerates up to 60s clock skew between Clerk server and this machine
# leeway=
300 tolerates up to 5min clock skew (VPS clock not NTP-synced)
payload
=
jwt
.
decode
(
payload
=
jwt
.
decode
(
token
,
token
,
signing_key
,
signing_key
,
algorithms
=
[
"RS256"
],
algorithms
=
[
"RS256"
],
issuer
=
CLERK_ISSUER
,
issuer
=
CLERK_ISSUER
,
leeway
=
6
0
,
leeway
=
30
0
,
options
=
{
options
=
{
"verify_aud"
:
False
,
# allow multiple audiences in dev
"verify_aud"
:
False
,
# allow multiple audiences in dev
},
},
...
...
backend/common/conversation_manager.py
View file @
2fec4891
import
asyncio
import
json
import
json
import
logging
import
logging
import
os
from
datetime
import
datetime
,
timezone
from
datetime
import
datetime
,
date
from
typing
import
Any
from
typing
import
Any
import
aiosqlite
from
common.mongo_client
import
mongodb_client
,
COLLECTION_INBOX
,
utc_now
import
psycopg
from
psycopg_pool
import
AsyncConnectionPool
from
config
import
CHECKPOINT_POSTGRES_URL
,
SQLITE_DB_PATH
,
USE_SQLITE_HISTORY
logger
=
logging
.
getLogger
(
__name__
)
logger
=
logging
.
getLogger
(
__name__
)
class
ConversationManager
:
def
__init__
(
self
,
connection_url
:
str
=
CHECKPOINT_POSTGRES_URL
,
table_name
:
str
=
"langgraph_chat_histories"
,
):
self
.
connection_url
=
connection_url
self
.
table_name
=
table_name
self
.
_pool
:
AsyncConnectionPool
|
None
=
None
async
def
_get_pool
(
self
)
->
AsyncConnectionPool
:
"""Get or create async connection pool."""
if
self
.
_pool
is
None
:
self
.
_pool
=
AsyncConnectionPool
(
self
.
connection_url
,
min_size
=
1
,
max_size
=
20
,
max_lifetime
=
600
,
# Recycle connections every 10 mins
max_idle
=
300
,
# Close idle connections after 5 mins
open
=
False
,
kwargs
=
{
"autocommit"
:
True
}
)
await
self
.
_pool
.
open
()
return
self
.
_pool
async
def
initialize_table
(
self
):
"""Create the chat history table if it doesn't exist"""
try
:
pool
=
await
self
.
_get_pool
()
async
with
pool
.
connection
()
as
conn
:
async
with
conn
.
cursor
()
as
cursor
:
await
cursor
.
execute
(
f
"""
CREATE TABLE IF NOT EXISTS {self.table_name} (
id SERIAL PRIMARY KEY,
identity_key VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
is_human BOOLEAN NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
await
cursor
.
execute
(
f
"""
CREATE INDEX IF NOT EXISTS idx_{self.table_name}_identity_timestamp
ON {self.table_name} (identity_key, timestamp)
"""
)
await
conn
.
commit
()
logger
.
info
(
f
"Table {self.table_name} initialized successfully"
)
except
Exception
as
e
:
logger
.
error
(
f
"Error initializing table: {e}"
)
raise
async
def
save_conversation_turn
(
self
,
identity_key
:
str
,
human_message
:
str
,
ai_message
:
str
):
"""Save both human and AI messages in a single atomic transaction with retry logic."""
max_retries
=
3
for
attempt
in
range
(
max_retries
):
try
:
pool
=
await
self
.
_get_pool
()
timestamp
=
datetime
.
now
()
async
with
pool
.
connection
()
as
conn
:
async
with
conn
.
cursor
()
as
cursor
:
await
cursor
.
execute
(
f
"""INSERT INTO {self.table_name} (identity_key, message, is_human, timestamp)
VALUES (
%
s,
%
s,
%
s,
%
s), (
%
s,
%
s,
%
s,
%
s)"""
,
(
identity_key
,
human_message
,
True
,
timestamp
,
identity_key
,
ai_message
,
False
,
timestamp
,
),
)
# With autocommit=True in pool, and context manager, transactions are handled.
# Explicit commit can be safer but might be redundant if autocommit is on.
# Let's keep existing logic but be mindful of autocommit.
# Actually if autocommit=True, we don't need conn.commit().
# But if we want atomic transaction for 2 inserts, we should NOT use autocommit=True for the pool globally,
# OR we start a transaction block.
# But psycopg3 connection `async with pool.connection() as conn` actually starts a transaction by default if autocommit is False.
# Let's revert pool autocommit=True and handle it normally which is safer for atomicity.
await
conn
.
commit
()
logger
.
debug
(
f
"Saved conversation turn for identity_key {identity_key}"
)
return
# Success
except
psycopg
.
OperationalError
as
e
:
logger
.
warning
(
f
"Database connection error (attempt {attempt+1}/{max_retries}): {e}"
)
if
attempt
==
max_retries
-
1
:
logger
.
error
(
f
"Failed to save conversation after {max_retries} attempts: {e}"
)
raise
await
asyncio
.
sleep
(
0.5
)
except
Exception
as
e
:
logger
.
error
(
f
"Failed to save conversation for identity_key {identity_key}: {e}"
,
exc_info
=
True
)
raise
async
def
get_chat_history
(
self
,
identity_key
:
str
,
limit
:
int
|
None
=
None
,
before_id
:
int
|
None
=
None
)
->
list
[
dict
[
str
,
Any
]]:
"""
Retrieve chat history for an identity (user_id or device_id) using cursor-based pagination.
AI messages được parse từ JSON string để lấy product_ids.
"""
max_retries
=
3
for
attempt
in
range
(
max_retries
):
try
:
query
=
f
"""
SELECT message, is_human, timestamp, id
FROM {self.table_name}
WHERE identity_key =
%
s
"""
params
=
[
identity_key
]
if
before_id
:
query
+=
" AND id <
%
s"
params
.
append
(
before_id
)
query
+=
" ORDER BY id DESC"
if
limit
:
query
+=
" LIMIT
%
s"
params
.
append
(
limit
)
pool
=
await
self
.
_get_pool
()
async
with
pool
.
connection
()
as
conn
,
conn
.
cursor
()
as
cursor
:
await
cursor
.
execute
(
query
,
tuple
(
params
))
results
=
await
cursor
.
fetchall
()
history
=
[]
for
row
in
results
:
message_content
=
row
[
0
]
is_human
=
row
[
1
]
entry
=
{
"is_human"
:
is_human
,
"timestamp"
:
row
[
2
],
"id"
:
row
[
3
],
}
if
is_human
:
# User message - text thuần
entry
[
"message"
]
=
message_content
else
:
# AI message - parse JSON để lấy ai_response + product_ids
try
:
parsed
=
json
.
loads
(
message_content
)
entry
[
"message"
]
=
parsed
.
get
(
"ai_response"
,
message_content
)
entry
[
"product_ids"
]
=
parsed
.
get
(
"product_ids"
,
[])
except
(
json
.
JSONDecodeError
,
TypeError
):
# Fallback nếu không phải JSON (data cũ)
entry
[
"message"
]
=
message_content
entry
[
"product_ids"
]
=
[]
history
.
append
(
entry
)
return
history
except
psycopg
.
OperationalError
as
e
:
logger
.
warning
(
f
"Database connection error in get_chat_history (attempt {attempt+1}/{max_retries}): {e}"
)
if
attempt
==
max_retries
-
1
:
logger
.
error
(
f
"Failed to get chat history after {max_retries} attempts: {e}"
)
raise
await
asyncio
.
sleep
(
0.5
)
except
Exception
as
e
:
logger
.
error
(
f
"Error retrieving chat history: {e}"
)
return
[]
async
def
clear_history
(
self
,
identity_key
:
str
):
"""Clear all chat history for an identity"""
try
:
pool
=
await
self
.
_get_pool
()
async
with
pool
.
connection
()
as
conn
:
async
with
conn
.
cursor
()
as
cursor
:
await
cursor
.
execute
(
f
"DELETE FROM {self.table_name} WHERE identity_key =
%
s"
,
(
identity_key
,))
await
conn
.
commit
()
logger
.
info
(
f
"Cleared chat history for identity_key {identity_key}"
)
except
Exception
as
e
:
logger
.
error
(
f
"Error clearing chat history: {e}"
)
async
def
get_user_count
(
self
)
->
int
:
"""Get total number of unique identities"""
try
:
pool
=
await
self
.
_get_pool
()
async
with
pool
.
connection
()
as
conn
,
conn
.
cursor
()
as
cursor
:
await
cursor
.
execute
(
f
"SELECT COUNT(DISTINCT identity_key) FROM {self.table_name}"
)
result
=
await
cursor
.
fetchone
()
return
result
[
0
]
if
result
else
0
except
Exception
as
e
:
logger
.
error
(
f
"Error getting user count: {e}"
)
return
0
async
def
get_message_count_today
(
self
,
identity_key
:
str
)
->
int
:
"""
Đếm số tin nhắn của identity trong ngày hôm nay (cho rate limiting).
Chỉ đếm human messages (is_human = true).
"""
try
:
pool
=
await
self
.
_get_pool
()
async
with
pool
.
connection
()
as
conn
,
conn
.
cursor
()
as
cursor
:
await
cursor
.
execute
(
f
"""
SELECT COUNT(*) FROM {self.table_name}
WHERE identity_key =
%
s
AND is_human = true
AND DATE(timestamp) = CURRENT_DATE
"""
,
(
identity_key
,),
)
result
=
await
cursor
.
fetchone
()
return
result
[
0
]
if
result
else
0
except
Exception
as
e
:
logger
.
error
(
f
"Error counting messages for {identity_key}: {e}"
)
return
0
async
def
close
(
self
):
"""Close the connection pool"""
if
self
.
_pool
:
await
self
.
_pool
.
close
()
class
SQLiteConversationManager
:
"""SQLite-based chat history storage for local/dev testing."""
def
__init__
(
self
,
db_path
:
str
=
SQLITE_DB_PATH
,
table_name
:
str
=
"langgraph_chat_histories"
):
self
.
db_path
=
db_path
self
.
table_name
=
table_name
db_dir
=
os
.
path
.
dirname
(
self
.
db_path
)
if
db_dir
:
os
.
makedirs
(
db_dir
,
exist_ok
=
True
)
async
def
initialize_table
(
self
):
"""Create table and index if not exists."""
try
:
async
with
aiosqlite
.
connect
(
self
.
db_path
)
as
db
:
await
db
.
execute
(
f
"""
CREATE TABLE IF NOT EXISTS {self.table_name} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
identity_key TEXT NOT NULL,
message TEXT NOT NULL,
is_human INTEGER NOT NULL,
timestamp TEXT DEFAULT (DATETIME('now'))
)
"""
)
await
db
.
execute
(
f
"""
CREATE INDEX IF NOT EXISTS idx_{self.table_name}_identity_timestamp
ON {self.table_name} (identity_key, timestamp)
"""
)
await
db
.
commit
()
logger
.
info
(
f
"SQLite table {self.table_name} initialized at {self.db_path}"
)
except
Exception
as
e
:
logger
.
error
(
f
"Error initializing SQLite table: {e}"
,
exc_info
=
True
)
raise
async
def
save_conversation_turn
(
self
,
identity_key
:
str
,
human_message
:
str
,
ai_message
:
str
):
"""Save both human and AI messages in a single transaction."""
timestamp
=
datetime
.
now
()
.
strftime
(
"
%
Y-
%
m-
%
d
%
H:
%
M:
%
S"
)
try
:
async
with
aiosqlite
.
connect
(
self
.
db_path
)
as
db
:
await
db
.
execute
(
f
"""INSERT INTO {self.table_name} (identity_key, message, is_human, timestamp)
VALUES (?, ?, ?, ?), (?, ?, ?, ?)"""
,
(
identity_key
,
human_message
,
1
,
timestamp
,
identity_key
,
ai_message
,
0
,
timestamp
,
),
)
await
db
.
commit
()
logger
.
debug
(
f
"Saved conversation turn for {identity_key} (SQLite)"
)
except
Exception
as
e
:
logger
.
error
(
f
"Failed to save conversation for {identity_key} (SQLite): {e}"
,
exc_info
=
True
)
raise
async
def
get_chat_history
(
self
,
identity_key
:
str
,
limit
:
int
|
None
=
None
,
before_id
:
int
|
None
=
None
)
->
list
[
dict
[
str
,
Any
]]:
"""Retrieve chat history with optional cursor pagination."""
try
:
query
=
f
"""
SELECT id, message, is_human, timestamp
FROM {self.table_name}
WHERE identity_key = ?
"""
params
:
list
[
Any
]
=
[
identity_key
]
if
before_id
:
query
+=
" AND id < ?"
params
.
append
(
before_id
)
query
+=
" ORDER BY id DESC"
if
limit
:
query
+=
" LIMIT ?"
params
.
append
(
limit
)
async
with
aiosqlite
.
connect
(
self
.
db_path
)
as
db
:
db
.
row_factory
=
aiosqlite
.
Row
async
with
db
.
execute
(
query
,
tuple
(
params
))
as
cursor
:
rows
=
await
cursor
.
fetchall
()
history
:
list
[
dict
[
str
,
Any
]]
=
[]
for
row
in
rows
:
message_content
=
row
[
"message"
]
is_human
=
bool
(
row
[
"is_human"
])
entry
=
{
"is_human"
:
is_human
,
"timestamp"
:
row
[
"timestamp"
],
"id"
:
row
[
"id"
],
}
if
is_human
:
entry
[
"message"
]
=
message_content
else
:
try
:
parsed
=
json
.
loads
(
message_content
)
entry
[
"message"
]
=
parsed
.
get
(
"ai_response"
,
message_content
)
entry
[
"product_ids"
]
=
parsed
.
get
(
"product_ids"
,
[])
except
(
json
.
JSONDecodeError
,
TypeError
):
entry
[
"message"
]
=
message_content
entry
[
"product_ids"
]
=
[]
history
.
append
(
entry
)
return
history
except
Exception
as
e
:
logger
.
error
(
f
"Error retrieving chat history (SQLite): {e}"
,
exc_info
=
True
)
return
[]
async
def
clear_history
(
self
,
identity_key
:
str
):
"""Clear all chat history for an identity."""
try
:
async
with
aiosqlite
.
connect
(
self
.
db_path
)
as
db
:
await
db
.
execute
(
f
"DELETE FROM {self.table_name} WHERE identity_key = ?"
,
(
identity_key
,))
await
db
.
commit
()
logger
.
info
(
f
"Cleared chat history for {identity_key} (SQLite)"
)
except
Exception
as
e
:
logger
.
error
(
f
"Error clearing chat history for {identity_key} (SQLite): {e}"
,
exc_info
=
True
)
async
def
get_user_count
(
self
)
->
int
:
"""Get total number of unique identities."""
try
:
async
with
aiosqlite
.
connect
(
self
.
db_path
)
as
db
:
async
with
db
.
execute
(
f
"SELECT COUNT(DISTINCT identity_key) FROM {self.table_name}"
)
as
cursor
:
row
=
await
cursor
.
fetchone
()
return
row
[
0
]
if
row
else
0
except
Exception
as
e
:
logger
.
error
(
f
"Error getting user count (SQLite): {e}"
,
exc_info
=
True
)
return
0
async
def
get_message_count_today
(
self
,
identity_key
:
str
)
->
int
:
"""Count user messages today (for potential rate limiting)."""
try
:
async
with
aiosqlite
.
connect
(
self
.
db_path
)
as
db
:
async
with
db
.
execute
(
f
"""
SELECT COUNT(*) FROM {self.table_name}
WHERE identity_key = ?
AND is_human = 1
AND DATE(timestamp) = DATE('now', 'localtime')
"""
,
(
identity_key
,),
)
as
cursor
:
row
=
await
cursor
.
fetchone
()
return
row
[
0
]
if
row
else
0
except
Exception
as
e
:
logger
.
error
(
f
"Error counting messages for {identity_key} (SQLite): {e}"
,
exc_info
=
True
)
return
0
async
def
close
(
self
):
# No persistent connection to close when using aiosqlite per-call
return
# ... (imports remain)
from
common.mongo_client
import
mongodb_client
,
COLLECTION_INBOX
,
utc_now
# ... (Existing Postgres/SQLite classes remain, or we can remove them if we fully migrate.
# For safety, I'll add MongoDBConversationManager and return it.)
class
MongoDBConversationManager
:
class
MongoDBConversationManager
:
"""MongoDB-based chat history storage."""
"""MongoDB-based chat history storage."""
...
@@ -415,25 +17,12 @@ class MongoDBConversationManager:
...
@@ -415,25 +17,12 @@ class MongoDBConversationManager:
async
def
initialize_table
(
self
):
async
def
initialize_table
(
self
):
"""Ensure indexes exist."""
"""Ensure indexes exist."""
try
:
try
:
# Create index on identity_key and timestamp
await
mongodb_client
.
db
[
self
.
collection_name
]
.
create_index
(
await
mongodb_client
.
db
[
self
.
collection_name
]
.
create_index
(
[(
"identity_key"
,
1
),
(
"timestamp"
,
-
1
)]
[(
"identity_key"
,
1
),
(
"timestamp"
,
-
1
)]
)
)
# Create index on id (auto-increment simulation or simple int id)
await
mongodb_client
.
db
[
self
.
collection_name
]
.
create_index
(
# MongoDB doesn't have auto-increment. We can use timestamp or ObjectId.
[(
"identity_key"
,
1
),
(
"is_human"
,
1
),
(
"timestamp"
,
-
1
)]
# To keep compatibility with cursor-based pagination (int id), we might need a counter.
)
# OR we can switch logic to use ObjectId or Timestamp as cursor.
# For simplicity let's stick to int ID using a counter collection if forced,
# OR better: Change the cursor logic to use timestamp or ObjectId.
# But the API expects 'next_cursor: int'.
# Let's generate a unique INT id using logical timestamp or detailed timestamp.
# Actually, standard Mongo practice is ObjectId.
# But let's try to simulate simple ID if we really need compatibility with frontend logic expecting INT.
# For now, let's use timestamp (ms) as Int ID?
# Or just update API models to accept String cursor?
# Let's stick to what allows us to write less code.
# User provided API code expects `before_id: int`.
# I will use a simple counter for now or just timestamp-based integer.
logger
.
info
(
f
"MongoDB collection {self.collection_name} initialized"
)
logger
.
info
(
f
"MongoDB collection {self.collection_name} initialized"
)
except
Exception
as
e
:
except
Exception
as
e
:
logger
.
error
(
f
"Error initializing MongoDB collection: {e}"
)
logger
.
error
(
f
"Error initializing MongoDB collection: {e}"
)
...
@@ -441,27 +30,26 @@ class MongoDBConversationManager:
...
@@ -441,27 +30,26 @@ class MongoDBConversationManager:
async
def
save_conversation_turn
(
self
,
identity_key
:
str
,
human_message
:
str
,
ai_message
:
str
):
async
def
save_conversation_turn
(
self
,
identity_key
:
str
,
human_message
:
str
,
ai_message
:
str
):
"""Save human and AI messages."""
"""Save human and AI messages."""
try
:
try
:
# Generate IDs based on timestamp (microseconds) to ensure uniqueness and order
ts
=
utc_now
()
ts
=
utc_now
()
base_id
=
int
(
ts
.
timestamp
()
*
1000000
)
base_id
=
int
(
ts
.
timestamp
()
*
1000000
)
docs
=
[
docs
=
[
{
{
"id"
:
base_id
,
"id"
:
base_id
,
"identity_key"
:
identity_key
,
"identity_key"
:
identity_key
,
"message"
:
human_message
,
"message"
:
human_message
,
"is_human"
:
True
,
"is_human"
:
True
,
"timestamp"
:
ts
"timestamp"
:
ts
,
},
},
{
{
"id"
:
base_id
+
1
,
"id"
:
base_id
+
1
,
"identity_key"
:
identity_key
,
"identity_key"
:
identity_key
,
"message"
:
ai_message
,
"message"
:
ai_message
,
"is_human"
:
False
,
"is_human"
:
False
,
"timestamp"
:
ts
"timestamp"
:
ts
,
}
}
,
]
]
await
mongodb_client
.
db
[
self
.
collection_name
]
.
insert_many
(
docs
)
await
mongodb_client
.
db
[
self
.
collection_name
]
.
insert_many
(
docs
)
logger
.
debug
(
f
"Saved conversation turn for {identity_key} (MongoDB)"
)
logger
.
debug
(
f
"Saved conversation turn for {identity_key} (MongoDB)"
)
except
Exception
as
e
:
except
Exception
as
e
:
...
@@ -474,28 +62,28 @@ class MongoDBConversationManager:
...
@@ -474,28 +62,28 @@ class MongoDBConversationManager:
"""Retrieve chat history."""
"""Retrieve chat history."""
try
:
try
:
query
=
{
"identity_key"
:
identity_key
}
query
=
{
"identity_key"
:
identity_key
}
if
before_id
:
if
before_id
:
query
[
"id"
]
=
{
"$lt"
:
before_id
}
query
[
"id"
]
=
{
"$lt"
:
before_id
}
cursor
=
mongodb_client
.
db
[
self
.
collection_name
]
.
find
(
query
)
.
sort
(
"id"
,
-
1
)
cursor
=
mongodb_client
.
db
[
self
.
collection_name
]
.
find
(
query
)
.
sort
(
"id"
,
-
1
)
if
limit
:
if
limit
:
cursor
=
cursor
.
limit
(
limit
)
cursor
=
cursor
.
limit
(
limit
)
docs
=
await
cursor
.
to_list
(
length
=
limit
or
100
)
docs
=
await
cursor
.
to_list
(
length
=
limit
or
100
)
history
=
[]
history
=
[]
for
doc
in
docs
:
for
doc
in
docs
:
message_content
=
doc
[
"message"
]
message_content
=
doc
[
"message"
]
is_human
=
doc
[
"is_human"
]
is_human
=
doc
[
"is_human"
]
entry
=
{
entry
=
{
"is_human"
:
is_human
,
"is_human"
:
is_human
,
"timestamp"
:
doc
[
"timestamp"
],
"timestamp"
:
doc
[
"timestamp"
],
"id"
:
doc
[
"id"
],
"id"
:
doc
[
"id"
],
}
}
if
is_human
:
if
is_human
:
entry
[
"message"
]
=
message_content
entry
[
"message"
]
=
message_content
else
:
else
:
...
@@ -506,9 +94,9 @@ class MongoDBConversationManager:
...
@@ -506,9 +94,9 @@ class MongoDBConversationManager:
except
(
json
.
JSONDecodeError
,
TypeError
):
except
(
json
.
JSONDecodeError
,
TypeError
):
entry
[
"message"
]
=
message_content
entry
[
"message"
]
=
message_content
entry
[
"product_ids"
]
=
[]
entry
[
"product_ids"
]
=
[]
history
.
append
(
entry
)
history
.
append
(
entry
)
return
history
return
history
except
Exception
as
e
:
except
Exception
as
e
:
logger
.
error
(
f
"Error retrieving chat history (MongoDB): {e}"
)
logger
.
error
(
f
"Error retrieving chat history (MongoDB): {e}"
)
...
@@ -529,9 +117,20 @@ class MongoDBConversationManager:
...
@@ -529,9 +117,20 @@ class MongoDBConversationManager:
return
0
return
0
async
def
get_message_count_today
(
self
,
identity_key
:
str
)
->
int
:
async
def
get_message_count_today
(
self
,
identity_key
:
str
)
->
int
:
# Simplification: skip exact date check or impl properly if needed
"""Count human messages sent today for rate limiting."""
# For MVP we can skip strict rate limit check or implement later
try
:
return
0
today_start
=
datetime
.
now
(
timezone
.
utc
)
.
replace
(
hour
=
0
,
minute
=
0
,
second
=
0
,
microsecond
=
0
)
count
=
await
mongodb_client
.
db
[
self
.
collection_name
]
.
count_documents
({
"identity_key"
:
identity_key
,
"is_human"
:
True
,
"timestamp"
:
{
"$gte"
:
today_start
},
})
return
count
except
Exception
as
e
:
logger
.
error
(
f
"Error counting messages for {identity_key} (MongoDB): {e}"
)
return
0
async
def
close
(
self
):
async
def
close
(
self
):
pass
pass
...
@@ -540,6 +139,7 @@ class MongoDBConversationManager:
...
@@ -540,6 +139,7 @@ class MongoDBConversationManager:
# --- Singleton ---
# --- Singleton ---
_instance
:
Any
=
None
_instance
:
Any
=
None
async
def
get_conversation_manager
():
async
def
get_conversation_manager
():
"""Return MongoDB manager."""
"""Return MongoDB manager."""
global
_instance
global
_instance
...
...
backend/common/embedding_service.py
View file @
2fec4891
...
@@ -40,9 +40,9 @@ class EmbeddingClientManager:
...
@@ -40,9 +40,9 @@ class EmbeddingClientManager:
# If using default key, cache the client
# If using default key, cache the client
if
not
api_key
:
if
not
api_key
:
if
self
.
_client
is
None
:
if
self
.
_client
is
None
:
self
.
_client
=
OpenAI
(
api_key
=
key
)
self
.
_client
=
OpenAI
(
api_key
=
key
)
return
self
.
_client
return
self
.
_client
# For custom keys, create new client (not cached)
# For custom keys, create new client (not cached)
return
OpenAI
(
api_key
=
key
)
return
OpenAI
(
api_key
=
key
)
...
@@ -65,9 +65,9 @@ class EmbeddingClientManager:
...
@@ -65,9 +65,9 @@ class EmbeddingClientManager:
# If using default key, cache the default client
# If using default key, cache the default client
if
not
api_key
:
if
not
api_key
:
if
self
.
_async_client
is
None
:
if
self
.
_async_client
is
None
:
self
.
_async_client
=
AsyncOpenAI
(
api_key
=
key
)
self
.
_async_client
=
AsyncOpenAI
(
api_key
=
key
)
return
self
.
_async_client
return
self
.
_async_client
# For user-specific keys, cache per user_id
# For user-specific keys, cache per user_id
if
user_id
and
user_id
in
self
.
_user_clients
:
if
user_id
and
user_id
in
self
.
_user_clients
:
...
@@ -79,7 +79,7 @@ class EmbeddingClientManager:
...
@@ -79,7 +79,7 @@ class EmbeddingClientManager:
return
client
return
client
logger
=
logging
.
getLogger
(
__name__
)
# NOTE:
# NOTE:
# - TẠM THỜI KHÔNG DÙNG REDIS CACHE CHO EMBEDDING để tránh phụ thuộc Redis/aioredis.
# - TẠM THỜI KHÔNG DÙNG REDIS CACHE CHO EMBEDDING để tránh phụ thuộc Redis/aioredis.
...
...
backend/common/encryption.py
View file @
2fec4891
...
@@ -17,60 +17,85 @@ logger = logging.getLogger(__name__)
...
@@ -17,60 +17,85 @@ logger = logging.getLogger(__name__)
ENCRYPTION_KEY
=
os
.
getenv
(
"ENCRYPTION_KEY"
)
ENCRYPTION_KEY
=
os
.
getenv
(
"ENCRYPTION_KEY"
)
# Fallback: Generate key from a password (NOT recommended for production)
# Fallback: Generate key from a password (NOT recommended for production)
# This is only for development/testing
FALLBACK_PASSWORD
=
os
.
getenv
(
"ENCRYPTION_PASSWORD"
,
"default-dev-password-change-in-production"
)
FALLBACK_PASSWORD
=
os
.
getenv
(
"ENCRYPTION_PASSWORD"
,
"default-dev-password-change-in-production"
)
# Cached Fernet instance (singleton) — avoids re-running PBKDF2 100K iterations per call
_fernet_instance
:
Fernet
|
None
=
None
def
_get_fernet_key
()
->
bytes
:
def
_derive_key_from_password
(
password
:
str
,
salt
:
bytes
)
->
bytes
:
"""Derive a Fernet key from password + salt using PBKDF2."""
kdf
=
PBKDF2HMAC
(
algorithm
=
hashes
.
SHA256
(),
length
=
32
,
salt
=
salt
,
iterations
=
100000
,
)
return
base64
.
urlsafe_b64encode
(
kdf
.
derive
(
password
.
encode
()))
def
_get_fernet
()
->
Fernet
:
"""
"""
Get or generate Fernet encryption key.
Get cached Fernet instance.
Priority: ENCRYPTION_KEY env var > generated from password (dev only)
Priority: ENCRYPTION_KEY env var > password-derived key (dev only).
When using ENCRYPTION_KEY, salt is not needed (key is used directly).
"""
"""
global
_fernet_instance
if
_fernet_instance
is
not
None
:
return
_fernet_instance
if
ENCRYPTION_KEY
:
if
ENCRYPTION_KEY
:
try
:
try
:
# Try to use as-is (should be base64-encoded 32-byte key
)
_fernet_instance
=
Fernet
(
ENCRYPTION_KEY
.
encode
()
)
return
ENCRYPTION_KEY
.
encode
()
return
_fernet_instance
except
Exception
:
except
Exception
:
# If not valid, try to decode as base64
try
:
try
:
return
base64
.
urlsafe_b64decode
(
ENCRYPTION_KEY
)
_fernet_instance
=
Fernet
(
base64
.
urlsafe_b64decode
(
ENCRYPTION_KEY
))
return
_fernet_instance
except
Exception
:
except
Exception
:
logger
.
warning
(
"Invalid ENCRYPTION_KEY format, using fallback"
)
logger
.
warning
(
"Invalid ENCRYPTION_KEY format, using fallback"
)
# Fallback: Generate from password (dev only - NOT secure for production)
# Fallback: Generate from password
with random salt
(dev only - NOT secure for production)
logger
.
warning
(
logger
.
warning
(
"⚠️ ENCRYPTION_KEY not set. Using password-based key derivation (NOT secure for production!)"
"⚠️ ENCRYPTION_KEY not set. Using password-based key derivation (NOT secure for production!)"
)
)
salt
=
b
"cucu_note_salt"
# Fixed salt for dev (should be random in production)
# Use random salt — stored in a file so decryption works across restarts
kdf
=
PBKDF2HMAC
(
salt_file
=
os
.
path
.
join
(
os
.
path
.
dirname
(
__file__
),
".."
,
"data"
,
".encryption_salt"
)
algorithm
=
hashes
.
SHA256
(),
os
.
makedirs
(
os
.
path
.
dirname
(
salt_file
),
exist_ok
=
True
)
length
=
32
,
salt
=
salt
,
if
os
.
path
.
exists
(
salt_file
):
iterations
=
100000
,
with
open
(
salt_file
,
"rb"
)
as
f
:
)
salt
=
f
.
read
()
key
=
base64
.
urlsafe_b64encode
(
kdf
.
derive
(
FALLBACK_PASSWORD
.
encode
()))
else
:
return
key
salt
=
os
.
urandom
(
16
)
with
open
(
salt_file
,
"wb"
)
as
f
:
f
.
write
(
salt
)
logger
.
info
(
"Generated new encryption salt (saved to
%
s)"
,
salt_file
)
key
=
_derive_key_from_password
(
FALLBACK_PASSWORD
,
salt
)
_fernet_instance
=
Fernet
(
key
)
return
_fernet_instance
def
encrypt_api_key
(
api_key
:
str
)
->
str
:
def
encrypt_api_key
(
api_key
:
str
)
->
str
:
"""
"""
Encrypt an API key using Fernet symmetric encryption.
Encrypt an API key using Fernet symmetric encryption.
Args:
Args:
api_key: Plain text API key to encrypt
api_key: Plain text API key to encrypt
Returns:
Returns:
Encrypted API key as base64 string
Encrypted API key as base64 string
Raises:
Raises:
ValueError: If api_key is empty
ValueError: If api_key is empty
RuntimeError: If encryption fails
RuntimeError: If encryption fails
"""
"""
if
not
api_key
or
not
api_key
.
strip
():
if
not
api_key
or
not
api_key
.
strip
():
raise
ValueError
(
"API key cannot be empty"
)
raise
ValueError
(
"API key cannot be empty"
)
try
:
try
:
fernet
=
Fernet
(
_get_fernet_key
()
)
fernet
=
_get_fernet
(
)
encrypted
=
fernet
.
encrypt
(
api_key
.
encode
())
encrypted
=
fernet
.
encrypt
(
api_key
.
encode
())
return
encrypted
.
decode
()
return
encrypted
.
decode
()
except
Exception
as
e
:
except
Exception
as
e
:
...
@@ -81,22 +106,22 @@ def encrypt_api_key(api_key: str) -> str:
...
@@ -81,22 +106,22 @@ def encrypt_api_key(api_key: str) -> str:
def
decrypt_api_key
(
encrypted_key
:
str
)
->
str
:
def
decrypt_api_key
(
encrypted_key
:
str
)
->
str
:
"""
"""
Decrypt an encrypted API key.
Decrypt an encrypted API key.
Args:
Args:
encrypted_key: Encrypted API key (base64 string)
encrypted_key: Encrypted API key (base64 string)
Returns:
Returns:
Decrypted plain text API key
Decrypted plain text API key
Raises:
Raises:
ValueError: If encrypted_key is empty
ValueError: If encrypted_key is empty
RuntimeError: If decryption fails (wrong key, corrupted data, etc.)
RuntimeError: If decryption fails (wrong key, corrupted data, etc.)
"""
"""
if
not
encrypted_key
or
not
encrypted_key
.
strip
():
if
not
encrypted_key
or
not
encrypted_key
.
strip
():
raise
ValueError
(
"Encrypted key cannot be empty"
)
raise
ValueError
(
"Encrypted key cannot be empty"
)
try
:
try
:
fernet
=
Fernet
(
_get_fernet_key
()
)
fernet
=
_get_fernet
(
)
decrypted
=
fernet
.
decrypt
(
encrypted_key
.
encode
())
decrypted
=
fernet
.
decrypt
(
encrypted_key
.
encode
())
return
decrypted
.
decode
()
return
decrypted
.
decode
()
except
Exception
as
e
:
except
Exception
as
e
:
...
@@ -107,39 +132,38 @@ def decrypt_api_key(encrypted_key: str) -> str:
...
@@ -107,39 +132,38 @@ def decrypt_api_key(encrypted_key: str) -> str:
def
mask_api_key
(
api_key
:
str
)
->
str
:
def
mask_api_key
(
api_key
:
str
)
->
str
:
"""
"""
Mask an API key for display (show only first 7 chars and last 4 chars).
Mask an API key for display (show only first 7 chars and last 4 chars).
Args:
Args:
api_key: API key to mask
api_key: API key to mask
Returns:
Returns:
Masked API key (e.g., "sk-...xxxx")
Masked API key (e.g., "sk-...xxxx")
"""
"""
if
not
api_key
or
len
(
api_key
)
<
11
:
if
not
api_key
or
len
(
api_key
)
<
11
:
return
"sk-...xxxx"
return
"sk-...xxxx"
return
f
"{api_key[:7]}...{api_key[-4:]}"
return
f
"{api_key[:7]}...{api_key[-4:]}"
def
validate_openai_key_format
(
api_key
:
str
)
->
bool
:
def
validate_openai_key_format
(
api_key
:
str
)
->
bool
:
"""
"""
Validate OpenAI API key format.
Validate OpenAI API key format.
Args:
Args:
api_key: API key to validate
api_key: API key to validate
Returns:
Returns:
True if format is valid, False otherwise
True if format is valid, False otherwise
"""
"""
if
not
api_key
or
not
api_key
.
strip
():
if
not
api_key
or
not
api_key
.
strip
():
return
False
return
False
# OpenAI keys typically start with "sk-" and are ~51 characters
# OpenAI keys typically start with "sk-" and are ~51 characters
key
=
api_key
.
strip
()
key
=
api_key
.
strip
()
if
not
key
.
startswith
(
"sk-"
):
if
not
key
.
startswith
(
"sk-"
):
return
False
return
False
if
len
(
key
)
<
20
or
len
(
key
)
>
100
:
# Reasonable range
if
len
(
key
)
<
20
or
len
(
key
)
>
100
:
# Reasonable range
return
False
return
False
return
True
return
True
backend/common/llm_factory.py
View file @
2fec4891
...
@@ -88,7 +88,7 @@ class LLMFactory:
...
@@ -88,7 +88,7 @@ class LLMFactory:
"streaming"
:
streaming
,
"streaming"
:
streaming
,
"api_key"
:
key
,
"api_key"
:
key
,
"temperature"
:
0
,
"temperature"
:
0
,
"max_tokens"
:
1000
,
"max_tokens"
:
4096
,
}
}
# Nếu bật json_mode, tiêm trực tiếp vào constructor
# Nếu bật json_mode, tiêm trực tiếp vào constructor
...
@@ -100,14 +100,7 @@ class LLMFactory:
...
@@ -100,14 +100,7 @@ class LLMFactory:
logger
.
info
(
f
"✅ Created OpenAI: {model_name}"
)
logger
.
info
(
f
"✅ Created OpenAI: {model_name}"
)
return
llm
return
llm
def
_enable_json_mode
(
self
,
llm
:
BaseChatModel
,
model_name
:
str
)
->
BaseChatModel
:
"""Enable JSON mode for the LLM."""
try
:
llm
=
llm
.
bind
(
response_format
=
{
"type"
:
"json_object"
})
logger
.
debug
(
f
"⚙️ JSON mode enabled for {model_name}"
)
except
Exception
as
e
:
logger
.
warning
(
f
"⚠️ JSON mode not supported: {e}"
)
return
llm
def
initialize
(
self
,
skip_warmup
:
bool
=
True
)
->
None
:
def
initialize
(
self
,
skip_warmup
:
bool
=
True
)
->
None
:
"""
"""
...
...
backend/common/memos_core/query_parser.py
View file @
2fec4891
...
@@ -48,23 +48,30 @@ def parse_date_range(
...
@@ -48,23 +48,30 @@ def parse_date_range(
if
match_ts
:
if
match_ts
:
try
:
try
:
from
common.timezone_config
import
VIETNAM_TZ
ts_start
=
float
(
match_ts
.
group
(
1
))
ts_start
=
float
(
match_ts
.
group
(
1
))
ts_end
=
float
(
match_ts
.
group
(
2
))
ts_end
=
float
(
match_ts
.
group
(
2
))
dt_start
=
datetime
.
fromtimestamp
(
ts_start
,
tz
=
timezone
.
utc
)
# Frontend sends UTC midnight timestamps, but user is in Vietnam (UTC+7).
dt_end
=
datetime
.
fromtimestamp
(
ts_end
,
tz
=
timezone
.
utc
)
# Shift by -7h so "Feb 5" means Feb 5 00:00 VN (= Feb 4 17:00 UTC)
# instead of Feb 5 00:00 UTC (= Feb 5 07:00 VN).
utc_offset_seconds
=
VIETNAM_TZ
.
utcoffset
(
None
)
.
total_seconds
()
dt_start
=
datetime
.
fromtimestamp
(
ts_start
-
utc_offset_seconds
,
tz
=
timezone
.
utc
)
dt_end
=
datetime
.
fromtimestamp
(
ts_end
-
utc_offset_seconds
,
tz
=
timezone
.
utc
)
except
(
ValueError
,
TypeError
):
except
(
ValueError
,
TypeError
):
pass
pass
else
:
else
:
# Case 2: DisplayTime filter (displayTime:YYYY-MM-DD)
# Case 2: DisplayTime filter (displayTime:YYYY-MM-DD)
# Dates are interpreted as Vietnam time (UTC+7), then converted to UTC for MongoDB
pattern_dt
=
r"displayTime:(\d{4}-\d{2}-\d{2})"
pattern_dt
=
r"displayTime:(\d{4}-\d{2}-\d{2})"
match_dt
=
re
.
search
(
pattern_dt
,
filter_str
)
match_dt
=
re
.
search
(
pattern_dt
,
filter_str
)
if
match_dt
:
if
match_dt
:
try
:
try
:
from
common.timezone_config
import
VIETNAM_TZ
date_str
=
match_dt
.
group
(
1
)
date_str
=
match_dt
.
group
(
1
)
dt
=
datetime
.
strptime
(
date_str
,
"
%
Y-
%
m-
%
d"
)
.
replace
(
tzinfo
=
timezone
.
utc
)
dt
=
datetime
.
strptime
(
date_str
,
"
%
Y-
%
m-
%
d"
)
.
replace
(
tzinfo
=
VIETNAM_TZ
)
dt_start
=
dt
dt_start
=
dt
.
astimezone
(
timezone
.
utc
)
dt_end
=
dt
+
timedelta
(
days
=
1
)
dt_end
=
(
dt
+
timedelta
(
days
=
1
))
.
astimezone
(
timezone
.
utc
)
except
ValueError
:
except
ValueError
:
pass
pass
...
...
backend/common/memos_core/schemas.py
View file @
2fec4891
...
@@ -191,7 +191,7 @@ class ShortcutUpdate(BaseModel):
...
@@ -191,7 +191,7 @@ class ShortcutUpdate(BaseModel):
class
ShortcutResponse
(
BaseModel
):
class
ShortcutResponse
(
BaseModel
):
id
:
int
id
:
str
name
:
str
name
:
str
filter
:
str
filter
:
str
...
...
backend/common/memos_core/services.py
View file @
2fec4891
...
@@ -810,21 +810,74 @@ class ReactionService:
...
@@ -810,21 +810,74 @@ class ReactionService:
class
ShortcutService
:
class
ShortcutService
:
"""Shortcut (Workspace) service with MongoDB backend."""
async
def
list_shortcuts
(
self
)
->
List
[
schemas
.
ShortcutResponse
]:
async
def
list_shortcuts
(
self
)
->
List
[
schemas
.
ShortcutResponse
]:
return
[]
cursor
=
mongodb_client
.
shortcuts
.
find
({})
.
sort
(
"created_at"
,
-
1
)
docs
=
await
cursor
.
to_list
(
length
=
100
)
return
[
schemas
.
ShortcutResponse
(
id
=
str
(
doc
[
"_id"
]),
name
=
doc
.
get
(
"name"
,
""
),
filter
=
doc
.
get
(
"filter"
,
""
),
)
for
doc
in
docs
]
async
def
create_shortcut
(
self
,
payload
:
schemas
.
ShortcutCreate
)
->
schemas
.
ShortcutResponse
:
async
def
create_shortcut
(
self
,
payload
:
schemas
.
ShortcutCreate
)
->
schemas
.
ShortcutResponse
:
return
schemas
.
ShortcutResponse
(
id
=
1
,
name
=
payload
.
name
,
filter
=
payload
.
filter
)
now
=
utc_now
()
doc
=
{
"name"
:
payload
.
name
,
"filter"
:
payload
.
filter
,
"created_at"
:
now
,
"updated_at"
:
now
,
}
result
=
await
mongodb_client
.
shortcuts
.
insert_one
(
doc
)
doc
[
"_id"
]
=
result
.
inserted_id
return
schemas
.
ShortcutResponse
(
id
=
str
(
doc
[
"_id"
]),
name
=
doc
[
"name"
],
filter
=
doc
[
"filter"
],
)
async
def
update_shortcut
(
self
,
shortcut_id
:
str
,
payload
:
schemas
.
ShortcutUpdate
)
->
schemas
.
ShortcutResponse
:
update_fields
:
dict
[
str
,
Any
]
=
{
"updated_at"
:
utc_now
()}
if
payload
.
name
is
not
None
:
update_fields
[
"name"
]
=
payload
.
name
if
payload
.
filter
is
not
None
:
update_fields
[
"filter"
]
=
payload
.
filter
filter_query
:
dict
[
str
,
Any
]
=
{}
if
ObjectId
.
is_valid
(
shortcut_id
):
filter_query
[
"_id"
]
=
ObjectId
(
shortcut_id
)
else
:
filter_query
[
"_id"
]
=
shortcut_id
from
pymongo
import
ReturnDocument
result
=
await
mongodb_client
.
shortcuts
.
find_one_and_update
(
filter_query
,
{
"$set"
:
update_fields
},
return_document
=
ReturnDocument
.
AFTER
,
)
if
not
result
:
raise
ValueError
(
f
"Shortcut {shortcut_id} not found"
)
async
def
update_shortcut
(
self
,
shortcut_id
:
int
,
payload
:
schemas
.
ShortcutUpdate
)
->
schemas
.
ShortcutResponse
:
return
schemas
.
ShortcutResponse
(
return
schemas
.
ShortcutResponse
(
id
=
s
hortcut_id
,
id
=
s
tr
(
result
[
"_id"
])
,
name
=
payload
.
name
or
"demo"
,
name
=
result
.
get
(
"name"
,
""
)
,
filter
=
payload
.
filter
or
""
,
filter
=
result
.
get
(
"filter"
,
""
)
,
)
)
async
def
delete_shortcut
(
self
,
shortcut_id
:
int
)
->
None
:
async
def
delete_shortcut
(
self
,
shortcut_id
:
str
)
->
None
:
return
None
filter_query
:
dict
[
str
,
Any
]
=
{}
if
ObjectId
.
is_valid
(
shortcut_id
):
filter_query
[
"_id"
]
=
ObjectId
(
shortcut_id
)
else
:
filter_query
[
"_id"
]
=
shortcut_id
result
=
await
mongodb_client
.
shortcuts
.
delete_one
(
filter_query
)
if
result
.
deleted_count
==
0
:
raise
ValueError
(
f
"Shortcut {shortcut_id} not found"
)
class
ActivityService
:
class
ActivityService
:
...
...
backend/common/middleware.py
View file @
2fec4891
...
@@ -37,6 +37,7 @@ PUBLIC_PATHS = {
...
@@ -37,6 +37,7 @@ PUBLIC_PATHS = {
PUBLIC_PATH_PREFIXES
=
[
PUBLIC_PATH_PREFIXES
=
[
"/static"
,
"/static"
,
"/mock"
,
"/mock"
,
"/api/test"
,
# debug/test endpoints - no auth required
]
]
...
...
backend/common/mongo_client.py
View file @
2fec4891
...
@@ -29,6 +29,7 @@ COLLECTION_REACTIONS = "cuccu_reactions"
...
@@ -29,6 +29,7 @@ COLLECTION_REACTIONS = "cuccu_reactions"
COLLECTION_MEMO_EMBEDDINGS
=
"cuccu_memo_embeddings"
COLLECTION_MEMO_EMBEDDINGS
=
"cuccu_memo_embeddings"
COLLECTION_INBOX
=
"cuccu_inbox"
COLLECTION_INBOX
=
"cuccu_inbox"
COLLECTION_USER_SETTINGS
=
"cuccu_user_settings"
COLLECTION_USER_SETTINGS
=
"cuccu_user_settings"
COLLECTION_SHORTCUTS
=
"cuccu_shortcuts"
class
MongoDBClient
:
class
MongoDBClient
:
...
@@ -116,6 +117,10 @@ class MongoDBClient:
...
@@ -116,6 +117,10 @@ class MongoDBClient:
def
user_settings
(
self
):
def
user_settings
(
self
):
return
self
.
db
[
COLLECTION_USER_SETTINGS
]
return
self
.
db
[
COLLECTION_USER_SETTINGS
]
@
property
def
shortcuts
(
self
):
return
self
.
db
[
COLLECTION_SHORTCUTS
]
# Singleton instance
# Singleton instance
mongodb_client
=
MongoDBClient
()
mongodb_client
=
MongoDBClient
()
...
@@ -206,6 +211,9 @@ async def create_indexes():
...
@@ -206,6 +211,9 @@ async def create_indexes():
# ====================== MEMO VERSIONS ======================
# ====================== MEMO VERSIONS ======================
await
db
[
"cuccu_memo_versions"
]
.
create_index
([(
"memo_id"
,
1
),
(
"version_index"
,
-
1
)])
await
db
[
"cuccu_memo_versions"
]
.
create_index
([(
"memo_id"
,
1
),
(
"version_index"
,
-
1
)])
# ====================== SHORTCUTS ======================
await
db
[
COLLECTION_SHORTCUTS
]
.
create_index
([(
"creator_id"
,
1
)])
logger
.
info
(
"✅ Database indexes created successfully (Production-ready)"
)
logger
.
info
(
"✅ Database indexes created successfully (Production-ready)"
)
except
Exception
as
e
:
except
Exception
as
e
:
logger
.
warning
(
f
"⚠️ Error creating indexes (may already exist): {e}"
)
logger
.
warning
(
f
"⚠️ Error creating indexes (may already exist): {e}"
)
...
...
backend/common/rate_limit.py
View file @
2fec4891
...
@@ -76,7 +76,7 @@ class RateLimitService:
...
@@ -76,7 +76,7 @@ class RateLimitService:
logger
.
info
(
f
"Using Redis for rate limiting: {redis_host}:{redis_port}/{redis_db}"
)
logger
.
info
(
f
"Using Redis for rate limiting: {redis_host}:{redis_port}/{redis_db}"
)
else
:
else
:
# Fallback to memory (not suitable for production with multiple instances)
# Fallback to memory (not suitable for production with multiple instances)
self
.
storage_uri
=
os
.
getenv
(
"RATE_STORAGE_URI"
,
"memory://"
)
self
.
storage_uri
=
os
.
getenv
(
"RATE_STORAGE_URI"
,
"memory://"
)
logger
.
warning
(
"⚠️ Using in-memory rate limiting (not suitable for production with multiple instances)"
)
logger
.
warning
(
"⚠️ Using in-memory rate limiting (not suitable for production with multiple instances)"
)
self
.
default_limits
=
[
"100/hour"
,
"30/minute"
]
self
.
default_limits
=
[
"100/hour"
,
"30/minute"
]
...
...
backend/common/timezone_config.py
0 → 100644
View file @
2fec4891
"""
Timezone configuration for CuCu Note.
All user-facing date operations should use VIETNAM_TZ.
MongoDB stores in UTC — convert at query boundaries.
"""
from
datetime
import
timezone
,
timedelta
VIETNAM_TZ
=
timezone
(
timedelta
(
hours
=
7
))
backend/config.py
View file @
2fec4891
...
@@ -126,8 +126,6 @@ CONV_SUPABASE_KEY: str | None = os.getenv("CONV_SUPABASE_KEY")
...
@@ -126,8 +126,6 @@ CONV_SUPABASE_KEY: str | None = os.getenv("CONV_SUPABASE_KEY")
# ====================== REDIS CONFIGURATION ======================
# ====================== REDIS CONFIGURATION ======================
REDIS_HOST
:
str
|
None
=
os
.
getenv
(
"REDIS_HOST"
)
REDIS_HOST
:
str
|
None
=
os
.
getenv
(
"REDIS_HOST"
)
REDIS_PORT
:
int
=
int
(
os
.
getenv
(
"REDIS_PORT"
,
"6379"
))
REDIS_PORT
:
int
=
int
(
os
.
getenv
(
"REDIS_PORT"
,
"6379"
))
REDIS_PASSWORD
:
str
|
None
=
os
.
getenv
(
"REDIS_PASSWORD"
)
REDIS_USERNAME
:
str
|
None
=
os
.
getenv
(
"REDIS_USERNAME"
)
# ====================== AI API KEYS & MODELS ======================
# ====================== AI API KEYS & MODELS ======================
OPENAI_API_KEY
:
str
|
None
=
os
.
getenv
(
"OPENAI_API_KEY"
)
OPENAI_API_KEY
:
str
|
None
=
os
.
getenv
(
"OPENAI_API_KEY"
)
...
@@ -172,7 +170,7 @@ REDIS_CACHE_URL: str | None = os.getenv("REDIS_CACHE_URL", "redis-14473.c93.us-e
...
@@ -172,7 +170,7 @@ REDIS_CACHE_URL: str | None = os.getenv("REDIS_CACHE_URL", "redis-14473.c93.us-e
REDIS_CACHE_PORT
:
int
=
int
(
os
.
getenv
(
"REDIS_CACHE_PORT"
,
"14473"
))
REDIS_CACHE_PORT
:
int
=
int
(
os
.
getenv
(
"REDIS_CACHE_PORT"
,
"14473"
))
REDIS_CACHE_DB
:
int
=
int
(
os
.
getenv
(
"REDIS_CACHE_DB"
,
"0"
))
REDIS_CACHE_DB
:
int
=
int
(
os
.
getenv
(
"REDIS_CACHE_DB"
,
"0"
))
REDIS_CACHE_TURN_ON
:
bool
=
os
.
getenv
(
"REDIS_CACHE_TURN_ON"
,
"true"
)
.
lower
()
==
"true"
REDIS_CACHE_TURN_ON
:
bool
=
os
.
getenv
(
"REDIS_CACHE_TURN_ON"
,
"true"
)
.
lower
()
==
"true"
REDIS_PASSWORD
:
str
|
None
=
os
.
getenv
(
"REDIS_CACHE_PASSWORD"
,
"4kCCXXaJXXv7k358eG69p1lDBQtHTbQ1"
)
REDIS_PASSWORD
:
str
|
None
=
os
.
getenv
(
"REDIS_CACHE_PASSWORD"
)
REDIS_USERNAME
:
str
=
os
.
getenv
(
"REDIS_CACHE_USERNAME"
,
"default"
)
REDIS_USERNAME
:
str
=
os
.
getenv
(
"REDIS_CACHE_USERNAME"
,
"default"
)
CONV_DATABASE_URL
:
str
|
None
=
os
.
getenv
(
"CONV_DATABASE_URL"
)
CONV_DATABASE_URL
:
str
|
None
=
os
.
getenv
(
"CONV_DATABASE_URL"
)
...
@@ -183,8 +181,8 @@ MONGODB_DB_NAME: str | None = os.getenv("MONGODB_DB_NAME", "cucu_note")
...
@@ -183,8 +181,8 @@ MONGODB_DB_NAME: str | None = os.getenv("MONGODB_DB_NAME", "cucu_note")
USE_MONGO_CONVERSATION
:
bool
=
os
.
getenv
(
"USE_MONGO_CONVERSATION"
,
"true"
)
.
lower
()
==
"true"
USE_MONGO_CONVERSATION
:
bool
=
os
.
getenv
(
"USE_MONGO_CONVERSATION"
,
"true"
)
.
lower
()
==
"true"
# MongoDB Connection Pooling
# MongoDB Connection Pooling
MONGODB_MAX_POOL_SIZE
:
int
=
int
(
os
.
getenv
(
"MONGODB_MAX_POOL_SIZE"
,
"5
0
"
))
MONGODB_MAX_POOL_SIZE
:
int
=
int
(
os
.
getenv
(
"MONGODB_MAX_POOL_SIZE"
,
"5"
))
MONGODB_MIN_POOL_SIZE
:
int
=
int
(
os
.
getenv
(
"MONGODB_MIN_POOL_SIZE"
,
"1
0
"
))
MONGODB_MIN_POOL_SIZE
:
int
=
int
(
os
.
getenv
(
"MONGODB_MIN_POOL_SIZE"
,
"1"
))
MONGODB_MAX_IDLE_TIME_MS
:
int
=
int
(
os
.
getenv
(
"MONGODB_MAX_IDLE_TIME_MS"
,
"45000"
))
MONGODB_MAX_IDLE_TIME_MS
:
int
=
int
(
os
.
getenv
(
"MONGODB_MAX_IDLE_TIME_MS"
,
"45000"
))
# ====================== CANIFA INTERNAL POSTGRES ======================
# ====================== CANIFA INTERNAL POSTGRES ======================
...
...
backend/entrypoint.sh
100644 → 100755
View file @
2fec4891
...
@@ -43,18 +43,11 @@ asyncio.run(setup())
...
@@ -43,18 +43,11 @@ asyncio.run(setup())
"
||
echo
"⚠️ Could not set up indexes (will retry on first request)"
"
||
echo
"⚠️ Could not set up indexes (will retry on first request)"
# Start the server
# Start the server
echo
"🌟 Starting Gunicorn server..."
echo
"🌟 Starting Uvicorn server (hot reload enabled)..."
# Allow overriding number of workers via env, default to 1 for simplicity
exec
uvicorn server:app
\
WORKERS
=
"
${
GUNICORN_WORKERS
:-
1
}
"
--host
0.0.0.0
\
echo
"🔧 Using Gunicorn workers:
$WORKERS
"
--port
5000
\
--reload
\
exec
gunicorn
\
--reload-dir
/app
\
--workers
"
$WORKERS
"
\
--log-level
info
--worker-class
uvicorn.workers.UvicornWorker
\
--bind
0.0.0.0:5000
\
--timeout
120
\
--access-logfile
-
\
--error-logfile
-
\
--log-level
info
\
server:app
backend/requirements.txt
View file @
2fec4891
# Core FastAPI
# Core FastAPI
fastapi==0.124.4
fastapi==0.124.4
a
uvicorn==0.38.0
uvicorn==0.38.0
uvloop>=0.21.0
starlette==0.50.0
starlette==0.50.0
pydantic==2.12.5
pydantic==2.12.5
pydantic_core==2.41.5
pydantic_core==2.41.5
...
@@ -51,13 +52,10 @@ google-auth==2.45.0
...
@@ -51,13 +52,10 @@ google-auth==2.45.0
# Tokenization
# Tokenization
tiktoken==0.12.0
tiktoken==0.12.0
# Observability
# Observability
(minimal - only trace API used)
opentelemetry-api==1.39.1
opentelemetry-api==1.39.1
opentelemetry-exporter-otlp-proto-common==1.39.1
opentelemetry-exporter-otlp-proto-http==1.39.1
opentelemetry-proto==1.39.1
opentelemetry-sdk==1.39.1
opentelemetry-sdk==1.39.1
opentelemetry-semantic-conventions==0.60b1
# Removed: otel exporters/proto/semantic-conventions (not configured)
# Utilities
# Utilities
python-dotenv==1.2.1
python-dotenv==1.2.1
...
@@ -71,7 +69,7 @@ tenacity==9.1.2
...
@@ -71,7 +69,7 @@ tenacity==9.1.2
backoff==2.2.1
backoff==2.2.1
regex==2025.11.3
regex==2025.11.3
Unidecode==1.4.0
Unidecode==1.4.0
pillow==12.0.0
# pillow==12.0.0 # Removed: not directly imported
# WebSocket
# WebSocket
websockets==15.0.1
websockets==15.0.1
...
@@ -104,4 +102,5 @@ cachetools==6.2.4
...
@@ -104,4 +102,5 @@ cachetools==6.2.4
pytest==9.0.2
pytest==9.0.2
# Production server
# Production server
gunicorn==23.0.0
# gunicorn==23.0.0 # Removed: using uvicorn instead
aiosqlite
backend/server.py
View file @
2fec4891
...
@@ -2,6 +2,7 @@ import asyncio
...
@@ -2,6 +2,7 @@ import asyncio
import
os
import
os
import
platform
import
platform
import
logging
import
logging
from
contextlib
import
asynccontextmanager
import
uvicorn
import
uvicorn
from
fastapi
import
FastAPI
from
fastapi
import
FastAPI
...
@@ -10,6 +11,7 @@ from fastapi.responses import RedirectResponse
...
@@ -10,6 +11,7 @@ from fastapi.responses import RedirectResponse
from
api.chatbot
import
router
as
chatbot_router
from
api.chatbot
import
router
as
chatbot_router
from
api.memos
import
router
as
memos_router
from
api.memos
import
router
as
memos_router
from
api.test_chat_route
import
router
as
test_router
from
common.cache
import
redis_cache
from
common.cache
import
redis_cache
from
common.langfuse_client
import
get_langfuse_client
from
common.langfuse_client
import
get_langfuse_client
from
common.middleware
import
middleware_manager
from
common.middleware
import
middleware_manager
...
@@ -29,54 +31,44 @@ logging.basicConfig(
...
@@ -29,54 +31,44 @@ logging.basicConfig(
)
)
logger
=
logging
.
getLogger
(
__name__
)
logger
=
logging
.
getLogger
(
__name__
)
# Langfuse client initialized in startup_event (not at import time)
app
=
FastAPI
(
title
=
"Contract AI Service"
,
description
=
"API for Contract AI Service"
,
version
=
"1.0.0"
,
)
# =============================================================================
# =============================================================================
#
STARTUP EVENT - Initialize Redis Cache + MongoDB
#
LIFESPAN - Initialize & cleanup resources (replaces deprecated on_event)
# =============================================================================
# =============================================================================
@
app
.
on_event
(
"startup"
)
@
asynccontextmanager
async
def
startup_event
():
async
def
lifespan
(
app
:
FastAPI
):
"""Initialize dependencies on startup."""
"""Startup & shutdown lifecycle manager."""
# --- STARTUP ---
# Initialize Redis (optional - will continue without cache if unavailable)
# Initialize Redis (optional - will continue without cache if unavailable)
redis_client
=
await
redis_cache
.
initialize
()
redis_client
=
await
redis_cache
.
initialize
()
if
redis_client
:
if
redis_client
:
logger
.
info
(
"✅ Redis cache initialized for message limit"
)
logger
.
info
(
"✅ Redis cache initialized for message limit"
)
else
:
else
:
logger
.
info
(
"⚠️ Redis cache unavailable - continuing without cache"
)
logger
.
info
(
"⚠️ Redis cache unavailable - continuing without cache"
)
# MongoDB initialization (required)
# MongoDB initialization (required)
from
common.mongo_client
import
init_mongodb
from
common.mongo_client
import
init_mongodb
await
init_mongodb
()
await
init_mongodb
()
logger
.
info
(
"✅ MongoDB connection initialized"
)
logger
.
info
(
"✅ MongoDB connection initialized"
)
# Langfuse initialization (optional
- lazy loaded, just triggers auth check
)
# Langfuse initialization (optional)
langfuse_client
=
get_langfuse_client
()
langfuse_client
=
get_langfuse_client
()
if
langfuse_client
:
if
langfuse_client
:
logger
.
info
(
"✅ Langfuse client ready"
)
logger
.
info
(
"✅ Langfuse client ready"
)
else
:
else
:
logger
.
warning
(
"⚠️ Langfuse client not available (missing keys or disabled)"
)
logger
.
warning
(
"⚠️ Langfuse client not available (missing keys or disabled)"
)
yield
# App is running
@
app
.
on_event
(
"shutdown"
)
# --- SHUTDOWN ---
async
def
shutdown_event
():
"""Cleanup on shutdown."""
try
:
try
:
# Close Redis connection if exists
redis_client
=
redis_cache
.
get_client
()
redis_client
=
redis_cache
.
get_client
()
if
redis_client
:
if
redis_client
:
await
redis_client
.
aclose
()
await
redis_client
.
aclose
()
logger
.
info
(
"Redis connection closed"
)
logger
.
info
(
"Redis connection closed"
)
except
Exception
as
e
:
except
Exception
as
e
:
logger
.
debug
(
f
"Error closing Redis: {e}"
)
logger
.
debug
(
f
"Error closing Redis: {e}"
)
# Close MongoDB connection
try
:
try
:
from
common.mongo_client
import
close_mongodb
from
common.mongo_client
import
close_mongodb
await
close_mongodb
()
await
close_mongodb
()
...
@@ -85,6 +77,14 @@ async def shutdown_event():
...
@@ -85,6 +77,14 @@ async def shutdown_event():
logger
.
debug
(
f
"Error closing MongoDB: {e}"
)
logger
.
debug
(
f
"Error closing MongoDB: {e}"
)
app
=
FastAPI
(
title
=
"Contract AI Service"
,
description
=
"API for Contract AI Service"
,
version
=
"1.0.0"
,
lifespan
=
lifespan
,
)
# =============================================================================
# =============================================================================
# MIDDLEWARE SETUP - Gom Auth + RateLimit + CORS vào một chỗ
# MIDDLEWARE SETUP - Gom Auth + RateLimit + CORS vào một chỗ
# =============================================================================
# =============================================================================
...
@@ -96,6 +96,7 @@ middleware_manager.setup(
...
@@ -96,6 +96,7 @@ middleware_manager.setup(
cors_origins
=
CORS_ORIGINS
,
# từ environment variable
cors_origins
=
CORS_ORIGINS
,
# từ environment variable
)
)
app
.
include_router
(
test_router
)
# No-auth test endpoints
app
.
include_router
(
chatbot_router
)
app
.
include_router
(
chatbot_router
)
app
.
include_router
(
memos_router
)
app
.
include_router
(
memos_router
)
...
...
docker-compose.yml
View file @
2fec4891
...
@@ -6,6 +6,10 @@ services:
...
@@ -6,6 +6,10 @@ services:
build
:
build
:
context
:
./backend
context
:
./backend
dockerfile
:
Dockerfile.prod
dockerfile
:
Dockerfile.prod
cache_from
:
-
type=local,src=.docker/cache/backend
cache_to
:
-
type=local,dest=.docker/cache/backend
container_name
:
cuccu_backend
container_name
:
cuccu_backend
restart
:
unless-stopped
restart
:
unless-stopped
ports
:
ports
:
...
@@ -30,37 +34,38 @@ services:
...
@@ -30,37 +34,38 @@ services:
deploy
:
deploy
:
resources
:
resources
:
limits
:
limits
:
memory
:
2G
memory
:
512M
cpus
:
'
1.0'
cpus
:
'
1.0'
reservations
:
reservations
:
memory
:
512
M
memory
:
128
M
cpus
:
'
0.5'
cpus
:
'
0.5'
# Frontend
# Frontend
(Production build with nginx - saves ~190MB RAM)
frontend
:
frontend
:
build
:
build
:
context
:
./frontend
context
:
./frontend
dockerfile
:
Dockerfile.prod
dockerfile
:
Dockerfile.prod
args
:
args
:
# Build-time envs for Vite
VITE_API_BASE_URL
:
${VITE_API_BASE_URL:-http://localhost:5000}
# Browser (người dùng) gọi trực tiếp vào host, nên dùng localhost:5000
VITE_CLERK_PUBLISHABLE_KEY
:
${VITE_CLERK_PUBLISHABLE_KEY:-}
VITE_API_BASE_URL
:
"
http://localhost:5000"
VITE_CLERK_PUBLISHABLE_KEY
:
${VITE_CLERK_PUBLISHABLE_KEY}
container_name
:
cuccu_frontend
container_name
:
cuccu_frontend
restart
:
unless-stopped
restart
:
unless-stopped
ports
:
ports
:
-
"
3001:80"
-
"
3001:80"
env_file
:
-
./frontend/.env
depends_on
:
depends_on
:
-
backend
-
backend
networks
:
networks
:
-
cuccu_network
-
cuccu_network
healthcheck
:
healthcheck
:
test
:
[
"
CMD"
,
"
curl"
,
"
-f
"
,
"
http://localhost:80"
]
test
:
[
"
CMD"
,
"
wget"
,
"
-q"
,
"
--spider
"
,
"
http://localhost:80"
]
interval
:
30s
interval
:
30s
timeout
:
10s
timeout
:
10s
retries
:
3
retries
:
3
start_period
:
30s
deploy
:
resources
:
limits
:
memory
:
32M
volumes
:
volumes
:
backend_data
:
backend_data
:
...
...
frontend/.env
View file @
2fec4891
...
@@ -2,4 +2,5 @@
...
@@ -2,4 +2,5 @@
VITE_CLERK_PUBLISHABLE_KEY=pk_test_Y29tbXVuYWwtc3VuYmVhbS0wLmNsZXJrLmFjY291bnRzLmRldiQ
VITE_CLERK_PUBLISHABLE_KEY=pk_test_Y29tbXVuYWwtc3VuYmVhbS0wLmNsZXJrLmFjY291bnRzLmRldiQ
# ====================== API URL ======================
# ====================== API URL ======================
VITE_API_URL=http://localhost:8080
# Dev mode: point directly to backend (no nginx proxy)
\ No newline at end of file
VITE_API_BASE_URL=http://160.191.50.138:5000
\ No newline at end of file
frontend/Dockerfile.dev
View file @
2fec4891
FROM node:
18
-alpine
FROM node:
22
-alpine
WORKDIR /app
WORKDIR /app
...
...
frontend/Dockerfile.prod
View file @
2fec4891
# Multi-stage build for production
# Multi-stage build for production
FROM node:
18
-alpine AS builder
FROM node:
22
-alpine AS builder
WORKDIR /app
WORKDIR /app
...
...
frontend/nginx.conf
View file @
2fec4891
...
@@ -15,6 +15,17 @@ server {
...
@@ -15,6 +15,17 @@ server {
add_header
X-Content-Type-Options
"nosniff"
always
;
add_header
X-Content-Type-Options
"nosniff"
always
;
add_header
X-XSS-Protection
"1
;
mode=block"
always
;
add_header
X-XSS-Protection
"1
;
mode=block"
always
;
# Proxy /api requests to backend container
location
/api/
{
proxy_pass
http://cuccu_backend:5000
;
proxy_http_version
1
.1
;
proxy_set_header
Host
$host
;
proxy_set_header
X-Real-IP
$remote_addr
;
proxy_set_header
X-Forwarded-For
$proxy_add_x_forwarded_for
;
proxy_set_header
X-Forwarded-Proto
$scheme
;
proxy_read_timeout
120s
;
}
# SPA routing - serve index.html for all routes
# SPA routing - serve index.html for all routes
location
/
{
location
/
{
try_files
$uri
$uri
/
/index.html
;
try_files
$uri
$uri
/
/index.html
;
...
...
frontend/src/components/ChatbotPanel.tsx
View file @
2fec4891
import
{
useState
,
useRef
,
useEffect
}
from
"react"
;
import
{
useState
,
useRef
,
useEffect
,
useImperativeHandle
,
forwardRef
,
useCallback
}
from
"react"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
XIcon
,
SendIcon
,
Loader2Icon
}
from
"lucide-react"
;
import
{
XIcon
,
SendIcon
,
Loader2Icon
,
CalendarIcon
}
from
"lucide-react"
;
import
{
API_ORIGIN
}
from
"@/service/apiClient"
;
import
{
getClerkSessionToken
}
from
"@/utils/clerk"
;
import
ReactMarkdown
,
{
Components
}
from
"react-markdown"
;
import
remarkGfm
from
"remark-gfm"
;
import
remarkBreaks
from
"remark-breaks"
;
import
{
useDateFilterNavigation
}
from
"@/hooks"
;
type
Message
=
{
type
Message
=
{
role
:
"user"
|
"ai"
;
role
:
"user"
|
"ai"
;
...
@@ -14,7 +20,11 @@ type ChatbotPanelProps = {
...
@@ -14,7 +20,11 @@ type ChatbotPanelProps = {
hideHeader
?:
boolean
;
hideHeader
?:
boolean
;
};
};
const
ChatbotPanel
=
({
className
,
onClose
,
hideHeader
=
false
}:
ChatbotPanelProps
)
=>
{
export
type
ChatbotPanelHandle
=
{
clearMessages
:
()
=>
Promise
<
void
>
;
};
const
ChatbotPanel
=
forwardRef
<
ChatbotPanelHandle
,
ChatbotPanelProps
>
(({
className
,
onClose
,
hideHeader
=
false
},
ref
)
=>
{
const
[
messages
,
setMessages
]
=
useState
<
Message
[]
>
([]);
const
[
messages
,
setMessages
]
=
useState
<
Message
[]
>
([]);
const
[
historyCursor
,
setHistoryCursor
]
=
useState
<
number
|
null
>
(
null
);
const
[
historyCursor
,
setHistoryCursor
]
=
useState
<
number
|
null
>
(
null
);
const
[
isHistoryLoading
,
setIsHistoryLoading
]
=
useState
(
false
);
const
[
isHistoryLoading
,
setIsHistoryLoading
]
=
useState
(
false
);
...
@@ -30,10 +40,22 @@ const ChatbotPanel = ({ className, onClose, hideHeader = false }: ChatbotPanelPr
...
@@ -30,10 +40,22 @@ const ChatbotPanel = ({ className, onClose, hideHeader = false }: ChatbotPanelPr
},
[
messages
]);
},
[
messages
]);
const
mapHistoryToMessages
=
(
items
:
any
[]):
Message
[]
=>
{
const
mapHistoryToMessages
=
(
items
:
any
[]):
Message
[]
=>
{
return
items
.
map
((
item
)
=>
({
return
items
.
map
((
item
)
=>
{
role
:
item
.
is_human
?
"user"
:
"ai"
,
if
(
item
.
is_human
)
{
content
:
String
(
item
.
message
??
""
),
return
{
role
:
"user"
as
const
,
content
:
String
(
item
.
message
??
""
)
};
}));
}
// AI messages may be stored as JSON — extract ai_response text
let
content
=
String
(
item
.
message
??
""
);
try
{
const
parsed
=
JSON
.
parse
(
content
);
if
(
parsed
&&
typeof
parsed
===
"object"
&&
parsed
.
ai_response
)
{
content
=
parsed
.
ai_response
;
}
}
catch
{
// Not JSON, use as-is
}
return
{
role
:
"ai"
as
const
,
content
};
});
};
};
const
loadHistory
=
async
(
isRefresh
:
boolean
)
=>
{
const
loadHistory
=
async
(
isRefresh
:
boolean
)
=>
{
...
@@ -41,10 +63,14 @@ const ChatbotPanel = ({ className, onClose, hideHeader = false }: ChatbotPanelPr
...
@@ -41,10 +63,14 @@ const ChatbotPanel = ({ className, onClose, hideHeader = false }: ChatbotPanelPr
setIsHistoryLoading
(
true
);
setIsHistoryLoading
(
true
);
try
{
try
{
const
token
=
await
getClerkSessionToken
();
const
cursor
=
isRefresh
?
null
:
historyCursor
;
const
cursor
=
isRefresh
?
null
:
historyCursor
;
const
url
=
`/api/history/me?limit=20
${
cursor
?
`&before_id=
${
cursor
}
`
:
""
}
`
;
const
url
=
`
${
API_ORIGIN
}
/api/history/me?limit=20
${
cursor
?
`&before_id=
${
cursor
}
`
:
""
}
`
;
const
headers
:
Record
<
string
,
string
>
=
{};
if
(
token
)
headers
[
"Authorization"
]
=
`Bearer
${
token
}
`
;
const
res
=
await
fetch
(
url
);
const
res
=
await
fetch
(
url
,
{
headers
}
);
if
(
!
res
.
ok
)
throw
new
Error
(
"Failed to load history"
);
if
(
!
res
.
ok
)
throw
new
Error
(
"Failed to load history"
);
const
data
=
await
res
.
json
();
const
data
=
await
res
.
json
();
...
@@ -54,7 +80,7 @@ const ChatbotPanel = ({ className, onClose, hideHeader = false }: ChatbotPanelPr
...
@@ -54,7 +80,7 @@ const ChatbotPanel = ({ className, onClose, hideHeader = false }: ChatbotPanelPr
if
(
items
.
length
===
0
)
{
if
(
items
.
length
===
0
)
{
if
(
isRefresh
&&
messages
.
length
===
0
)
{
if
(
isRefresh
&&
messages
.
length
===
0
)
{
// No history at all -> show default greeting
// No history at all -> show default greeting
setMessages
([{
role
:
"ai"
,
content
:
"
Hello! I'm CiCi. How can I help you with your notes tod
ay?"
}]);
setMessages
([{
role
:
"ai"
,
content
:
"
Xin chào! Mình là CuCu Assistant 😊 Bạn muốn tìm ghi chú gì hôm n
ay?"
}]);
}
}
setHistoryCursor
(
null
);
setHistoryCursor
(
null
);
return
;
return
;
...
@@ -74,13 +100,36 @@ const ChatbotPanel = ({ className, onClose, hideHeader = false }: ChatbotPanelPr
...
@@ -74,13 +100,36 @@ const ChatbotPanel = ({ className, onClose, hideHeader = false }: ChatbotPanelPr
}
catch
(
error
)
{
}
catch
(
error
)
{
console
.
error
(
"Failed to load history:"
,
error
);
console
.
error
(
"Failed to load history:"
,
error
);
if
(
isRefresh
&&
messages
.
length
===
0
)
{
if
(
isRefresh
&&
messages
.
length
===
0
)
{
setMessages
([{
role
:
"ai"
,
content
:
"
Hello! I'm CiCi. How can I help you with your notes tod
ay?"
}]);
setMessages
([{
role
:
"ai"
,
content
:
"
Xin chào! Mình là CuCu Assistant 😊 Bạn muốn tìm ghi chú gì hôm n
ay?"
}]);
}
}
}
finally
{
}
finally
{
setIsHistoryLoading
(
false
);
setIsHistoryLoading
(
false
);
}
}
};
};
const
clearMessages
=
async
()
=>
{
try
{
const
token
=
await
getClerkSessionToken
();
const
headers
:
Record
<
string
,
string
>
=
{};
if
(
token
)
headers
[
"Authorization"
]
=
`Bearer
${
token
}
`
;
const
res
=
await
fetch
(
`
${
API_ORIGIN
}
/api/history/me`
,
{
method
:
"DELETE"
,
headers
,
});
if
(
!
res
.
ok
)
throw
new
Error
(
"Failed to clear history"
);
setMessages
([{
role
:
"ai"
,
content
:
"Đã xóa lịch sử chat! Mình có thể giúp gì cho bạn?"
}]);
setHistoryCursor
(
null
);
}
catch
(
error
)
{
console
.
error
(
"Failed to clear history:"
,
error
);
}
};
useImperativeHandle
(
ref
,
()
=>
({
clearMessages
,
}));
// Load initial history on mount
// Load initial history on mount
useEffect
(()
=>
{
useEffect
(()
=>
{
void
loadHistory
(
true
);
void
loadHistory
(
true
);
...
@@ -95,35 +144,174 @@ const ChatbotPanel = ({ className, onClose, hideHeader = false }: ChatbotPanelPr
...
@@ -95,35 +144,174 @@ const ChatbotPanel = ({ className, onClose, hideHeader = false }: ChatbotPanelPr
setMessages
((
prev
)
=>
[...
prev
,
{
role
:
"user"
,
content
:
userMessage
}]);
setMessages
((
prev
)
=>
[...
prev
,
{
role
:
"user"
,
content
:
userMessage
}]);
setIsLoading
(
true
);
setIsLoading
(
true
);
// Add empty AI message placeholder for streaming
const
aiMsgIndex
=
messages
.
length
+
1
;
// +1 for user msg just added
setMessages
((
prev
)
=>
[...
prev
,
{
role
:
"ai"
,
content
:
""
}]);
try
{
try
{
const
response
=
await
fetch
(
"/api/agent/chat"
,
{
const
token
=
await
getClerkSessionToken
();
const
headers
:
Record
<
string
,
string
>
=
{
"Content-Type"
:
"application/json"
};
if
(
token
)
headers
[
"Authorization"
]
=
`Bearer
${
token
}
`
;
const
response
=
await
fetch
(
`
${
API_ORIGIN
}
/api/agent/chat/stream`
,
{
method
:
"POST"
,
method
:
"POST"
,
headers
:
{
"Content-Type"
:
"application/json"
}
,
headers
,
body
:
JSON
.
stringify
({
user_query
:
userMessage
})
body
:
JSON
.
stringify
({
user_query
:
userMessage
})
});
});
if
(
!
response
.
ok
)
throw
new
Error
(
"Failed to get response from AI"
);
if
(
!
response
.
ok
||
!
response
.
body
)
throw
new
Error
(
"Failed to get response from AI"
);
const
data
=
await
response
.
json
();
const
reader
=
response
.
body
.
getReader
();
if
(
data
.
status
===
"success"
&&
data
.
ai_response
)
{
const
decoder
=
new
TextDecoder
();
setMessages
((
prev
)
=>
[...
prev
,
{
role
:
"ai"
,
content
:
data
.
ai_response
}]);
let
buffer
=
""
;
}
else
{
throw
new
Error
(
"Invalid response format"
);
while
(
true
)
{
const
{
done
,
value
}
=
await
reader
.
read
();
if
(
done
)
break
;
buffer
+=
decoder
.
decode
(
value
,
{
stream
:
true
});
const
lines
=
buffer
.
split
(
"
\n
"
);
// Keep the last incomplete line in the buffer
buffer
=
lines
.
pop
()
||
""
;
for
(
const
line
of
lines
)
{
const
trimmed
=
line
.
trim
();
if
(
!
trimmed
.
startsWith
(
"data: "
))
continue
;
try
{
const
data
=
JSON
.
parse
(
trimmed
.
slice
(
6
));
if
(
data
.
token
)
{
// Append token to the last AI message
setMessages
((
prev
)
=>
{
const
updated
=
[...
prev
];
const
lastMsg
=
updated
[
updated
.
length
-
1
];
if
(
lastMsg
&&
lastMsg
.
role
===
"ai"
)
{
updated
[
updated
.
length
-
1
]
=
{
...
lastMsg
,
content
:
lastMsg
.
content
+
data
.
token
,
};
}
return
updated
;
});
}
if
(
data
.
done
)
{
// Final event — replace with full response (in case of parse differences)
if
(
data
.
ai_response
)
{
setMessages
((
prev
)
=>
{
const
updated
=
[...
prev
];
const
lastMsg
=
updated
[
updated
.
length
-
1
];
if
(
lastMsg
&&
lastMsg
.
role
===
"ai"
)
{
updated
[
updated
.
length
-
1
]
=
{
...
lastMsg
,
content
:
data
.
ai_response
,
};
}
return
updated
;
});
}
}
if
(
data
.
error
)
{
throw
new
Error
(
data
.
error
);
}
}
catch
(
parseErr
)
{
// Skip malformed SSE lines
console
.
warn
(
"SSE parse error:"
,
parseErr
);
}
}
}
}
}
catch
(
error
)
{
}
catch
(
error
)
{
console
.
error
(
"Chat error:"
,
error
);
console
.
error
(
"Chat error:"
,
error
);
setMessages
((
prev
)
=>
[...
prev
,
{
role
:
"ai"
,
content
:
"Sorry, I'm having trouble connecting to the server. Please check your backend."
}]);
setMessages
((
prev
)
=>
{
const
updated
=
[...
prev
];
const
lastMsg
=
updated
[
updated
.
length
-
1
];
if
(
lastMsg
&&
lastMsg
.
role
===
"ai"
&&
!
lastMsg
.
content
)
{
// Replace empty placeholder with error
updated
[
updated
.
length
-
1
]
=
{
...
lastMsg
,
content
:
"Xin lỗi, mình đang gặp sự cố kết nối. Vui lòng thử lại."
,
};
}
return
updated
;
});
}
finally
{
}
finally
{
setIsLoading
(
false
);
setIsLoading
(
false
);
}
}
};
};
// ─── Clickable dates in AI responses ───
const
navigateToDate
=
useDateFilterNavigation
();
const
renderTextWithClickableDates
=
useCallback
(
(
text
:
string
)
=>
{
// Match dates in format YYYY-MM-DD (standalone or inside parentheses)
const
dateRegex
=
/
(\d{4}
-
\d{2}
-
\d{2})
/g
;
const
parts
=
text
.
split
(
dateRegex
);
if
(
parts
.
length
===
1
)
return
text
;
// No dates found
return
parts
.
map
((
part
,
idx
)
=>
{
if
(
dateRegex
.
test
(
part
))
{
// Reset lastIndex since we're reusing regex
dateRegex
.
lastIndex
=
0
;
return
(
<
button
key=
{
idx
}
onClick=
{
()
=>
navigateToDate
(
part
)
}
className=
"inline-flex items-center gap-0.5 text-primary hover:underline cursor-pointer font-medium"
title=
{
`Xem ghi chú ngày ${part}`
}
type=
"button"
>
<
CalendarIcon
className=
"size-3 inline"
/>
{
part
}
</
button
>
);
}
return
part
;
});
},
[
navigateToDate
],
);
const
markdownComponents
:
Components
=
{
// Override text rendering in paragraphs, list items, strong, etc.
p
:
({
children
,
...
props
})
=>
(
<
p
{
...
props
}
>
{
Array
.
isArray
(
children
)
?
children
.
map
((
child
,
i
)
=>
typeof
child
===
"string"
?
<
span
key=
{
i
}
>
{
renderTextWithClickableDates
(
child
)
}
</
span
>
:
child
,
)
:
typeof
children
===
"string"
?
renderTextWithClickableDates
(
children
)
:
children
}
</
p
>
),
li
:
({
children
,
...
props
})
=>
(
<
li
{
...
props
}
>
{
Array
.
isArray
(
children
)
?
children
.
map
((
child
,
i
)
=>
typeof
child
===
"string"
?
<
span
key=
{
i
}
>
{
renderTextWithClickableDates
(
child
)
}
</
span
>
:
child
,
)
:
typeof
children
===
"string"
?
renderTextWithClickableDates
(
children
)
:
children
}
</
li
>
),
strong
:
({
children
,
...
props
})
=>
(
<
strong
{
...
props
}
>
{
typeof
children
===
"string"
?
renderTextWithClickableDates
(
children
)
:
children
}
</
strong
>
),
};
return
(
return
(
<
div
className=
{
cn
(
"w-full h-full flex flex-col bg-background border rounded-lg overflow-hidden shadow-sm"
,
className
)
}
>
<
div
className=
{
cn
(
"w-full h-full flex flex-col bg-background border rounded-lg overflow-hidden shadow-sm"
,
className
)
}
>
{
/* Header */
}
{
/* Header */
}
{
!
hideHeader
&&
(
{
!
hideHeader
&&
(
<
div
className=
"flex items-center justify-between p-4 border-b bg-muted/30"
>
<
div
className=
"flex items-center justify-between p-4 border-b bg-muted/30"
>
<
h2
className=
"font-semibold text-sm"
>
C
iCi
Assistant
</
h2
>
<
h2
className=
"font-semibold text-sm"
>
C
uCu
Assistant
</
h2
>
{
onClose
&&
(
{
onClose
&&
(
<
button
onClick=
{
onClose
}
className=
"p-1 hover:bg-muted rounded-md transition-colors"
>
<
button
onClick=
{
onClose
}
className=
"p-1 hover:bg-muted rounded-md transition-colors"
>
<
XIcon
className=
"size-4"
/>
<
XIcon
className=
"size-4"
/>
...
@@ -156,7 +344,15 @@ const ChatbotPanel = ({ className, onClose, hideHeader = false }: ChatbotPanelPr
...
@@ -156,7 +344,15 @@ const ChatbotPanel = ({ className, onClose, hideHeader = false }: ChatbotPanelPr
?
"bg-primary text-primary-content rounded-tr-none"
?
"bg-primary text-primary-content rounded-tr-none"
:
"bg-muted text-foreground rounded-tl-none border"
:
"bg-muted text-foreground rounded-tl-none border"
)
}
>
)
}
>
{
msg
.
content
}
{
msg
.
role
===
"ai"
?
(
<
div
className=
"markdown-content max-w-none break-words text-inherit"
>
<
ReactMarkdown
remarkPlugins=
{
[
remarkGfm
,
remarkBreaks
]
}
components=
{
markdownComponents
}
>
{
msg
.
content
}
</
ReactMarkdown
>
</
div
>
)
:
(
msg
.
content
)
}
</
div
>
</
div
>
</
div
>
</
div
>
))
}
))
}
...
@@ -179,7 +375,7 @@ const ChatbotPanel = ({ className, onClose, hideHeader = false }: ChatbotPanelPr
...
@@ -179,7 +375,7 @@ const ChatbotPanel = ({ className, onClose, hideHeader = false }: ChatbotPanelPr
<
input
<
input
value=
{
input
}
value=
{
input
}
onChange=
{
(
e
)
=>
setInput
(
e
.
target
.
value
)
}
onChange=
{
(
e
)
=>
setInput
(
e
.
target
.
value
)
}
placeholder=
"
Ask CiCi something
..."
placeholder=
"
Hỏi CuCu gì đó
..."
className=
"flex-1 bg-muted/50 border-none focus:ring-1 focus:ring-primary rounded-xl px-4 py-2 text-sm outline-none transition-all"
className=
"flex-1 bg-muted/50 border-none focus:ring-1 focus:ring-primary rounded-xl px-4 py-2 text-sm outline-none transition-all"
disabled=
{
isLoading
}
disabled=
{
isLoading
}
/>
/>
...
@@ -194,6 +390,8 @@ const ChatbotPanel = ({ className, onClose, hideHeader = false }: ChatbotPanelPr
...
@@ -194,6 +390,8 @@ const ChatbotPanel = ({ className, onClose, hideHeader = false }: ChatbotPanelPr
</
div
>
</
div
>
</
div
>
</
div
>
);
);
};
});
ChatbotPanel
.
displayName
=
"ChatbotPanel"
;
export
default
ChatbotPanel
;
export
default
ChatbotPanel
;
frontend/src/components/ChatbotWidget.tsx
View file @
2fec4891
import
{
MessageCircleIcon
,
XIcon
,
Maximize2Icon
,
Minimize2Icon
}
from
"lucide-react"
;
import
{
MessageCircleIcon
,
XIcon
,
Maximize2Icon
,
Minimize2Icon
,
Trash2Icon
}
from
"lucide-react"
;
import
{
useState
,
useRef
,
useCallback
,
useEffect
}
from
"react"
;
import
{
useState
,
useRef
,
useCallback
,
useEffect
}
from
"react"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
cn
}
from
"@/lib/utils"
;
import
ChatbotPanel
from
"./ChatbotPanel"
;
import
ChatbotPanel
,
{
type
ChatbotPanelHandle
}
from
"./ChatbotPanel"
;
type
Position
=
{
type
Position
=
{
x
:
number
;
x
:
number
;
...
@@ -44,6 +44,7 @@ const ChatbotWidget = ({ className }: { className?: string }) => {
...
@@ -44,6 +44,7 @@ const ChatbotWidget = ({ className }: { className?: string }) => {
const
hasDraggedRef
=
useRef
(
false
);
const
hasDraggedRef
=
useRef
(
false
);
const
dragRef
=
useRef
<
{
startX
:
number
;
startY
:
number
;
startPosX
:
number
;
startPosY
:
number
}
|
null
>
(
null
);
const
dragRef
=
useRef
<
{
startX
:
number
;
startY
:
number
;
startPosX
:
number
;
startPosY
:
number
}
|
null
>
(
null
);
const
animationFrameRef
=
useRef
<
number
|
null
>
(
null
);
const
animationFrameRef
=
useRef
<
number
|
null
>
(
null
);
const
chatbotPanelRef
=
useRef
<
ChatbotPanelHandle
>
(
null
);
// Save position to localStorage when it changes
// Save position to localStorage when it changes
useEffect
(()
=>
{
useEffect
(()
=>
{
...
@@ -112,11 +113,16 @@ const ChatbotWidget = ({ className }: { className?: string }) => {
...
@@ -112,11 +113,16 @@ const ChatbotWidget = ({ className }: { className?: string }) => {
},
[]);
},
[]);
const
handleMouseUp
=
useCallback
(()
=>
{
const
handleMouseUp
=
useCallback
(()
=>
{
setIsDragging
(
false
);
dragRef
.
current
=
null
;
},
[]);
// Toggle via onClick — only if user didn't drag
const
handleClick
=
useCallback
((
e
:
React
.
MouseEvent
)
=>
{
e
.
stopPropagation
();
if
(
!
hasDraggedRef
.
current
)
{
if
(
!
hasDraggedRef
.
current
)
{
setIsOpen
((
prev
)
=>
!
prev
);
setIsOpen
((
prev
)
=>
!
prev
);
}
}
setIsDragging
(
false
);
dragRef
.
current
=
null
;
},
[]);
},
[]);
useEffect
(()
=>
{
useEffect
(()
=>
{
...
@@ -189,7 +195,7 @@ const ChatbotWidget = ({ className }: { className?: string }) => {
...
@@ -189,7 +195,7 @@ const ChatbotWidget = ({ className }: { className?: string }) => {
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
isDragging
)
{
if
(
isDragging
)
{
window
.
addEventListener
(
"touchmove"
,
handleTouchMove
,
{
passive
:
true
});
window
.
addEventListener
(
"touchmove"
,
handleTouchMove
,
{
passive
:
true
});
window
.
addEventListener
(
"touchend"
,
handleTouchEnd
,
{
passive
:
true
}
);
window
.
addEventListener
(
"touchend"
,
handleTouchEnd
);
return
()
=>
{
return
()
=>
{
window
.
removeEventListener
(
"touchmove"
,
handleTouchMove
);
window
.
removeEventListener
(
"touchmove"
,
handleTouchMove
);
window
.
removeEventListener
(
"touchend"
,
handleTouchEnd
);
window
.
removeEventListener
(
"touchend"
,
handleTouchEnd
);
...
@@ -267,11 +273,21 @@ const ChatbotWidget = ({ className }: { className?: string }) => {
...
@@ -267,11 +273,21 @@ const ChatbotWidget = ({ className }: { className?: string }) => {
<
div
className=
"flex items-center gap-2"
>
<
div
className=
"flex items-center gap-2"
>
<
span
className=
"inline-flex size-2 rounded-full bg-emerald-500"
/>
<
span
className=
"inline-flex size-2 rounded-full bg-emerald-500"
/>
<
div
>
<
div
>
<
p
className=
"text-sm font-semibold leading-none"
>
C
iCi
Assistant
</
p
>
<
p
className=
"text-sm font-semibold leading-none"
>
C
uCu
Assistant
</
p
>
<
p
className=
"text-xs text-muted-foreground"
>
Notes chat
</
p
>
<
p
className=
"text-xs text-muted-foreground"
>
Notes chat
</
p
>
</
div
>
</
div
>
</
div
>
</
div
>
<
div
className=
"flex items-center gap-1"
>
<
div
className=
"flex items-center gap-1"
>
{
/* Nút Clear Messages */
}
<
button
type=
"button"
className=
"rounded-full p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
onClick=
{
()
=>
chatbotPanelRef
.
current
?.
clearMessages
()
}
aria
-
label=
"Clear messages"
title=
"Xóa lịch sử chat"
>
<
Trash2Icon
className=
"size-4"
/>
</
button
>
{
/* Nút Expand/Collapse */
}
{
/* Nút Expand/Collapse */
}
<
button
<
button
type=
"button"
type=
"button"
...
@@ -301,7 +317,7 @@ const ChatbotWidget = ({ className }: { className?: string }) => {
...
@@ -301,7 +317,7 @@ const ChatbotWidget = ({ className }: { className?: string }) => {
{
/* Chat Content - không có header riêng nữa */
}
{
/* Chat Content - không có header riêng nữa */
}
<
div
className=
"h-[calc(100%-52px)]"
>
<
div
className=
"h-[calc(100%-52px)]"
>
<
ChatbotPanel
variant=
"widget"
className=
"border-0 rounded-none"
hideHeader
/>
<
ChatbotPanel
ref=
{
chatbotPanelRef
}
variant=
"widget"
className=
"border-0 rounded-none"
hideHeader
/>
</
div
>
</
div
>
</
div
>
</
div
>
)
}
)
}
...
@@ -323,6 +339,7 @@ const ChatbotWidget = ({ className }: { className?: string }) => {
...
@@ -323,6 +339,7 @@ const ChatbotWidget = ({ className }: { className?: string }) => {
:
"bg-primary text-primary-foreground"
,
:
"bg-primary text-primary-foreground"
,
)
}
)
}
onMouseDown=
{
handleMouseDown
}
onMouseDown=
{
handleMouseDown
}
onClick=
{
handleClick
}
onTouchStart=
{
handleTouchStart
}
onTouchStart=
{
handleTouchStart
}
role=
"button"
role=
"button"
tabIndex=
{
0
}
tabIndex=
{
0
}
...
...
frontend/src/components/CreateShortcutDialog.tsx
View file @
2fec4891
...
@@ -14,6 +14,7 @@ import useLoading from "@/hooks/useLoading";
...
@@ -14,6 +14,7 @@ import useLoading from "@/hooks/useLoading";
import
{
handleError
}
from
"@/lib/error"
;
import
{
handleError
}
from
"@/lib/error"
;
import
{
Shortcut
,
ShortcutSchema
}
from
"@/types/proto/api/v1/shortcut_service_pb"
;
import
{
Shortcut
,
ShortcutSchema
}
from
"@/types/proto/api/v1/shortcut_service_pb"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
{
useTagCounts
}
from
"@/hooks/useUserQueries"
;
interface
Props
{
interface
Props
{
open
:
boolean
;
open
:
boolean
;
...
@@ -26,17 +27,36 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
...
@@ -26,17 +27,36 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
const
t
=
useTranslate
();
const
t
=
useTranslate
();
const
user
=
useCurrentUser
();
const
user
=
useCurrentUser
();
const
{
refetchSettings
}
=
useAuth
();
const
{
refetchSettings
}
=
useAuth
();
const
{
data
:
tagCounts
}
=
useTagCounts
(
true
);
const
[
title
,
setTitle
]
=
useState
<
string
>
(
initialShortcut
?.
title
||
""
);
const
[
title
,
setTitle
]
=
useState
<
string
>
(
initialShortcut
?.
title
||
""
);
const
[
tag
,
setTag
]
=
useState
<
string
>
(
""
);
const
[
selectedTags
,
setSelectedTags
]
=
useState
<
string
[]
>
([]);
const
[
tagSearch
,
setTagSearch
]
=
useState
(
""
);
const
[
timeRange
,
setTimeRange
]
=
useState
<
"all"
|
"today"
|
"last7"
|
"last30"
>
(
"all"
);
const
[
timeRange
,
setTimeRange
]
=
useState
<
"all"
|
"today"
|
"last7"
|
"last30"
>
(
"all"
);
const
[
pinnedOnly
,
setPinnedOnly
]
=
useState
(
false
);
const
[
pinnedOnly
,
setPinnedOnly
]
=
useState
(
false
);
const
[
hasTaskListOnly
,
setHasTaskListOnly
]
=
useState
(
false
);
const
[
hasTaskListOnly
,
setHasTaskListOnly
]
=
useState
(
false
);
const
[
hasLinkOnly
,
setHasLinkOnly
]
=
useState
(
false
);
const
[
hasLinkOnly
,
setHasLinkOnly
]
=
useState
(
false
);
const
[
hasCodeOnly
,
setHasCodeOnly
]
=
useState
(
false
);
const
[
hasCodeOnly
,
setHasCodeOnly
]
=
useState
(
false
);
const
[
untaggedOnly
,
setUntaggedOnly
]
=
useState
(
false
);
const
[
untaggedOnly
,
setUntaggedOnly
]
=
useState
(
false
);
const
[
contentSearch
,
setContentSearch
]
=
useState
(
""
);
const
requestState
=
useLoading
(
false
);
const
requestState
=
useLoading
(
false
);
const
isCreating
=
!
initialShortcut
;
const
isCreating
=
!
initialShortcut
;
// Get all available tags sorted by count (descending)
const
allTags
=
tagCounts
?
Object
.
entries
(
tagCounts
)
.
sort
(([,
a
],
[,
b
])
=>
b
-
a
)
.
map
(([
tag
])
=>
tag
)
:
[];
// Filter tags based on search input
const
filteredTags
=
tagSearch
.
trim
()
?
allTags
.
filter
((
tag
)
=>
tag
.
toLowerCase
().
includes
(
tagSearch
.
toLowerCase
()))
:
allTags
;
const
toggleTag
=
(
tag
:
string
)
=>
{
setSelectedTags
((
prev
)
=>
(
prev
.
includes
(
tag
)
?
prev
.
filter
((
t
)
=>
t
!==
tag
)
:
[...
prev
,
tag
]));
};
// Reset or hydrate form state when dialog opens / shortcut changes
// Reset or hydrate form state when dialog opens / shortcut changes
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
!
open
)
return
;
if
(
!
open
)
return
;
...
@@ -46,9 +66,18 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
...
@@ -46,9 +66,18 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
const
filter
=
initialShortcut
.
filter
||
""
;
const
filter
=
initialShortcut
.
filter
||
""
;
// Basic, best-effort parse from existing filter string
// Parse multiple tags from filter string
const
tagMatch
=
filter
.
match
(
/"
([^
"
]
+
)
"
\s
+in
\s
+tags/
);
// Format: "tag1" in tags && "tag2" in tags
setTag
(
tagMatch
?
tagMatch
[
1
]
:
""
);
const
tagMatches
=
filter
.
matchAll
(
/"
([^
"
]
+
)
"
\s
+in
\s
+tags/g
);
const
parsedTags
:
string
[]
=
[];
for
(
const
match
of
tagMatches
)
{
parsedTags
.
push
(
match
[
1
]);
}
setSelectedTags
(
parsedTags
);
// Parse content search
const
contentMatch
=
filter
.
match
(
/content
\.
contains
\(
"
([^
"
]
+
)
"
\)
/
);
setContentSearch
(
contentMatch
?
contentMatch
[
1
]
:
""
);
setPinnedOnly
(
filter
.
includes
(
"pinned"
));
setPinnedOnly
(
filter
.
includes
(
"pinned"
));
setHasTaskListOnly
(
filter
.
includes
(
"has_task_list"
));
setHasTaskListOnly
(
filter
.
includes
(
"has_task_list"
));
...
@@ -68,21 +97,28 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
...
@@ -68,21 +97,28 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
}
else
{
}
else
{
// New shortcut: reset to defaults
// New shortcut: reset to defaults
setTitle
(
""
);
setTitle
(
""
);
setTag
(
""
);
setSelectedTags
([]);
setTagSearch
(
""
);
setTimeRange
(
"all"
);
setTimeRange
(
"all"
);
setPinnedOnly
(
false
);
setPinnedOnly
(
false
);
setHasTaskListOnly
(
false
);
setHasTaskListOnly
(
false
);
setHasLinkOnly
(
false
);
setHasLinkOnly
(
false
);
setHasCodeOnly
(
false
);
setHasCodeOnly
(
false
);
setUntaggedOnly
(
false
);
setUntaggedOnly
(
false
);
setContentSearch
(
""
);
}
}
},
[
open
,
initialShortcut
]);
},
[
open
,
initialShortcut
]);
const
buildFilter
=
():
string
=>
{
const
buildFilter
=
():
string
=>
{
const
conditions
:
string
[]
=
[];
const
conditions
:
string
[]
=
[];
if
(
tag
.
trim
())
{
// Multiple tags support
conditions
.
push
(
`"
${
tag
.
trim
()}
" in tags`
);
for
(
const
tag
of
selectedTags
)
{
conditions
.
push
(
`"
${
tag
}
" in tags`
);
}
if
(
contentSearch
.
trim
())
{
conditions
.
push
(
`content.contains("
${
contentSearch
.
trim
()}
")`
);
}
}
if
(
pinnedOnly
)
{
if
(
pinnedOnly
)
{
...
@@ -135,19 +171,19 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
...
@@ -135,19 +171,19 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
filter
,
filter
,
},
},
});
});
toast
.
success
(
"
Create workspace successfully
"
);
toast
.
success
(
"
Tạo workspace thành công! 🎉
"
);
}
else
{
}
else
{
await
shortcutServiceClient
.
updateShortcut
({
await
shortcutServiceClient
.
updateShortcut
({
shortcut
:
{
shortcut
:
{
...
create
(
ShortcutSchema
,
{
...
create
(
ShortcutSchema
,
{
name
:
initialShortcut
!
.
name
,
name
:
initialShortcut
!
.
name
,
title
:
title
.
trim
(),
title
:
title
.
trim
(),
filter
,
filter
,
}),
}),
},
},
updateMask
:
create
(
FieldMaskSchema
,
{
paths
:
[
"title"
,
"filter"
]
}),
updateMask
:
create
(
FieldMaskSchema
,
{
paths
:
[
"title"
,
"filter"
]
}),
});
});
toast
.
success
(
"
Update workspace successfully
"
);
toast
.
success
(
"
Cập nhật workspace thành công! ✅
"
);
}
}
await
refetchSettings
();
await
refetchSettings
();
requestState
.
setFinish
();
requestState
.
setFinish
();
...
@@ -163,32 +199,112 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
...
@@ -163,32 +199,112 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
return
(
return
(
<
Dialog
open=
{
open
}
onOpenChange=
{
onOpenChange
}
>
<
Dialog
open=
{
open
}
onOpenChange=
{
onOpenChange
}
>
<
DialogContent
className=
"max-w-
md
"
>
<
DialogContent
className=
"max-w-
lg max-h-[85vh] overflow-y-auto
"
>
<
DialogHeader
>
<
DialogHeader
>
<
DialogTitle
>
{
`${isCreating ? t("common.create") : t("common.edit")}
${t("common.shortcuts")}
`
}
</
DialogTitle
>
<
DialogTitle
>
{
`${isCreating ? t("common.create") : t("common.edit")}
Workspace
`
}
</
DialogTitle
>
</
DialogHeader
>
</
DialogHeader
>
<
div
className=
"flex flex-col gap-4"
>
<
div
className=
"flex flex-col gap-4"
>
{
/* Title */
}
<
div
className=
"grid gap-2"
>
<
div
className=
"grid gap-2"
>
<
Label
htmlFor=
"title"
>
{
t
(
"common.title"
)
}
</
Label
>
<
Label
htmlFor=
"title"
>
{
t
(
"common.title"
)
}
</
Label
>
<
Input
id=
"title"
type=
"text"
placeholder=
""
value=
{
title
}
onChange=
{
(
e
)
=>
setTitle
(
e
.
target
.
value
)
}
/>
<
Input
id=
"title"
type=
"text"
placeholder=
"VD: 📋 Công việc, 🔥 Quan trọng..."
value=
{
title
}
onChange=
{
(
e
)
=>
setTitle
(
e
.
target
.
value
)
}
/>
</
div
>
</
div
>
{
/* Filter conditions */
}
<
div
className=
"grid gap-2"
>
<
div
className=
"grid gap-2"
>
<
Label
className=
"text-sm font-medium"
>
Điều kiện (chọn, không cần gõ cú pháp)
</
Label
>
<
Label
className=
"text-sm font-medium"
>
Điều kiện lọc
</
Label
>
<
div
className=
"space-y-3 rounded-md border bg-muted/40 p-3"
>
<
div
className=
"space-y-4 rounded-md border bg-muted/40 p-3"
>
{
/* Multi-tag selector */
}
<
div
className=
"grid gap-2"
>
<
div
className=
"grid gap-2"
>
<
Label
htmlFor=
"shortcut-tag"
className=
"text-xs text-muted-foreground"
>
<
Label
className=
"text-xs text-muted-foreground"
>
Tag (tùy chọn)
🏷️ Chọn Tags (
{
selectedTags
.
length
>
0
?
`đã chọn ${selectedTags.length}`
:
"chưa chọn"
}
)
</
Label
>
{
/* Selected tags display */
}
{
selectedTags
.
length
>
0
&&
(
<
div
className=
"flex flex-wrap gap-1.5 mb-1"
>
{
selectedTags
.
map
((
tag
)
=>
(
<
button
key=
{
tag
}
type=
"button"
onClick=
{
()
=>
toggleTag
(
tag
)
}
className=
"inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/80 transition-colors"
>
#
{
tag
}
<
span
className=
"ml-0.5 text-[10px] opacity-70"
>
✕
</
span
>
</
button
>
))
}
</
div
>
)
}
{
/* Search filter for tags */
}
{
allTags
.
length
>
5
&&
(
<
Input
type=
"text"
placeholder=
"🔍 Tìm tag..."
value=
{
tagSearch
}
onChange=
{
(
e
)
=>
setTagSearch
(
e
.
target
.
value
)
}
className=
"h-8 text-xs"
/>
)
}
{
/* Available tags grid */
}
{
allTags
.
length
>
0
?
(
<
div
className=
"flex flex-wrap gap-1.5 max-h-32 overflow-y-auto rounded-md border bg-background p-2"
>
{
filteredTags
.
map
((
tag
)
=>
{
const
isSelected
=
selectedTags
.
includes
(
tag
);
const
count
=
tagCounts
?.[
tag
]
||
0
;
return
(
<
button
key=
{
tag
}
type=
"button"
onClick=
{
()
=>
toggleTag
(
tag
)
}
className=
{
`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs border transition-all cursor-pointer ${isSelected
? "bg-primary text-primary-foreground border-primary shadow-sm"
: "bg-muted/50 text-muted-foreground border-border hover:border-primary/50 hover:bg-muted"
}`
}
>
<
span
className=
"opacity-60"
>
#
</
span
>
{
tag
}
<
span
className=
"text-[10px] opacity-50"
>
(
{
count
}
)
</
span
>
</
button
>
);
})
}
{
filteredTags
.
length
===
0
&&
tagSearch
&&
(
<
span
className=
"text-xs text-muted-foreground italic p-1"
>
Không tìm thấy tag
"
{
tagSearch
}
"
</
span
>
)
}
</
div
>
)
:
(
<
div
className=
"text-xs text-muted-foreground italic p-2 border rounded-md bg-background"
>
Chưa có tag nào. Hãy thêm #tag vào ghi chú trước.
</
div
>
)
}
</
div
>
{
/* Content search */
}
<
div
className=
"grid gap-2"
>
<
Label
className=
"text-xs text-muted-foreground"
>
🔍 Tìm theo nội dung (tùy chọn)
</
Label
>
</
Label
>
<
Input
<
Input
id=
"shortcut-tag"
type=
"text"
type=
"text"
placeholder=
'Ví dụ: work, kafka'
placeholder=
"VD: docker, kubernetes..."
value=
{
tag
}
value=
{
contentSearch
}
onChange=
{
(
e
)
=>
setTag
(
e
.
target
.
value
)
}
onChange=
{
(
e
)
=>
setContentSearch
(
e
.
target
.
value
)
}
className=
"h-8 text-sm"
/>
/>
</
div
>
</
div
>
{
/* Time range */
}
<
div
className=
"grid gap-2"
>
<
div
className=
"grid gap-2"
>
<
Label
className=
"text-xs text-muted-foreground"
>
Thời gian
</
Label
>
<
Label
className=
"text-xs text-muted-foreground"
>
⏰
Thời gian
</
Label
>
<
select
<
select
className=
"h-9 rounded-md border border-input bg-background px-2 text-sm"
className=
"h-9 rounded-md border border-input bg-background px-2 text-sm"
value=
{
timeRange
}
value=
{
timeRange
}
...
@@ -200,28 +316,30 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
...
@@ -200,28 +316,30 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
<
option
value=
"last30"
>
30 ngày gần đây
</
option
>
<
option
value=
"last30"
>
30 ngày gần đây
</
option
>
</
select
>
</
select
>
</
div
>
</
div
>
{
/* Content type filters */
}
<
div
className=
"grid gap-2"
>
<
div
className=
"grid gap-2"
>
<
Label
className=
"text-xs text-muted-foreground"
>
Lọc theo loại nội dung
</
Label
>
<
Label
className=
"text-xs text-muted-foreground"
>
📋
Lọc theo loại nội dung
</
Label
>
<
div
className=
"flex flex-col gap-2 text-sm text-foreground"
>
<
div
className=
"flex flex-col gap-2 text-sm text-foreground"
>
<
label
className=
"flex items-center gap-2"
>
<
label
className=
"flex items-center gap-2"
>
<
Checkbox
checked=
{
pinnedOnly
}
onCheckedChange=
{
(
v
)
=>
setPinnedOnly
(
Boolean
(
v
))
}
/>
<
Checkbox
checked=
{
pinnedOnly
}
onCheckedChange=
{
(
v
)
=>
setPinnedOnly
(
Boolean
(
v
))
}
/>
<
span
>
Chỉ ghi chú được ghim
</
span
>
<
span
>
📌
Chỉ ghi chú được ghim
</
span
>
</
label
>
</
label
>
<
label
className=
"flex items-center gap-2"
>
<
label
className=
"flex items-center gap-2"
>
<
Checkbox
checked=
{
hasTaskListOnly
}
onCheckedChange=
{
(
v
)
=>
setHasTaskListOnly
(
Boolean
(
v
))
}
/>
<
Checkbox
checked=
{
hasTaskListOnly
}
onCheckedChange=
{
(
v
)
=>
setHasTaskListOnly
(
Boolean
(
v
))
}
/>
<
span
>
Có checklist (task list)
</
span
>
<
span
>
✅
Có checklist (task list)
</
span
>
</
label
>
</
label
>
<
label
className=
"flex items-center gap-2"
>
<
label
className=
"flex items-center gap-2"
>
<
Checkbox
checked=
{
hasLinkOnly
}
onCheckedChange=
{
(
v
)
=>
setHasLinkOnly
(
Boolean
(
v
))
}
/>
<
Checkbox
checked=
{
hasLinkOnly
}
onCheckedChange=
{
(
v
)
=>
setHasLinkOnly
(
Boolean
(
v
))
}
/>
<
span
>
Có liên kết
</
span
>
<
span
>
🔗
Có liên kết
</
span
>
</
label
>
</
label
>
<
label
className=
"flex items-center gap-2"
>
<
label
className=
"flex items-center gap-2"
>
<
Checkbox
checked=
{
hasCodeOnly
}
onCheckedChange=
{
(
v
)
=>
setHasCodeOnly
(
Boolean
(
v
))
}
/>
<
Checkbox
checked=
{
hasCodeOnly
}
onCheckedChange=
{
(
v
)
=>
setHasCodeOnly
(
Boolean
(
v
))
}
/>
<
span
>
Có code block
</
span
>
<
span
>
💻
Có code block
</
span
>
</
label
>
</
label
>
<
label
className=
"flex items-center gap-2"
>
<
label
className=
"flex items-center gap-2"
>
<
Checkbox
checked=
{
untaggedOnly
}
onCheckedChange=
{
(
v
)
=>
setUntaggedOnly
(
Boolean
(
v
))
}
/>
<
Checkbox
checked=
{
untaggedOnly
}
onCheckedChange=
{
(
v
)
=>
setUntaggedOnly
(
Boolean
(
v
))
}
/>
<
span
>
Chưa có tag (ghi chú nháp)
</
span
>
<
span
>
📝
Chưa có tag (ghi chú nháp)
</
span
>
</
label
>
</
label
>
</
div
>
</
div
>
</
div
>
</
div
>
...
...
frontend/src/components/StatisticsView/MonthNavigator.tsx
View file @
2fec4891
...
@@ -8,9 +8,9 @@ import type { MonthNavigatorProps } from "@/types/statistics";
...
@@ -8,9 +8,9 @@ import type { MonthNavigatorProps } from "@/types/statistics";
export
const
MonthNavigator
=
({
visibleMonth
,
onMonthChange
,
activityStats
}:
MonthNavigatorProps
)
=>
{
export
const
MonthNavigator
=
({
visibleMonth
,
onMonthChange
,
activityStats
}:
MonthNavigatorProps
)
=>
{
const
[
isOpen
,
setIsOpen
]
=
useState
(
false
);
const
[
isOpen
,
setIsOpen
]
=
useState
(
false
);
const
currentMonth
=
new
Date
(
visibleMonth
);
const
currentYear
=
getYearFromDate
(
visibleMonth
);
const
currentYear
=
getYearFromDate
(
visibleMonth
);
const
currentMonthNum
=
getMonthFromDate
(
visibleMonth
);
const
currentMonthNum
=
getMonthFromDate
(
visibleMonth
);
const
currentMonth
=
new
Date
(
currentYear
,
currentMonthNum
-
1
,
1
);
const
handlePrevMonth
=
()
=>
{
const
handlePrevMonth
=
()
=>
{
onMonthChange
(
addMonths
(
visibleMonth
,
-
1
));
onMonthChange
(
addMonths
(
visibleMonth
,
-
1
));
...
...
frontend/src/pages/Home.tsx
View file @
2fec4891
import
{
useEffect
}
from
"react"
;
import
{
useEffect
}
from
"react"
;
import
{
useSearchParams
}
from
"react-router-dom"
;
import
{
useSearchParams
}
from
"react-router-dom"
;
import
{
MemoRenderContext
}
from
"@/components/MasonryView"
;
import
{
MemoRenderContext
}
from
"@/components/MasonryView"
;
import
MemoView
from
"@/components/MemoView"
;
import
MemoView
from
"@/components/MemoView"
;
...
@@ -53,7 +53,7 @@ const Home = () => {
...
@@ -53,7 +53,7 @@ const Home = () => {
renderer=
{
(
memo
:
Memo
,
context
?:
MemoRenderContext
)
=>
(
renderer=
{
(
memo
:
Memo
,
context
?:
MemoRenderContext
)
=>
(
<
MemoView
key=
{
`${memo.name}-${memo.displayTime}`
}
memo=
{
memo
}
showVisibility
showPinned
compact=
{
context
?.
compact
}
/>
<
MemoView
key=
{
`${memo.name}-${memo.displayTime}`
}
memo=
{
memo
}
showVisibility
showPinned
compact=
{
context
?.
compact
}
/>
)
}
)
}
listSort=
{
(
memos
)
=>
memos
.
filter
((
m
)
=>
!
m
.
pinned
)
}
// Exclude pinned from regular list
listSort=
{
listSort
}
orderBy=
{
orderBy
}
orderBy=
{
orderBy
}
filter=
{
memoFilter
}
filter=
{
memoFilter
}
/>
/>
...
...
frontend/src/service/apiClient.ts
View file @
2fec4891
...
@@ -2,9 +2,10 @@ import { redirectOnAuthFailure } from "@/utils/auth-redirect";
...
@@ -2,9 +2,10 @@ import { redirectOnAuthFailure } from "@/utils/auth-redirect";
import
{
getClerkSessionToken
}
from
"@/utils/clerk"
;
import
{
getClerkSessionToken
}
from
"@/utils/clerk"
;
import
type
{
RequestOptions
}
from
"./types"
;
import
type
{
RequestOptions
}
from
"./types"
;
// Call backend directly (bypass Vite proxy).
// API origin - empty string = relative URLs (proxied via nginx in Docker).
// Override via VITE_API_BASE_URL, e.g. "http://localhost:5000"
// Override via VITE_API_BASE_URL, e.g. "http://localhost:5000" for local dev without Docker.
export
const
API_ORIGIN
=
(
import
.
meta
.
env
.
VITE_API_BASE_URL
as
string
|
undefined
)
||
"http://localhost:5000"
;
const
_envOrigin
=
import
.
meta
.
env
.
VITE_API_BASE_URL
as
string
|
undefined
;
export
const
API_ORIGIN
:
string
=
(
_envOrigin
!==
undefined
&&
_envOrigin
!==
""
)
?
_envOrigin
:
""
;
export
const
API_BASE
=
`
${
API_ORIGIN
}
/api/v1`
;
export
const
API_BASE
=
`
${
API_ORIGIN
}
/api/v1`
;
const
parseBody
=
async
(
response
:
Response
):
Promise
<
unknown
>
=>
{
const
parseBody
=
async
(
response
:
Response
):
Promise
<
unknown
>
=>
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment