Commit 90c34457 authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

update

parent 662e55c8
- generic [active] [ref=e1]:
- complementary [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]: CA
- generic [ref=e5]:
- heading "Canifa AI" [level=2] [ref=e6]
- text: Admin Console
- generic [ref=e7]:
- generic [ref=e8]:
- generic [ref=e9]: Main
- generic [ref=e10] [cursor=pointer]:
- generic [ref=e11]: RM
- generic [ref=e12]: Kế hoạch phát triển
- generic [ref=e15] [cursor=pointer]: Sơ đồ hoạt động
- generic [ref=e16] [cursor=pointer]:
- generic [ref=e18]: Chatbot
- generic [ref=e19]: LIVE
- generic [ref=e22] [cursor=pointer]: History
- generic [ref=e25] [cursor=pointer]: Product Perf.
- generic [ref=e26] [cursor=pointer]:
- generic [ref=e28]: AI Data Analyst
- generic [ref=e29]: NEW
- generic [ref=e30] [cursor=pointer]:
- generic [ref=e31]: SQL
- generic [ref=e32]: AI sinh SQL
- generic [ref=e33]: NEW
- generic [ref=e34] [cursor=pointer]:
- generic [ref=e35]: LIVE
- generic [ref=e36]: Realtime Monitor
- generic [ref=e37]: LIVE
- generic [ref=e38] [cursor=pointer]:
- generic [ref=e39]: PO
- generic [ref=e40]: Prompt Optimizer
- generic [ref=e41]: NEW
- generic [ref=e42] [cursor=pointer]:
- generic [ref=e43]: US
- generic [ref=e44]: User Simulator
- generic [ref=e45]: NEW
- generic [ref=e46] [cursor=pointer]:
- generic [ref=e47]: UI
- generic [ref=e48]: User Insight
- generic [ref=e49]: NEW
- generic [ref=e50] [cursor=pointer]:
- generic [ref=e51]: RT
- generic [ref=e52]: Regression Test
- generic [ref=e53]: NEW
- generic [ref=e54] [cursor=pointer]:
- generic [ref=e55]: ST
- generic [ref=e56]: Stress Test
- generic [ref=e57]: NEW
- generic [ref=e58] [cursor=pointer]:
- generic [ref=e59]: CR
- generic [ref=e60]: Competitor Research
- generic [ref=e61]: NEW
- generic [ref=e62]:
- generic [ref=e63]: Workspace
- generic [ref=e66] [cursor=pointer]: Resources
- generic [ref=e69] [cursor=pointer]: Team Notes
- generic [ref=e72] [cursor=pointer]: Changelog
- generic [ref=e75] [cursor=pointer]: Hướng dẫn
- generic [ref=e76]:
- generic [ref=e77]: Thử nghiệm
- generic [ref=e78] [cursor=pointer]:
- generic [ref=e80]: Text-to-SQL
- generic [ref=e81]: BETA
- generic [ref=e82] [cursor=pointer]:
- generic [ref=e84]: DB Test
- generic [ref=e85]: BETA
- generic [ref=e86] [cursor=pointer]:
- generic [ref=e88]: Feedback Demo
- generic [ref=e89]: NEW
- generic [ref=e90] [cursor=pointer]:
- generic [ref=e92]: Chatbot (Dev)
- generic [ref=e93]: DEV
- generic [ref=e96] [cursor=pointer]: Cache Manager
- generic [ref=e97]:
- generic [ref=e98]: External
- link "API Docs" [ref=e99] [cursor=pointer]:
- /url: /docs
- generic [ref=e101]: API Docs
- link "ReDoc" [ref=e102] [cursor=pointer]:
- /url: /redoc
- generic [ref=e104]: ReDoc
- generic [ref=e106]:
- generic [ref=e107]:
- generic [ref=e108]: U
- generic [ref=e109]: user
- button "⚙" [ref=e110]
- button "⏻" [ref=e111]
- generic [ref=e112]:
- strong [ref=e114]: v2.5.0
- text: · Online
- iframe [ref=e116]:
- generic [ref=f1e2]:
- generic [ref=f1e4]:
- heading "📝 Team Notes" [level=1] [ref=f1e5]
- paragraph [ref=f1e6]: Notes, pinned docs & team workspace
- generic [ref=f1e7]:
- generic [ref=f1e8]:
- heading "📝 Team Notes & Pinned Docs" [level=2] [ref=f1e9]
- button "+ New Note" [ref=f1e10] [cursor=pointer]
- generic [ref=f1e11]:
- button "All" [ref=f1e12] [cursor=pointer]
- button "📌 Pinned" [ref=f1e13] [cursor=pointer]
- button "📝 Notes" [ref=f1e14] [cursor=pointer]
- button "📄 Docs" [ref=f1e15] [cursor=pointer]
- button "? To-do" [ref=f1e16] [cursor=pointer]
- button "📢 Announcements" [ref=f1e17] [cursor=pointer]
- generic [ref=f1e18]:
- generic [ref=f1e19]:
- generic [ref=f1e20]:
- generic [ref=f1e21]: 📝 note
- generic [ref=f1e22]: 📌
- generic [ref=f1e23]: v2.0 — Nâng cấp lớn (kế hoạch)
- generic [ref=f1e24]: "Prompt Engineering v2: rewrite toàn bộ, multi-turn context, persona nâng cao Giao diện chatbot mới: dark mode, product cards, quick replies Multi-Agent: Sales Agent + Support Agent + Analytics Agent phối hợp A/B Testing Engine: so sánh prompt/model variants trên live traffic Customer Analytics Dashboard: phân tích hành vi khách hàng từ chat data"
- generic [ref=f1e25]:
- generic [ref=f1e26]: 27 ph�t tru?c
- generic [ref=f1e27]:
- button "✏️" [ref=f1e28] [cursor=pointer]
- button "✏️" [ref=f1e29] [cursor=pointer]
- button "🗑️" [ref=f1e30] [cursor=pointer]
- generic [ref=f1e31]:
- generic [ref=f1e32]:
- generic [ref=f1e33]: 📝 note
- generic [ref=f1e34]: 📌
- generic [ref=f1e35]: v1.1 — Đổi model (thử nghiệm)
- generic [ref=f1e36]: "Chuyển GPT-4.1-mini Gemini 3.1 Flash-Lite Giảm ~55% chi phí per request Rút gọn prompt 56%, format phù hợp Gemini Benchmark: 20 test cases so sánh Score/Latency/Cost chế rollback về GPT nếu Gemini không ổn"
- generic [ref=f1e37]:
- generic [ref=f1e38]: 3 ng�y tru?c
- generic [ref=f1e39]:
- button "✏️" [ref=f1e40] [cursor=pointer]
- button "✏️" [ref=f1e41] [cursor=pointer]
- button "🗑️" [ref=f1e42] [cursor=pointer]
- generic [ref=f1e43]:
- generic [ref=f1e44]:
- generic [ref=f1e45]: 📝 note
- generic [ref=f1e46]: 📌
- generic [ref=f1e47]: v1.0 — Production
- generic [ref=f1e48]: "Model: GPT-4.1-mini Search: Vector search (pgvector, IVFFlat) Stock: API Magento realtime Prompt: Module v1 (persona, guardrails, sales flow) Observability: Langfuse tracing + scoring Chat: Lưu lịch sử + Like/Dislike feedback"
- generic [ref=f1e49]:
- generic [ref=f1e50]: 3 ng�y tru?c
- generic [ref=f1e51]:
- button "✏️" [ref=f1e52] [cursor=pointer]
- button "✏️" [ref=f1e53] [cursor=pointer]
- button "🗑️" [ref=f1e54] [cursor=pointer]
- generic [ref=f1e55]:
- generic [ref=f1e57]: 📝 note
- generic [ref=f1e58]: v1.2 — Đổi logic lấy dữ liệu (thử nghiệm)
- generic [ref=f1e59]: "Hybrid Search: vector + keyword thay vector-only HNSW Index thay IVFFlat recall tăng ~15% Filters nâng cao: lọc giá, màu, size, danh mục trước khi search Caching layer (Redis): cache kết quả search phổ biến, giảm latency"
- generic [ref=f1e60]:
- generic [ref=f1e61]: 27 ph�t tru?c
- generic [ref=f1e62]:
- button "✏️" [ref=f1e63] [cursor=pointer]
- button "✏️" [ref=f1e64] [cursor=pointer]
- button "🗑️" [ref=f1e65] [cursor=pointer]
\ No newline at end of file
- generic [active] [ref=e1]:
- complementary [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]: CA
- generic [ref=e5]:
- heading "Canifa AI" [level=2] [ref=e6]
- text: Admin Console
- generic [ref=e7]:
- generic [ref=e8]:
- generic [ref=e9]: Main
- generic [ref=e10] [cursor=pointer]:
- generic [ref=e11]: RM
- generic [ref=e12]: Kế hoạch phát triển
- generic [ref=e15] [cursor=pointer]: Sơ đồ hoạt động
- generic [ref=e16] [cursor=pointer]:
- generic [ref=e18]: Chatbot
- generic [ref=e19]: LIVE
- generic [ref=e22] [cursor=pointer]: History
- generic [ref=e25] [cursor=pointer]: Product Perf.
- generic [ref=e26] [cursor=pointer]:
- generic [ref=e28]: AI Data Analyst
- generic [ref=e29]: NEW
- generic [ref=e30] [cursor=pointer]:
- generic [ref=e31]: SQL
- generic [ref=e32]: AI sinh SQL
- generic [ref=e33]: NEW
- generic [ref=e34] [cursor=pointer]:
- generic [ref=e35]: LIVE
- generic [ref=e36]: Realtime Monitor
- generic [ref=e37]: LIVE
- generic [ref=e38] [cursor=pointer]:
- generic [ref=e39]: PO
- generic [ref=e40]: Prompt Optimizer
- generic [ref=e41]: NEW
- generic [ref=e42] [cursor=pointer]:
- generic [ref=e43]: US
- generic [ref=e44]: User Simulator
- generic [ref=e45]: NEW
- generic [ref=e46] [cursor=pointer]:
- generic [ref=e47]: UI
- generic [ref=e48]: User Insight
- generic [ref=e49]: NEW
- generic [ref=e50] [cursor=pointer]:
- generic [ref=e51]: RT
- generic [ref=e52]: Regression Test
- generic [ref=e53]: NEW
- generic [ref=e54] [cursor=pointer]:
- generic [ref=e55]: ST
- generic [ref=e56]: Stress Test
- generic [ref=e57]: NEW
- generic [ref=e58] [cursor=pointer]:
- generic [ref=e59]: CR
- generic [ref=e60]: Competitor Research
- generic [ref=e61]: NEW
- generic [ref=e62]:
- generic [ref=e63]: Workspace
- generic [ref=e66] [cursor=pointer]: Resources
- generic [ref=e69] [cursor=pointer]: Team Notes
- generic [ref=e72] [cursor=pointer]: Changelog
- generic [ref=e75] [cursor=pointer]: Hướng dẫn
- generic [ref=e76]:
- generic [ref=e77]: Thử nghiệm
- generic [ref=e78] [cursor=pointer]:
- generic [ref=e80]: Text-to-SQL
- generic [ref=e81]: BETA
- generic [ref=e82] [cursor=pointer]:
- generic [ref=e84]: DB Test
- generic [ref=e85]: BETA
- generic [ref=e86] [cursor=pointer]:
- generic [ref=e88]: Feedback Demo
- generic [ref=e89]: NEW
- generic [ref=e90] [cursor=pointer]:
- generic [ref=e92]: Chatbot (Dev)
- generic [ref=e93]: DEV
- generic [ref=e96] [cursor=pointer]: Cache Manager
- generic [ref=e97]:
- generic [ref=e98]: External
- link "API Docs" [ref=e99] [cursor=pointer]:
- /url: /docs
- generic [ref=e101]: API Docs
- link "ReDoc" [ref=e102] [cursor=pointer]:
- /url: /redoc
- generic [ref=e104]: ReDoc
- generic [ref=e106]:
- generic [ref=e107]:
- generic [ref=e108]: U
- generic [ref=e109]: user
- button "⚙" [ref=e110]
- button "⏻" [ref=e111]
- generic [ref=e112]:
- strong [ref=e114]: v2.5.0
- text: · Online
- iframe [ref=e116]:
- generic [ref=f2e2]:
- generic [ref=f2e4]:
- heading "📝 Team Notes" [level=1] [ref=f2e5]
- paragraph [ref=f2e6]: Notes, pinned docs & team workspace
- generic [ref=f2e7]:
- generic [ref=f2e8]:
- heading "📝 Team Notes & Pinned Docs" [level=2] [ref=f2e9]
- button "+ New Note" [ref=f2e10] [cursor=pointer]
- generic [ref=f2e11]:
- button "All" [ref=f2e12] [cursor=pointer]
- button "📌 Pinned" [ref=f2e13] [cursor=pointer]
- button "📝 Notes" [ref=f2e14] [cursor=pointer]
- button "📄 Docs" [ref=f2e15] [cursor=pointer]
- button "✅ To-do" [ref=f2e16] [cursor=pointer]
- button "📢 Announcements" [ref=f2e17] [cursor=pointer]
- generic [ref=f2e18]:
- generic [ref=f2e19]:
- generic [ref=f2e20]:
- generic [ref=f2e21]: 📝 note
- generic [ref=f2e22]: 📌
- generic [ref=f2e23]: v2.0 — Nâng cấp lớn (kế hoạch)
- generic [ref=f2e24]: "Prompt Engineering v2: rewrite toàn bộ, multi-turn context, persona nâng cao Giao diện chatbot mới: dark mode, product cards, quick replies Multi-Agent: Sales Agent + Support Agent + Analytics Agent phối hợp A/B Testing Engine: so sánh prompt/model variants trên live traffic Customer Analytics Dashboard: phân tích hành vi khách hàng từ chat data"
- generic [ref=f2e25]:
- generic [ref=f2e26]: 38 phút trước
- generic [ref=f2e27]:
- button "✏️" [ref=f2e28] [cursor=pointer]
- button "✏️" [ref=f2e29] [cursor=pointer]
- button "🗑️" [ref=f2e30] [cursor=pointer]
- generic [ref=f2e31]:
- generic [ref=f2e32]:
- generic [ref=f2e33]: 📝 note
- generic [ref=f2e34]: 📌
- generic [ref=f2e35]: v1.1 — Đổi model (thử nghiệm)
- generic [ref=f2e36]: "Chuyển GPT-4.1-mini Gemini 3.1 Flash-Lite Giảm ~55% chi phí per request Rút gọn prompt 56%, format phù hợp Gemini Benchmark: 20 test cases so sánh Score/Latency/Cost chế rollback về GPT nếu Gemini không ổn"
- generic [ref=f2e37]:
- generic [ref=f2e38]: 3 ngày trước
- generic [ref=f2e39]:
- button "✏️" [ref=f2e40] [cursor=pointer]
- button "✏️" [ref=f2e41] [cursor=pointer]
- button "🗑️" [ref=f2e42] [cursor=pointer]
- generic [ref=f2e43]:
- generic [ref=f2e44]:
- generic [ref=f2e45]: 📝 note
- generic [ref=f2e46]: 📌
- generic [ref=f2e47]: v1.0 — Production
- generic [ref=f2e48]: "Model: GPT-4.1-mini Search: Vector search (pgvector, IVFFlat) Stock: API Magento realtime Prompt: Module v1 (persona, guardrails, sales flow) Observability: Langfuse tracing + scoring Chat: Lưu lịch sử + Like/Dislike feedback"
- generic [ref=f2e49]:
- generic [ref=f2e50]: 3 ngày trước
- generic [ref=f2e51]:
- button "✏️" [ref=f2e52] [cursor=pointer]
- button "✏️" [ref=f2e53] [cursor=pointer]
- button "🗑️" [ref=f2e54] [cursor=pointer]
- generic [ref=f2e55]:
- generic [ref=f2e57]: 📝 note
- generic [ref=f2e58]: v1.2 — Đổi logic lấy dữ liệu (thử nghiệm)
- generic [ref=f2e59]: "Hybrid Search: vector + keyword thay vector-only HNSW Index thay IVFFlat recall tăng ~15% Filters nâng cao: lọc giá, màu, size, danh mục trước khi search Caching layer (Redis): cache kết quả search phổ biến, giảm latency"
- generic [ref=f2e60]:
- generic [ref=f2e61]: 38 phút trước
- generic [ref=f2e62]:
- button "✏️" [ref=f2e63] [cursor=pointer]
- button "✏️" [ref=f2e64] [cursor=pointer]
- button "🗑️" [ref=f2e65] [cursor=pointer]
\ No newline at end of file
- generic [active] [ref=e1]:
- complementary [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]: CA
- generic [ref=e5]:
- heading "Canifa AI" [level=2] [ref=e6]
- text: Admin Console
- generic [ref=e7]:
- generic [ref=e8]:
- generic [ref=e9]: Main
- generic [ref=e10] [cursor=pointer]:
- generic [ref=e11]: RM
- generic [ref=e12]: Kế hoạch phát triển
- generic [ref=e15] [cursor=pointer]: Sơ đồ hoạt động
- generic [ref=e16] [cursor=pointer]:
- generic [ref=e18]: Chatbot
- generic [ref=e19]: LIVE
- generic [ref=e22] [cursor=pointer]: History
- generic [ref=e25] [cursor=pointer]: Product Perf.
- generic [ref=e26] [cursor=pointer]:
- generic [ref=e28]: AI Data Analyst
- generic [ref=e29]: NEW
- generic [ref=e30] [cursor=pointer]:
- generic [ref=e31]: SQL
- generic [ref=e32]: AI sinh SQL
- generic [ref=e33]: NEW
- generic [ref=e34] [cursor=pointer]:
- generic [ref=e35]: LIVE
- generic [ref=e36]: Realtime Monitor
- generic [ref=e37]: LIVE
- generic [ref=e38] [cursor=pointer]:
- generic [ref=e39]: PO
- generic [ref=e40]: Prompt Optimizer
- generic [ref=e41]: NEW
- generic [ref=e42] [cursor=pointer]:
- generic [ref=e43]: US
- generic [ref=e44]: User Simulator
- generic [ref=e45]: NEW
- generic [ref=e46] [cursor=pointer]:
- generic [ref=e47]: UI
- generic [ref=e48]: User Insight
- generic [ref=e49]: NEW
- generic [ref=e50] [cursor=pointer]:
- generic [ref=e51]: RT
- generic [ref=e52]: Regression Test
- generic [ref=e53]: NEW
- generic [ref=e54] [cursor=pointer]:
- generic [ref=e55]: ST
- generic [ref=e56]: Stress Test
- generic [ref=e57]: NEW
- generic [ref=e58] [cursor=pointer]:
- generic [ref=e59]: CR
- generic [ref=e60]: Competitor Research
- generic [ref=e61]: NEW
- generic [ref=e62]:
- generic [ref=e63]: Workspace
- generic [ref=e66] [cursor=pointer]: Resources
- generic [ref=e69] [cursor=pointer]: Team Notes
- generic [ref=e72] [cursor=pointer]: Changelog
- generic [ref=e75] [cursor=pointer]: Hướng dẫn
- generic [ref=e76]:
- generic [ref=e77]: Thử nghiệm
- generic [ref=e78] [cursor=pointer]:
- generic [ref=e80]: Text-to-SQL
- generic [ref=e81]: BETA
- generic [ref=e82] [cursor=pointer]:
- generic [ref=e84]: DB Test
- generic [ref=e85]: BETA
- generic [ref=e86] [cursor=pointer]:
- generic [ref=e88]: Feedback Demo
- generic [ref=e89]: NEW
- generic [ref=e90] [cursor=pointer]:
- generic [ref=e92]: Chatbot (Dev)
- generic [ref=e93]: DEV
- generic [ref=e96] [cursor=pointer]: Cache Manager
- generic [ref=e97]:
- generic [ref=e98]: External
- link "API Docs" [ref=e99] [cursor=pointer]:
- /url: /docs
- generic [ref=e101]: API Docs
- link "ReDoc" [ref=e102] [cursor=pointer]:
- /url: /redoc
- generic [ref=e104]: ReDoc
- generic [ref=e106]:
- generic [ref=e107]:
- generic [ref=e108]: U
- generic [ref=e109]: user
- button "⚙" [ref=e110]
- button "⏻" [ref=e111]
- generic [ref=e112]:
- strong [ref=e114]: v2.5.0
- text: · Online
- iframe [ref=e116]:
- generic [ref=f3e2]:
- generic [ref=f3e3]:
- generic [ref=f3e4]:
- heading "Customer Insight" [level=2] [ref=f3e5]
- paragraph [ref=f3e6]: Canifa CDP — Hồ sơ 360° & Dự đoán hành vi
- textbox "Tìm theo tên, email, SĐT..." [ref=f3e7]
- generic [ref=f3e8]:
- generic [ref=f3e9] [cursor=pointer]:
- generic [ref=f3e10]: "N"
- generic [ref=f3e11]:
- generic [ref=f3e12]: Nguyễn Thị Lan
- generic [ref=f3e13]: lan.nguyen@gmail.com
- generic [ref=f3e14]:
- generic [ref=f3e15]: "82"
- generic [ref=f3e16]: Health
- generic [ref=f3e17] [cursor=pointer]:
- generic [ref=f3e18]: T
- generic [ref=f3e19]:
- generic [ref=f3e20]: Trần Văn Minh
- generic [ref=f3e21]: minh.tran@outlook.com
- generic [ref=f3e22]:
- generic [ref=f3e23]: "61"
- generic [ref=f3e24]: Health
- generic [ref=f3e25] [cursor=pointer]:
- generic [ref=f3e26]: L
- generic [ref=f3e27]:
- generic [ref=f3e28]: Lê Phương Anh
- generic [ref=f3e29]: phuonganh.le@yahoo.com
- generic [ref=f3e30]:
- generic [ref=f3e31]: "95"
- generic [ref=f3e32]: Health
- generic [ref=f3e33] [cursor=pointer]:
- generic [ref=f3e34]: P
- generic [ref=f3e35]:
- generic [ref=f3e36]: Phạm Quốc Hùng
- generic [ref=f3e37]: hung.pham@canifa.vn
- generic [ref=f3e38]:
- generic [ref=f3e39]: "35"
- generic [ref=f3e40]: Health
- generic [ref=f3e41] [cursor=pointer]:
- generic [ref=f3e42]: Đ
- generic [ref=f3e43]:
- generic [ref=f3e44]: Đỗ Thanh Hà
- generic [ref=f3e45]: ha.do@gmail.com
- generic [ref=f3e46]:
- generic [ref=f3e47]: "72"
- generic [ref=f3e48]: Health
- generic [ref=f3e49] [cursor=pointer]:
- generic [ref=f3e50]: B
- generic [ref=f3e51]:
- generic [ref=f3e52]: Bùi Đức Thịnh
- generic [ref=f3e53]: thinh.bui@company.com
- generic [ref=f3e54]:
- generic [ref=f3e55]: "88"
- generic [ref=f3e56]: Health
- generic [ref=f3e59]:
- generic [ref=f3e60]: 📊
- paragraph [ref=f3e61]: Chọn một khách hàng
- paragraph [ref=f3e62]: Click vào tên bên trái để xem hồ sơ 360° & AI predictions
\ No newline at end of file
- generic [active] [ref=e1]:
- complementary [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]: CA
- generic [ref=e5]:
- heading "Canifa AI" [level=2] [ref=e6]
- text: Admin Console
- generic [ref=e7]:
- generic [ref=e8]:
- generic [ref=e9]: Main
- generic [ref=e10] [cursor=pointer]:
- generic [ref=e11]: RM
- generic [ref=e12]: Kế hoạch phát triển
- generic [ref=e15] [cursor=pointer]: Sơ đồ hoạt động
- generic [ref=e16] [cursor=pointer]:
- generic [ref=e18]: Chatbot
- generic [ref=e19]: LIVE
- generic [ref=e22] [cursor=pointer]: History
- generic [ref=e25] [cursor=pointer]: Product Perf.
- generic [ref=e26] [cursor=pointer]:
- generic [ref=e28]: AI Data Analyst
- generic [ref=e29]: NEW
- generic [ref=e30] [cursor=pointer]:
- generic [ref=e31]: SQL
- generic [ref=e32]: AI sinh SQL
- generic [ref=e33]: NEW
- generic [ref=e34] [cursor=pointer]:
- generic [ref=e35]: LIVE
- generic [ref=e36]: Realtime Monitor
- generic [ref=e37]: LIVE
- generic [ref=e38] [cursor=pointer]:
- generic [ref=e39]: PO
- generic [ref=e40]: Prompt Optimizer
- generic [ref=e41]: NEW
- generic [ref=e42] [cursor=pointer]:
- generic [ref=e43]: US
- generic [ref=e44]: User Simulator
- generic [ref=e45]: NEW
- generic [ref=e46] [cursor=pointer]:
- generic [ref=e47]: UI
- generic [ref=e48]: User Insight
- generic [ref=e49]: NEW
- generic [ref=e50] [cursor=pointer]:
- generic [ref=e51]: RT
- generic [ref=e52]: Regression Test
- generic [ref=e53]: NEW
- generic [ref=e54] [cursor=pointer]:
- generic [ref=e55]: ST
- generic [ref=e56]: Stress Test
- generic [ref=e57]: NEW
- generic [ref=e58] [cursor=pointer]:
- generic [ref=e59]: CR
- generic [ref=e60]: Competitor Research
- generic [ref=e61]: NEW
- generic [ref=e62]:
- generic [ref=e63]: Workspace
- generic [ref=e66] [cursor=pointer]: Resources
- generic [ref=e69] [cursor=pointer]: Team Notes
- generic [ref=e72] [cursor=pointer]: Changelog
- generic [ref=e75] [cursor=pointer]: Hướng dẫn
- generic [ref=e76]:
- generic [ref=e77]: Thử nghiệm
- generic [ref=e78] [cursor=pointer]:
- generic [ref=e80]: Text-to-SQL
- generic [ref=e81]: BETA
- generic [ref=e82] [cursor=pointer]:
- generic [ref=e84]: DB Test
- generic [ref=e85]: BETA
- generic [ref=e86] [cursor=pointer]:
- generic [ref=e88]: Feedback Demo
- generic [ref=e89]: NEW
- generic [ref=e90] [cursor=pointer]:
- generic [ref=e92]: Chatbot (Dev)
- generic [ref=e93]: DEV
- generic [ref=e96] [cursor=pointer]: Cache Manager
- generic [ref=e97]:
- generic [ref=e98]: External
- link "API Docs" [ref=e99] [cursor=pointer]:
- /url: /docs
- generic [ref=e101]: API Docs
- link "ReDoc" [ref=e102] [cursor=pointer]:
- /url: /redoc
- generic [ref=e104]: ReDoc
- generic [ref=e106]:
- generic [ref=e107]:
- generic [ref=e108]: U
- generic [ref=e109]: user
- button "⚙" [ref=e110]
- button "⏻" [ref=e111]
- generic [ref=e112]:
- strong [ref=e114]: v2.5.0
- text: · Online
- iframe [ref=e116]:
- generic [ref=f4e2]:
- generic [ref=f4e3]:
- generic [ref=f4e4]:
- generic [ref=f4e5]: US
- generic [ref=f4e6]:
- paragraph [ref=f4e7]: User Simulator
- paragraph [ref=f4e8]: AI persona test chatbot
- generic [ref=f4e9]:
- button "Setup" [ref=f4e10] [cursor=pointer]
- button "Simulate" [ref=f4e11] [cursor=pointer]
- button "Matrix" [ref=f4e12] [cursor=pointer]
- button "Insights" [ref=f4e13] [cursor=pointer]
- button "Run All (5)" [ref=f4e15] [cursor=pointer]
- generic [ref=f4e18]:
- generic [ref=f4e19]:
- generic [ref=f4e20]:
- generic [ref=f4e21]:
- paragraph [ref=f4e22]: Chatbot Prompt
- paragraph [ref=f4e23]: Prompt chatbot hiện tại — personas sẽ test với cái này
- button "Edit" [ref=f4e24] [cursor=pointer]
- generic [ref=f4e25]: Ban la tro ly tu van thoi trang Canifa. Hay tra loi cau hoi cua khach hang mot cach than thien va huu ich.
- generic [ref=f4e26]:
- generic [ref=f4e27]:
- paragraph [ref=f4e28]: User Personas (5)
- paragraph [ref=f4e29]: AI se dong vai tung persona de test chatbot
- button "+ Add Persona" [ref=f4e30] [cursor=pointer]
- generic [ref=f4e31]:
- generic [ref=f4e32]:
- generic [ref=f4e33]:
- generic [ref=f4e35]: C
- generic [ref=f4e36]:
- paragraph [ref=f4e37]: Chi Lan
- paragraph [ref=f4e38]: 40 tuoi - Non-tech user
- paragraph [ref=f4e39]: Khong quen dung app, can huong dan tung buoc. Hay bo cuoc neu khong hieu.
- generic [ref=f4e40]:
- button "Edit" [ref=f4e41] [cursor=pointer]
- button "x" [ref=f4e42] [cursor=pointer]
- button "▶" [ref=f4e43] [cursor=pointer]
- generic [ref=f4e44]:
- generic [ref=f4e45]:
- generic [ref=f4e47]: D
- generic [ref=f4e48]:
- paragraph [ref=f4e49]: Dev Minh
- paragraph [ref=f4e50]: 28 tuoi - Technical power user
- paragraph [ref=f4e51]: Developer, hoi ky thuat chi tiet, hay test edge case, phan bac neu cau tra loi khong chinh xac.
- generic [ref=f4e52]:
- button "Edit" [ref=f4e53] [cursor=pointer]
- button "x" [ref=f4e54] [cursor=pointer]
- button "▶" [ref=f4e55] [cursor=pointer]
- generic [ref=f4e56]:
- generic [ref=f4e57]:
- generic [ref=f4e59]: S
- generic [ref=f4e60]:
- paragraph [ref=f4e61]: SV Nam
- paragraph [ref=f4e62]: 20 tuoi - Budget shopper
- paragraph [ref=f4e63]: Ngan sach han che, hay so sanh doi thu, nhay cam ve gia, thich mac ca va tim deal.
- generic [ref=f4e64]:
- button "Edit" [ref=f4e65] [cursor=pointer]
- button "x" [ref=f4e66] [cursor=pointer]
- button "▶" [ref=f4e67] [cursor=pointer]
- generic [ref=f4e68]:
- generic [ref=f4e69]:
- generic [ref=f4e71]: A
- generic [ref=f4e72]:
- paragraph [ref=f4e73]: Anh Tuan
- paragraph [ref=f4e74]: 45 tuoi - Busy executive
- paragraph [ref=f4e75]: Giam doc, rat ban, muon cau tra loi ngan gon va nhanh. Ghet phai doc nhieu.
- generic [ref=f4e76]:
- button "Edit" [ref=f4e77] [cursor=pointer]
- button "x" [ref=f4e78] [cursor=pointer]
- button "▶" [ref=f4e79] [cursor=pointer]
- generic [ref=f4e80]:
- generic [ref=f4e81]:
- generic [ref=f4e83]: B
- generic [ref=f4e84]:
- paragraph [ref=f4e85]: Ba Hoa
- paragraph [ref=f4e86]: 65 tuoi - Elderly user
- paragraph [ref=f4e87]: Lon tuoi, dung dien thoai kho, can giai thich cham tung buoc, khong hieu tu chuyen nganh.
- generic [ref=f4e88]:
- button "Edit" [ref=f4e89] [cursor=pointer]
- button "x" [ref=f4e90] [cursor=pointer]
- button "▶" [ref=f4e91] [cursor=pointer]
\ No newline at end of file
- generic [active] [ref=e1]:
- complementary [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]: CA
- generic [ref=e5]:
- heading "Canifa AI" [level=2] [ref=e6]
- text: Admin Console
- generic [ref=e7]:
- generic [ref=e8]:
- generic [ref=e9]: Main
- generic [ref=e10] [cursor=pointer]:
- generic [ref=e11]: RM
- generic [ref=e12]: Kế hoạch phát triển
- generic [ref=e15] [cursor=pointer]: Sơ đồ hoạt động
- generic [ref=e16] [cursor=pointer]:
- generic [ref=e18]: Chatbot
- generic [ref=e19]: LIVE
- generic [ref=e22] [cursor=pointer]: History
- generic [ref=e25] [cursor=pointer]: Product Perf.
- generic [ref=e26] [cursor=pointer]:
- generic [ref=e28]: AI Data Analyst
- generic [ref=e29]: NEW
- generic [ref=e30] [cursor=pointer]:
- generic [ref=e31]: SQL
- generic [ref=e32]: AI sinh SQL
- generic [ref=e33]: NEW
- generic [ref=e34] [cursor=pointer]:
- generic [ref=e35]: LIVE
- generic [ref=e36]: Realtime Monitor
- generic [ref=e37]: LIVE
- generic [ref=e38] [cursor=pointer]:
- generic [ref=e39]: PO
- generic [ref=e40]: Prompt Optimizer
- generic [ref=e41]: NEW
- generic [ref=e42] [cursor=pointer]:
- generic [ref=e43]: US
- generic [ref=e44]: User Simulator
- generic [ref=e45]: NEW
- generic [ref=e46] [cursor=pointer]:
- generic [ref=e47]: UI
- generic [ref=e48]: User Insight
- generic [ref=e49]: NEW
- generic [ref=e50] [cursor=pointer]:
- generic [ref=e51]: RT
- generic [ref=e52]: Regression Test
- generic [ref=e53]: NEW
- generic [ref=e54] [cursor=pointer]:
- generic [ref=e55]: ST
- generic [ref=e56]: Stress Test
- generic [ref=e57]: NEW
- generic [ref=e58] [cursor=pointer]:
- generic [ref=e59]: CR
- generic [ref=e60]: Competitor Research
- generic [ref=e61]: NEW
- generic [ref=e62]:
- generic [ref=e63]: Workspace
- generic [ref=e66] [cursor=pointer]: Resources
- generic [ref=e69] [cursor=pointer]: Team Notes
- generic [ref=e72] [cursor=pointer]: Changelog
- generic [ref=e75] [cursor=pointer]: Hướng dẫn
- generic [ref=e76]:
- generic [ref=e77]: Thử nghiệm
- generic [ref=e78] [cursor=pointer]:
- generic [ref=e80]: Text-to-SQL
- generic [ref=e81]: BETA
- generic [ref=e82] [cursor=pointer]:
- generic [ref=e84]: DB Test
- generic [ref=e85]: BETA
- generic [ref=e86] [cursor=pointer]:
- generic [ref=e88]: Feedback Demo
- generic [ref=e89]: NEW
- generic [ref=e90] [cursor=pointer]:
- generic [ref=e92]: Chatbot (Dev)
- generic [ref=e93]: DEV
- generic [ref=e96] [cursor=pointer]: Cache Manager
- generic [ref=e97]:
- generic [ref=e98]: External
- link "API Docs" [ref=e99] [cursor=pointer]:
- /url: /docs
- generic [ref=e101]: API Docs
- link "ReDoc" [ref=e102] [cursor=pointer]:
- /url: /redoc
- generic [ref=e104]: ReDoc
- generic [ref=e106]:
- generic [ref=e107]:
- generic [ref=e108]: U
- generic [ref=e109]: user
- button "⚙" [ref=e110]
- button "⏻" [ref=e111]
- generic [ref=e112]:
- strong [ref=e114]: v2.5.0
- text: · Online
- iframe [ref=e116]:
- generic [ref=f5e2]:
- generic [ref=f5e4]:
- heading "💬 History Viewer" [level=1] [ref=f5e5]
- paragraph [ref=f5e6]: Xem lịch sử hội thoại theo identity key
- generic [ref=f5e7]:
- generic [ref=f5e8]: Identity Key
- textbox "device_id hoặc user_id..." [ref=f5e9]
- generic [ref=f5e10]: Từ
- textbox [ref=f5e11]
- generic [ref=f5e12]: Đến
- textbox [ref=f5e13]
- generic [ref=f5e14]:
- button "Fetch" [ref=f5e15] [cursor=pointer]
- button "Hôm nay" [ref=f5e16] [cursor=pointer]
- button "Clear" [ref=f5e17] [cursor=pointer]
- paragraph [ref=f5e22]:
- text: Nhập identity key và nhấn
- strong [ref=f5e23]: Fetch
- text: để xem lịch sử
\ No newline at end of file
- generic [ref=e2]: "{\"detail\":\"Not Found\"}"
\ No newline at end of file
"""
Experiment Log API — AI version experiment tracking.
"""
import logging
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional, List
from collections import defaultdict
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from api.notes_route import _get_pool, _now
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/dashboard", tags=["Experiment Log"])
async def _query(q: str, params: tuple = ()) -> list[dict]:
pool = await _get_pool()
async with pool.connection() as conn:
async with conn.cursor() as cur:
await cur.execute(q, params)
cols = [d[0] for d in cur.description] if cur.description else []
rows = await cur.fetchall()
return [dict(zip(cols, row)) for row in rows]
async def _execute(q: str, params: tuple = ()):
pool = await _get_pool()
async with pool.connection() as conn:
async with conn.cursor() as cur:
await cur.execute(q, params)
await conn.commit()
def _serialize(row: dict) -> dict:
r = {**row}
for k in ("created_at", "updated_at"):
if k in r and r[k]:
r[k] = r[k].isoformat()
# Convert Decimal to float
for k in ("score", "latency_avg", "cost_per_req", "error_rate"):
if k in r and r[k] is not None:
r[k] = float(r[k])
return r
# ── Models ──
class ExperimentCreate(BaseModel):
version: str
title: str
content: str = ""
tags: List[str] = []
score: Optional[float] = None
latency_avg: Optional[float] = None
cost_per_req: Optional[float] = None
error_rate: Optional[float] = None
model_name: str = ""
status: str = "draft"
pinned: bool = False
class ExperimentUpdate(BaseModel):
version: Optional[str] = None
title: Optional[str] = None
content: Optional[str] = None
tags: Optional[List[str]] = None
score: Optional[float] = None
latency_avg: Optional[float] = None
cost_per_req: Optional[float] = None
error_rate: Optional[float] = None
model_name: Optional[str] = None
status: Optional[str] = None
pinned: Optional[bool] = None
# ── Endpoints ──
@router.get("/experiments")
async def list_experiments(tag: Optional[str] = None, status: Optional[str] = None):
"""List all experiments. Optional filter by tag or status."""
q = "SELECT * FROM experiment_log WHERE 1=1"
params = []
if tag:
q += " AND %s = ANY(tags)"
params.append(tag)
if status:
q += " AND status = %s"
params.append(status)
q += " ORDER BY pinned DESC, created_at DESC"
rows = await _query(q, tuple(params))
items = [_serialize(r) for r in rows]
# Collect all tags with counts
tag_counts = defaultdict(int)
all_rows = await _query("SELECT tags FROM experiment_log")
for r in all_rows:
for t in (r.get("tags") or []):
tag_counts[t] += 1
# Activity heatmap: date → count
activity_rows = await _query(
"SELECT DATE(created_at AT TIME ZONE 'Asia/Ho_Chi_Minh') as d, COUNT(*) as c FROM experiment_log GROUP BY d"
)
activity = {str(r["d"]): int(r["c"]) for r in activity_rows}
return {
"status": "success",
"experiments": items,
"total": len(items),
"tags": dict(sorted(tag_counts.items(), key=lambda x: -x[1])),
"activity": activity,
}
@router.post("/experiments")
async def create_experiment(item: ExperimentCreate):
"""Create a new experiment entry."""
eid = str(uuid.uuid4())[:8]
now = _now()
await _execute(
"""INSERT INTO experiment_log
(id, version, title, content, tags, score, latency_avg, cost_per_req, error_rate, model_name, status, pinned, author, created_at, updated_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'Admin',%s,%s)""",
(eid, item.version, item.title, item.content, item.tags,
item.score, item.latency_avg, item.cost_per_req, item.error_rate,
item.model_name, item.status, item.pinned, now, now),
)
return {"status": "success", "id": eid}
@router.put("/experiments/{exp_id}")
async def update_experiment(exp_id: str, update: ExperimentUpdate):
"""Update an experiment."""
sets, vals = [], []
for field in ("version", "title", "content", "score", "latency_avg",
"cost_per_req", "error_rate", "model_name", "status", "pinned"):
val = getattr(update, field, None)
if val is not None:
sets.append(f"{field} = %s")
vals.append(val)
if update.tags is not None:
sets.append("tags = %s")
vals.append(update.tags)
if not sets:
raise HTTPException(status_code=400, detail="Nothing to update")
sets.append("updated_at = %s")
vals.append(_now())
vals.append(exp_id)
await _execute(f"UPDATE experiment_log SET {', '.join(sets)} WHERE id = %s", tuple(vals))
return {"status": "success"}
@router.delete("/experiments/{exp_id}")
async def delete_experiment(exp_id: str):
"""Delete an experiment."""
await _execute("DELETE FROM experiment_log WHERE id = %s", (exp_id,))
return {"status": "success"}
@router.patch("/experiments/{exp_id}/pin")
async def toggle_experiment_pin(exp_id: str):
"""Toggle pin status."""
rows = await _query("SELECT pinned FROM experiment_log WHERE id = %s", (exp_id,))
if not rows:
raise HTTPException(status_code=404, detail="Not found")
new_pin = not rows[0]["pinned"]
await _execute("UPDATE experiment_log SET pinned = %s, updated_at = %s WHERE id = %s",
(new_pin, _now(), exp_id))
return {"status": "success", "pinned": new_pin}
@router.get("/experiments/compare")
async def compare_experiments(id1: str, id2: str):
"""Compare two experiments side-by-side."""
rows = await _query("SELECT * FROM experiment_log WHERE id IN (%s, %s)", (id1, id2))
if len(rows) < 2:
raise HTTPException(status_code=404, detail="One or both experiments not found")
items = [_serialize(r) for r in rows]
return {"status": "success", "experiments": items}
import logging
from fastapi import APIRouter, Body
from common.message_limit import message_limit_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/limit", tags=["Limit Management"])
@router.get("/info", summary="Lấy thông tin limit của 1 ID (vd: device:xxx hoặc user:yyy)")
async def get_limit_info(identity_key: str):
"""
identity_key format: 'device:id' hoặc 'user:id'
"""
is_authenticated = identity_key.startswith("user:")
info = await message_limit_service.get_usage(identity_key, is_authenticated)
return {"status": "success", "info": info}
@router.post("/reset", summary="Reset limit (thêm limit / cho phép nhắn lại) cho 1 ID")
async def reset_limit(payload: dict = Body(...)):
identity_key = payload.get("identity_key")
if not identity_key:
return {"status": "error", "message": "Missing identity_key"}
success = await message_limit_service.reset(identity_key)
if success:
return {"status": "success", "message": f"Đã reset/thêm limit cho {identity_key} (về 0)"}
else:
return {"status": "error", "message": "Có lỗi khi reset limit"}
"""
Dashboard Notes API - Team notes, pinned docs, draft management.
Stores notes in a local JSON file for simplicity.
Dashboard API - Notes, Links/Resources, Changelog.
All stored in Postgres (dashboard_items table) using the checkpoint DB.
"""
import json
import os
import logging
import uuid
from datetime import datetime
from typing import Optional
from pathlib import Path
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
import psycopg
from psycopg import sql
from psycopg_pool import AsyncConnectionPool
from fastapi import APIRouter, Header, HTTPException
from pydantic import BaseModel
router = APIRouter(prefix="/api/dashboard", tags=["Dashboard Notes"])
from config import CHECKPOINT_POSTGRES_URL
# Storage path
NOTES_FILE = Path(__file__).parent.parent / "data" / "dashboard_notes.json"
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/dashboard", tags=["Dashboard"])
TABLE = "dashboard_items"
VN_TZ = timezone(timedelta(hours=7))
# ═══════════════════════════════════════════════════
# DB POOL (reuse pattern from conversation_manager)
# ═══════════════════════════════════════════════════
_pool: AsyncConnectionPool | None = None
async def _get_pool() -> AsyncConnectionPool:
global _pool
if _pool is None:
_pool = AsyncConnectionPool(
CHECKPOINT_POSTGRES_URL,
min_size=1,
max_size=5,
max_lifetime=600,
max_idle=300,
open=False,
)
await _pool.open()
await _init_table()
return _pool
async def _init_table():
"""Create dashboard_items table if not exists."""
pool = await _get_pool() if _pool else _pool
if pool is None:
return
async with pool.connection() as conn:
async with conn.cursor() as cur:
await cur.execute(f"""
CREATE TABLE IF NOT EXISTS {TABLE} (
id VARCHAR(36) PRIMARY KEY,
type VARCHAR(20) NOT NULL DEFAULT 'note',
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
url TEXT DEFAULT '',
description TEXT DEFAULT '',
category VARCHAR(50) DEFAULT 'note',
icon VARCHAR(10) DEFAULT '',
color VARCHAR(20) DEFAULT '',
pinned BOOLEAN DEFAULT FALSE,
author VARCHAR(100) DEFAULT 'Admin',
metadata JSONB DEFAULT '{{}}'::jsonb,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
)
""")
await cur.execute(f"""
CREATE INDEX IF NOT EXISTS idx_dashboard_items_type
ON {TABLE} (type, pinned DESC, updated_at DESC)
""")
await conn.commit()
logger.info(f"✅ Table {TABLE} initialized")
async def _query(query_str: str, params: tuple = ()) -> list[dict]:
pool = await _get_pool()
async with pool.connection() as conn:
async with conn.cursor() as cur:
await cur.execute(query_str, params)
cols = [d[0] for d in cur.description] if cur.description else []
rows = await cur.fetchall()
return [dict(zip(cols, row)) for row in rows]
async def _execute(query_str: str, params: tuple = ()):
pool = await _get_pool()
async with pool.connection() as conn:
async with conn.cursor() as cur:
await cur.execute(query_str, params)
await conn.commit()
def _now():
return datetime.now(VN_TZ)
def _row_to_dict(row: dict) -> dict:
"""Convert DB row to API-friendly dict."""
r = {**row}
# Convert datetime to ISO string
for k in ("created_at", "updated_at"):
if k in r and r[k]:
r[k] = r[k].isoformat()
# Merge metadata into response if present
meta = r.pop("metadata", None)
if meta and isinstance(meta, dict):
r.update(meta)
return r
# ═══════════════════════════════════════════════════
# Pydantic Models
# ═══════════════════════════════════════════════════
class NoteCreate(BaseModel):
title: str
content: str
category: str = "note" # note, doc, todo, announcement
category: str = "note"
pinned: bool = False
color: Optional[str] = None # optional color tag
color: Optional[str] = None
class NoteUpdate(BaseModel):
......@@ -34,384 +135,320 @@ class NoteUpdate(BaseModel):
color: Optional[str] = None
def _load_notes() -> list:
"""Load notes from JSON file."""
if not NOTES_FILE.exists():
NOTES_FILE.parent.mkdir(parents=True, exist_ok=True)
NOTES_FILE.write_text("[]", encoding="utf-8")
return []
try:
return json.loads(NOTES_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, FileNotFoundError):
return []
class LinkCreate(BaseModel):
title: str
url: str
description: str = ""
category: str = "other"
icon: str = ""
pinned: bool = False
class LinkUpdate(BaseModel):
title: Optional[str] = None
url: Optional[str] = None
description: Optional[str] = None
category: Optional[str] = None
icon: Optional[str] = None
pinned: Optional[bool] = None
class ChangelogCreate(BaseModel):
content: str
author: str = "Admin"
class UserCreate(BaseModel):
name: str
role: str = "user"
# ═══════════════════════════════════════════════════
# NOTES CRUD
# ═══════════════════════════════════════════════════
def _save_notes(notes: list):
"""Save notes to JSON file."""
NOTES_FILE.parent.mkdir(parents=True, exist_ok=True)
NOTES_FILE.write_text(json.dumps(notes, ensure_ascii=False, indent=2), encoding="utf-8")
def _default_color(category: str) -> str:
return {
"note": "#58a6ff", "doc": "#a78bfa",
"todo": "#e3b341", "announcement": "#f85149",
}.get(category, "#58a6ff")
@router.get("/notes")
async def list_notes():
"""Get all notes, pinned first."""
notes = _load_notes()
# Sort: pinned first, then by updated_at desc
notes.sort(key=lambda n: (not n.get("pinned", False), n.get("updated_at", "")), reverse=False)
notes.sort(key=lambda n: (not n.get("pinned", False)))
rows = await _query(
f"SELECT * FROM {TABLE} WHERE type = 'note' ORDER BY pinned DESC, updated_at DESC"
)
notes = [_row_to_dict(r) for r in rows]
return {"status": "success", "notes": notes, "total": len(notes)}
@router.post("/notes")
async def create_note(note: NoteCreate, x_author: str = Header(default="Admin")):
"""Create a new note."""
notes = _load_notes()
now = datetime.now().isoformat()
new_note = {
"id": str(uuid.uuid4())[:8],
"title": note.title,
"content": note.content,
"category": note.category,
"pinned": note.pinned,
"color": note.color or _default_color(note.category),
"created_at": now,
"updated_at": now,
"author": x_author,
}
notes.append(new_note)
_save_notes(notes)
return {"status": "success", "note": new_note}
now = _now()
nid = str(uuid.uuid4())[:8]
color = note.color or _default_color(note.category)
await _execute(
f"""INSERT INTO {TABLE} (id, type, title, content, category, color, pinned, author, created_at, updated_at)
VALUES (%s, 'note', %s, %s, %s, %s, %s, %s, %s, %s)""",
(nid, note.title, note.content, note.category, color, note.pinned, x_author, now, now),
)
return {"status": "success", "note": {
"id": nid, "title": note.title, "content": note.content,
"category": note.category, "color": color, "pinned": note.pinned,
"author": x_author, "created_at": now.isoformat(), "updated_at": now.isoformat(),
}}
@router.put("/notes/{note_id}")
async def update_note(note_id: str, update: NoteUpdate, x_author: str = Header(default="Admin")):
"""Update an existing note. Only the original author can edit."""
notes = _load_notes()
for i, n in enumerate(notes):
if n["id"] == note_id:
if n.get("author", "Admin") != x_author and x_author != "Admin":
raise HTTPException(status_code=403, detail=f"Bạn không thể sửa note của {n.get('author')}")
if update.title is not None:
notes[i]["title"] = update.title
if update.content is not None:
notes[i]["content"] = update.content
if update.category is not None:
notes[i]["category"] = update.category
if update.pinned is not None:
notes[i]["pinned"] = update.pinned
if update.color is not None:
notes[i]["color"] = update.color
notes[i]["updated_at"] = datetime.now().isoformat()
_save_notes(notes)
return {"status": "success", "note": notes[i]}
raise HTTPException(status_code=404, detail="Note not found")
"""Update a note."""
sets, vals = [], []
for field in ("title", "content", "category", "pinned", "color"):
val = getattr(update, field, None)
if val is not None:
sets.append(f"{field} = %s")
vals.append(val)
if not sets:
raise HTTPException(status_code=400, detail="Nothing to update")
sets.append("updated_at = %s")
vals.append(_now())
vals.append(note_id)
await _execute(
f"UPDATE {TABLE} SET {', '.join(sets)} WHERE id = %s AND type = 'note'", tuple(vals)
)
rows = await _query(f"SELECT * FROM {TABLE} WHERE id = %s", (note_id,))
if not rows:
raise HTTPException(status_code=404, detail="Note not found")
return {"status": "success", "note": _row_to_dict(rows[0])}
@router.delete("/notes/{note_id}")
async def delete_note(note_id: str, x_author: str = Header(default="Admin")):
"""Delete a note. Only the original author can delete."""
notes = _load_notes()
for n in notes:
if n["id"] == note_id:
if n.get("author", "Admin") != x_author and x_author != "Admin":
raise HTTPException(status_code=403, detail=f"Bạn không thể xóa note của {n.get('author')}")
break
original_len = len(notes)
notes = [n for n in notes if n["id"] != note_id]
if len(notes) == original_len:
"""Delete a note."""
rows = await _query(f"SELECT author FROM {TABLE} WHERE id = %s AND type = 'note'", (note_id,))
if not rows:
raise HTTPException(status_code=404, detail="Note not found")
_save_notes(notes)
if rows[0]["author"] != x_author and x_author != "Admin":
raise HTTPException(status_code=403, detail=f"Bạn không thể xóa note của {rows[0]['author']}")
await _execute(f"DELETE FROM {TABLE} WHERE id = %s", (note_id,))
return {"status": "success", "message": "Note deleted"}
@router.patch("/notes/{note_id}/pin")
async def toggle_pin(note_id: str):
"""Toggle pin status of a note."""
notes = _load_notes()
for i, n in enumerate(notes):
if n["id"] == note_id:
notes[i]["pinned"] = not notes[i].get("pinned", False)
notes[i]["updated_at"] = datetime.now().isoformat()
_save_notes(notes)
return {"status": "success", "pinned": notes[i]["pinned"]}
raise HTTPException(status_code=404, detail="Note not found")
def _default_color(category: str) -> str:
"""Return default color based on category."""
return {
"note": "#58a6ff",
"doc": "#a78bfa",
"todo": "#e3b341",
"announcement": "#f85149",
}.get(category, "#58a6ff")
"""Toggle pin status."""
rows = await _query(f"SELECT pinned FROM {TABLE} WHERE id = %s AND type = 'note'", (note_id,))
if not rows:
raise HTTPException(status_code=404, detail="Note not found")
new_pin = not rows[0]["pinned"]
await _execute(
f"UPDATE {TABLE} SET pinned = %s, updated_at = %s WHERE id = %s",
(new_pin, _now(), note_id),
)
return {"status": "success", "pinned": new_pin}
# ═══════════════════════════════════════════════════
# LINKS / RESOURCES
# LINKS / RESOURCES CRUD
# ═══════════════════════════════════════════════════
LINKS_FILE = Path(__file__).parent.parent / "data" / "dashboard_links.json"
class LinkCreate(BaseModel):
title: str
url: str
description: str = ""
category: str = "other" # tool, doc, design, api, repo, other
icon: str = "" # emoji icon
pinned: bool = False
class LinkUpdate(BaseModel):
title: Optional[str] = None
url: Optional[str] = None
description: Optional[str] = None
category: Optional[str] = None
icon: Optional[str] = None
pinned: Optional[bool] = None
DEFAULT_TOOL_LINKS = [
{"id": "tool_stress_test", "title": "Stress Test Dashboard", "url": "/static/stress-test.html",
"description": "Bắn N request đồng thời — đo RPS, latency p95, error rate", "category": "tool", "icon": "🔥", "pinned": False, "author": "System"},
"description": "Bắn N request đồng thời — đo RPS, latency p95, error rate", "category": "tool", "icon": "🔥"},
{"id": "tool_regression_test", "title": "Regression Test", "url": "/static/regression-test.html",
"description": "Chạy bộ test cases qua bất kỳ chatbot URL — con người đọc kết quả", "category": "tool", "icon": "🧪", "pinned": False, "author": "System"},
"description": "Chạy bộ test cases qua bất kỳ chatbot URL — con người đọc kết quả", "category": "tool", "icon": "🧪"},
{"id": "tool_user_simulator", "title": "User Simulator", "url": "/static/user-simulator.html",
"description": "AI đóng vai persona test chatbot — đánh giá từ góc user thật", "category": "tool", "icon": "🎭", "pinned": False, "author": "System"},
"description": "AI đóng vai persona test chatbot — đánh giá từ góc user thật", "category": "tool", "icon": "🎭"},
{"id": "tool_prompt_optimizer", "title": "Prompt Optimizer", "url": "/static/prompt-optimizer.html",
"description": "AI Judge chấm điểm chatbot + tự động gợi ý cải thiện prompt", "category": "tool", "icon": "⚡", "pinned": False, "author": "System"},
"description": "AI Judge chấm điểm chatbot + tự động gợi ý cải thiện prompt", "category": "tool", "icon": "⚡"},
{"id": "tool_competitor_research", "title": "Competitor Research", "url": "/static/competitor-research.html",
"description": "Nghiên cứu đối thủ — phân tích Zalando, YaMe, H&M", "category": "doc", "icon": "🔍", "pinned": False, "author": "System"},
"description": "Nghiên cứu đối thủ — phân tích Zalando, YaMe, H&M", "category": "doc", "icon": "🔍"},
]
def _load_links() -> list:
if not LINKS_FILE.exists():
LINKS_FILE.parent.mkdir(parents=True, exist_ok=True)
LINKS_FILE.write_text("[]", encoding="utf-8")
return []
try:
links = json.loads(LINKS_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, FileNotFoundError):
links = []
# Auto-seed default tool links if missing
existing_ids = {l.get("id") for l in links}
added = False
for default in DEFAULT_TOOL_LINKS:
if default["id"] not in existing_ids:
links.append({**default, "created_at": datetime.now().isoformat()})
added = True
if added:
_save_links(links)
return links
def _default_icon(category: str) -> str:
return {"tool": "🔧", "doc": "📄", "design": "🎨", "api": "🔌", "repo": "📦", "other": "🔗"}.get(category, "🔗")
def _save_links(links: list):
LINKS_FILE.parent.mkdir(parents=True, exist_ok=True)
LINKS_FILE.write_text(json.dumps(links, ensure_ascii=False, indent=2), encoding="utf-8")
async def _seed_default_links():
"""Auto-seed default tool links if missing."""
rows = await _query(f"SELECT id FROM {TABLE} WHERE type = 'link'")
existing_ids = {r["id"] for r in rows}
now = _now()
for link in DEFAULT_TOOL_LINKS:
if link["id"] not in existing_ids:
await _execute(
f"""INSERT INTO {TABLE} (id, type, title, url, description, category, icon, pinned, author, created_at, updated_at)
VALUES (%s, 'link', %s, %s, %s, %s, %s, FALSE, 'System', %s, %s)""",
(link["id"], link["title"], link["url"], link["description"], link["category"], link["icon"], now, now),
)
@router.get("/links")
async def list_links():
"""Get all resource links, pinned first."""
links = _load_links()
links.sort(key=lambda l: (not l.get("pinned", False), l.get("created_at", "")))
await _seed_default_links()
rows = await _query(
f"SELECT * FROM {TABLE} WHERE type = 'link' ORDER BY pinned DESC, created_at ASC"
)
links = [_row_to_dict(r) for r in rows]
return {"status": "success", "links": links, "total": len(links)}
@router.post("/links")
async def create_link(link: LinkCreate, x_author: str = Header(default="Admin")):
"""Create a new resource link."""
links = _load_links()
now = datetime.now().isoformat()
new_link = {
"id": str(uuid.uuid4())[:8],
"title": link.title,
"url": link.url,
"description": link.description,
"category": link.category,
"icon": link.icon or _default_icon(link.category),
"pinned": link.pinned,
"created_at": now,
"author": x_author,
}
links.append(new_link)
_save_links(links)
return {"status": "success", "link": new_link}
now = _now()
lid = str(uuid.uuid4())[:8]
icon = link.icon or _default_icon(link.category)
await _execute(
f"""INSERT INTO {TABLE} (id, type, title, url, description, category, icon, pinned, author, created_at, updated_at)
VALUES (%s, 'link', %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(lid, link.title, link.url, link.description, link.category, icon, link.pinned, x_author, now, now),
)
return {"status": "success", "link": {
"id": lid, "title": link.title, "url": link.url,
"description": link.description, "category": link.category,
"icon": icon, "pinned": link.pinned, "author": x_author,
"created_at": now.isoformat(),
}}
@router.put("/links/{link_id}")
async def update_link(link_id: str, update: LinkUpdate, x_author: str = Header(default="Admin")):
"""Update an existing resource link. Only original author can edit."""
links = _load_links()
for i, l in enumerate(links):
if l["id"] == link_id:
if l.get("author", "Admin") != x_author and x_author != "Admin":
raise HTTPException(status_code=403, detail=f"Bạn không thể sửa link của {l.get('author')}")
for field in ["title", "url", "description", "category", "icon", "pinned"]:
val = getattr(update, field, None)
if val is not None:
links[i][field] = val
_save_links(links)
return {"status": "success", "link": links[i]}
raise HTTPException(status_code=404, detail="Link not found")
"""Update a link."""
sets, vals = [], []
for field in ("title", "url", "description", "category", "icon", "pinned"):
val = getattr(update, field, None)
if val is not None:
sets.append(f"{field} = %s")
vals.append(val)
if not sets:
raise HTTPException(status_code=400, detail="Nothing to update")
vals.append(link_id)
await _execute(
f"UPDATE {TABLE} SET {', '.join(sets)} WHERE id = %s AND type = 'link'", tuple(vals)
)
rows = await _query(f"SELECT * FROM {TABLE} WHERE id = %s", (link_id,))
if not rows:
raise HTTPException(status_code=404, detail="Link not found")
return {"status": "success", "link": _row_to_dict(rows[0])}
@router.delete("/links/{link_id}")
async def delete_link(link_id: str, x_author: str = Header(default="Admin")):
"""Delete a resource link. Only original author can delete."""
links = _load_links()
for l in links:
if l["id"] == link_id:
if l.get("author", "Admin") != x_author and x_author != "Admin":
raise HTTPException(status_code=403, detail=f"Bạn không thể xóa link của {l.get('author')}")
break
original_len = len(links)
links = [l for l in links if l["id"] != link_id]
if len(links) == original_len:
"""Delete a link."""
rows = await _query(f"SELECT author FROM {TABLE} WHERE id = %s AND type = 'link'", (link_id,))
if not rows:
raise HTTPException(status_code=404, detail="Link not found")
_save_links(links)
if rows[0]["author"] != x_author and x_author != "Admin":
raise HTTPException(status_code=403, detail=f"Bạn không thể xóa link của {rows[0]['author']}")
await _execute(f"DELETE FROM {TABLE} WHERE id = %s", (link_id,))
return {"status": "success", "message": "Link deleted"}
@router.patch("/links/{link_id}/pin")
async def toggle_link_pin(link_id: str):
"""Toggle pin status of a link."""
links = _load_links()
for i, l in enumerate(links):
if l["id"] == link_id:
links[i]["pinned"] = not links[i].get("pinned", False)
_save_links(links)
return {"status": "success", "pinned": links[i]["pinned"]}
raise HTTPException(status_code=404, detail="Link not found")
def _default_icon(category: str) -> str:
return {
"tool": "🔧",
"doc": "📄",
"design": "🎨",
"api": "🔌",
"repo": "📦",
"other": "🔗",
}.get(category, "🔗")
"""Toggle pin status."""
rows = await _query(f"SELECT pinned FROM {TABLE} WHERE id = %s AND type = 'link'", (link_id,))
if not rows:
raise HTTPException(status_code=404, detail="Link not found")
new_pin = not rows[0]["pinned"]
await _execute(f"UPDATE {TABLE} SET pinned = %s WHERE id = %s", (new_pin, link_id))
return {"status": "success", "pinned": new_pin}
# ═══════════════════════════════════════════════════
# CHANGELOG
# CHANGELOG CRUD
# ═══════════════════════════════════════════════════
CHANGELOG_FILE = Path(__file__).parent.parent / "data" / "dashboard_changelog.json"
class ChangelogCreate(BaseModel):
content: str
author: str = "Admin"
def _load_changelog() -> list:
if not CHANGELOG_FILE.exists():
CHANGELOG_FILE.parent.mkdir(parents=True, exist_ok=True)
CHANGELOG_FILE.write_text("[]", encoding="utf-8")
return []
try:
return json.loads(CHANGELOG_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, FileNotFoundError):
return []
def _save_changelog(entries: list):
CHANGELOG_FILE.parent.mkdir(parents=True, exist_ok=True)
CHANGELOG_FILE.write_text(json.dumps(entries, ensure_ascii=False, indent=2), encoding="utf-8")
@router.get("/changelog")
async def list_changelog():
"""Get all changelog entries, newest first."""
entries = _load_changelog()
entries.sort(key=lambda e: e.get("created_at", ""), reverse=True)
rows = await _query(
f"SELECT * FROM {TABLE} WHERE type = 'changelog' ORDER BY created_at DESC"
)
entries = [_row_to_dict(r) for r in rows]
return {"status": "success", "entries": entries, "total": len(entries)}
@router.post("/changelog")
async def create_changelog_entry(entry: ChangelogCreate):
"""Add a new changelog entry."""
entries = _load_changelog()
new_entry = {
"id": str(uuid.uuid4())[:8],
"content": entry.content,
"author": entry.author,
"created_at": datetime.now().isoformat(),
}
entries.append(new_entry)
_save_changelog(entries)
return {"status": "success", "entry": new_entry}
now = _now()
eid = str(uuid.uuid4())[:8]
await _execute(
f"""INSERT INTO {TABLE} (id, type, content, author, created_at, updated_at)
VALUES (%s, 'changelog', %s, %s, %s, %s)""",
(eid, entry.content, entry.author, now, now),
)
return {"status": "success", "entry": {
"id": eid, "content": entry.content, "author": entry.author,
"created_at": now.isoformat(),
}}
@router.delete("/changelog/{entry_id}")
async def delete_changelog_entry(entry_id: str):
"""Delete a changelog entry."""
entries = _load_changelog()
original_len = len(entries)
entries = [e for e in entries if e["id"] != entry_id]
if len(entries) == original_len:
rows = await _query(f"SELECT id FROM {TABLE} WHERE id = %s AND type = 'changelog'", (entry_id,))
if not rows:
raise HTTPException(status_code=404, detail="Entry not found")
_save_changelog(entries)
await _execute(f"DELETE FROM {TABLE} WHERE id = %s", (entry_id,))
return {"status": "success", "message": "Entry deleted"}
# ═══════════════════════════════════════════════════
# USERS / ROLES
# USERS / ROLES (still simple — kept in DB too)
# ═══════════════════════════════════════════════════
USERS_FILE = Path(__file__).parent.parent / "data" / "users.json"
def _load_users() -> list:
if not USERS_FILE.exists():
USERS_FILE.parent.mkdir(parents=True, exist_ok=True)
USERS_FILE.write_text('[{"name":"Admin","role":"admin"}]', encoding="utf-8")
return [{"name": "Admin", "role": "admin"}]
try:
return json.loads(USERS_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, FileNotFoundError):
return []
def _save_users(users: list):
USERS_FILE.parent.mkdir(parents=True, exist_ok=True)
USERS_FILE.write_text(json.dumps(users, ensure_ascii=False, indent=2), encoding="utf-8")
class UserCreate(BaseModel):
name: str
role: str = "user" # admin | user
async def _init_users_table():
"""Users table separate for simplicity."""
pool = await _get_pool()
async with pool.connection() as conn:
async with conn.cursor() as cur:
await cur.execute("""
CREATE TABLE IF NOT EXISTS dashboard_users (
name VARCHAR(100) PRIMARY KEY,
role VARCHAR(20) DEFAULT 'user'
)
""")
# Seed admin
await cur.execute("""
INSERT INTO dashboard_users (name, role) VALUES ('Admin', 'admin')
ON CONFLICT (name) DO NOTHING
""")
await conn.commit()
@router.get("/users")
async def list_users():
"""Get all users and their roles."""
return {"status": "success", "users": _load_users()}
"""Get all users."""
await _init_users_table()
rows = await _query("SELECT * FROM dashboard_users ORDER BY name")
return {"status": "success", "users": [dict(r) for r in rows]}
@router.post("/users")
async def add_user(user: UserCreate):
"""Add a new user role."""
users = _load_users()
# Update if exists
for u in users:
if u["name"].lower() == user.name.lower():
u["role"] = user.role
_save_users(users)
return {"status": "success", "message": f"Updated {user.name} to {user.role}", "users": users}
users.append({"name": user.name, "role": user.role})
_save_users(users)
return {"status": "success", "message": f"Added {user.name} as {user.role}", "users": users}
"""Add or update a user role."""
await _init_users_table()
await _execute(
"""INSERT INTO dashboard_users (name, role) VALUES (%s, %s)
ON CONFLICT (name) DO UPDATE SET role = EXCLUDED.role""",
(user.name, user.role),
)
rows = await _query("SELECT * FROM dashboard_users ORDER BY name")
return {"status": "success", "message": f"Updated {user.name} to {user.role}", "users": [dict(r) for r in rows]}
@router.delete("/users/{name}")
async def remove_user(name: str):
"""Remove a user role entry."""
users = _load_users()
users = [u for u in users if u["name"].lower() != name.lower()]
_save_users(users)
return {"status": "success", "users": users}
\ No newline at end of file
"""Remove a user."""
await _init_users_table()
await _execute("DELETE FROM dashboard_users WHERE name = %s", (name,))
rows = await _query("SELECT * FROM dashboard_users ORDER BY name")
return {"status": "success", "users": [dict(r) for r in rows]}
\ No newline at end of file
"""
Ultra Description Manager — API Route
Generates AI-powered product descriptions from product images + stock data.
Pipeline:
Phase 1: Vision (Llama 4 Scout) → raw JSON 20 fields from product image
Phase 2: Enrichment (GPT-OSS 120B) → rewrite with stylist tone + real stock data
"""
import asyncio
import json
import logging
import re
import uuid
from typing import Optional
import httpx
from fastapi import APIRouter, Query, BackgroundTasks
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from common.cache import redis_cache
from common.starrocks_connection import get_db_connection
from common.ultra_desc_db import UltraDescriptionDB, DescFieldConfig
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/product-desc", tags=["Ultra Description"])
TABLE_NAME = "shared_source.magento_product_dimension_with_text_embedding"
# ═══ Groq Config — Multi-Key Round-Robin ═══
GROQ_API_KEYS = [
"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", # Key 1
"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", # Key 2
"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", # Key 3
"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",
"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",
"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",
]
_groq_key_index = 0 # Round-robin counter
def _next_groq_key() -> str:
"""Get next Groq API key using round-robin rotation."""
global _groq_key_index
key = GROQ_API_KEYS[_groq_key_index % len(GROQ_API_KEYS)]
_groq_key_index += 1
return key
GROQ_API_KEY = GROQ_API_KEYS[0] # Backwards compat for imports
VISION_MODEL = "meta-llama/llama-4-scout-17b-16e-instruct"
ENRICH_MODEL = "openai/gpt-oss-120b"
# ═══ Batch Tracking ═══
BATCH_STATE = {
"is_running": False,
"total": 0,
"done": 0,
"errors": 0,
"current_code": None
}
# ═══ Prompts ═══
VISION_PROMPT = """Bạn là chuyên gia phân tích sản phẩm thời trang. Nhìn ảnh sản phẩm và trả về JSON với ĐÚNG 28 trường sau.
QUAN TRỌNG: KHÔNG đoán chất liệu nếu không chắc. KHÔNG bỏ trống trường nào. Nếu không chắc, ghi "Không xác định".
{
"ten_san_pham": "Tên sản phẩm ngắn gọn",
"tagline": "1 câu slogan hấp dẫn",
"mo_ta_chinh": "3 câu mô tả: form dáng + thiết kế + tác dụng lên cơ thể",
"chat_lieu": "Chất liệu vải (VD: Cotton 100%, Polyester pha). Ghi 'Không xác định' nếu không biết",
"tinh_nang_vai": "Tính năng nổi bật của vải (VD: co giãn 4 chiều, kháng khuẩn, thấm hút)",
"huong_dan_bao_quan": "Hướng dẫn giặt/bảo quản (VD: Giặt máy ≤30°C, không dùng tẩy)",
"phong_cach": "1-3 từ khóa phong cách (VD: Smart Casual, Minimalist)",
"tags": "5 tags phân loại, cách dấu phẩy",
"do_tuoi": "Khoảng tuổi phù hợp (VD: 20-35)",
"gioi_tinh": "Nam/Nữ/Unisex",
"loi_song": "Mô tả lối sống khách hàng mục tiêu",
"tinh_cach": "Tính cách phù hợp",
"dip_mac": "4+ dịp mặc cụ thể, ngăn bởi dấu ·",
"phoi_do": "3 combo phối đồ, ngăn bởi dấu |",
"nguyen_tac_phoi_do": "Lý do và quy tắc phối đồ (VD: Áo ôm nên phối quần suông để cân đối; Vải lanh hợp không khí biển)",
"tranh_phoi_cung": "Những item/dòng SP KHÔNG NÊN phối cùng (Negative constraints - VD: Tránh phối nỉ dày mùa hè, tránh quần jogger vì lệch phong cách)",
"layer": "Gợi ý layering",
"mua": "Mùa phù hợp",
"faq_1_q": "Câu hỏi FAQ 1 (về form dáng)",
"faq_1_a": "Trả lời FAQ 1",
"faq_2_q": "Câu hỏi FAQ 2 (về size)",
"faq_2_a": "Trả lời FAQ 2",
"faq_3_q": "Câu hỏi FAQ 3 (về mix match)",
"faq_3_a": "Trả lời FAQ 3",
"hook_quang_cao": "1 câu hook quảng cáo gây tò mò",
"cross_sell": "2-3 sản phẩm nên mua kèm",
"ly_do_mua": "3 lý do nên mua, ngăn bởi dấu |",
"luu_y_size": "Lưu ý chọn size cụ thể"
}
Trả về ĐÚNG JSON, không text thừa."""
CANIFA_PRODUCT_LINES = "Áo phông, Quần soóc, Áo nỉ, Váy liền, Quần nỉ, Tất, Áo len, Bộ mặc nhà, Bộ quần áo, Áo Polo, Chân váy, Áo nỉ có mũ, Áo Sơ mi, Quần jean, Quần dài, Áo khoác chần bông, Quần mặc nhà, Áo Body, Quần Khaki, Áo khoác gió, Áo khoác dáng ngắn, Áo kiểu, Áo len gilet, Áo khoác gilet chần bông, Áo giữ nhiệt, Áo mặc nhà, Quần lót, Pyjama, Áo ba lỗ, Áo khoác nỉ không mũ, Blazer, Cardigan, Khăn, Bộ thể thao, Áo khoác chống nắng, Quần váy, Quần lót đùi, Áo khoác, Khăn mặt, Áo khoác nỉ có mũ, Mũ, Áo khoác lông vũ, Khăn tắm, Áo lót, Quần lót tam giác, Túi xách, Quần leggings mặc nhà, Áo khoác sợi, Quần leggings, Chăn cá nhân, Mũ thể thao, Áo khoác dạ, Quần giữ nhiệt, Găng tay chống nắng, Khăn lau đầu, Áo khoác gilet, Áo hai dây, Khẩu trang, Quần culottes, Quần Body, Áo bra active"
ENRICHMENT_SYSTEM = """Bạn là Stylist cao cấp của Canifa — chuyên viết mô tả sản phẩm thời trang bằng tiếng Việt.
Phong cách: tự tin, chuyên gia, dùng từ ngữ gợi hình (hack dáng, tôn dáng, thoát dáng, che khuyết điểm).
Nếu có thông tin chất liệu từ Magento, PHẢI sử dụng chính xác. KHÔNG bịa chất liệu.
NẾU có description gốc, trích xuất chất liệu, tính năng vải, hướng dẫn giặt từ đó.
KHÔNG nói về giá cả. Luôn trả về JSON chuẩn 28 trường."""
ENRICHMENT_EXAMPLE = """{
"ten_san_pham": "Áo Polo Nam Cổ Bẻ Tay Ngắn",
"tagline": "Lịch lãm không cần cố gắng",
"mo_ta_chinh": "Form regular fit vừa vặn, tôn dáng mà không gò bó. Thiết kế cổ bẻ tối giản nhưng lịch sự, phù hợp cả office lẫn weekend. Phom vai suông giúp che nhẹ bắp tay, hack dáng thanh thoát hơn.",
"chat_lieu": "Cotton USA 100%",
"tinh_nang_vai": "Co giãn nhẹ, thấm hút mồ hôi tốt, giữ form sau nhiều lần giặt",
"huong_dan_bao_quan": "Giặt máy ≤30°C, lộn trái, không dùng tẩy, phơi trong bóng râm",
"phong_cach": "Smart Casual, Minimalist",
"tags": "polo, nam, công sở, smart casual, tay ngắn, cotton usa",
"do_tuoi": "25-40",
"gioi_tinh": "Nam",
"loi_song": "Người đàn ông hiện đại, coi trọng phong cách nhưng không cầu kỳ",
"tinh_cach": "Tự tin, chỉn chu, thực dụng",
"dip_mac": "Đi làm hàng ngày · Cafe cuối tuần · Gặp đối tác · Đi chơi cùng gia đình",
"phoi_do": "Quần Chinos Beige + Giày Loafer Da → Thanh lịch cuối tuần | Quần Jean Slim Xanh Đậm + Sneaker Trắng → Năng động công sở | Quần Short Khaki + Slide Da → Relax ngày hè",
"nguyen_tac_phoi_do": "Polo form regular có phần cổ bẻ lịch sự, nguyên tắc phối là mix CÙNG ĐỘ FIT (quần dáng slim hoặc straight) ở thân dưới. Quần quá rộng sẽ làm mất đi độ thanh lịch của áo.",
"tranh_phoi_cung": "Tránh phối cùng quần jogger nỉ quá thể thao hoặc quần đùi chạy bộ vì sẽ lệch pha phong cách hoàn toàn.",
"layer": "Mặc đơn mùa hè. Layer cùng Áo Khoác Bomber hoặc Blazer mỏng cho mùa thu.",
"mua": "Xuân Hè",
"faq_1_q": "Áo này có hack dáng không?",
"faq_1_a": "Phom regular fit vai suông giúp che nhẹ bắp tay, tạo cảm giác thanh thoát hơn — đặc biệt hợp với anh em có phần trên hơi đầy.",
"faq_2_q": "Cao 1m70 nặng 65kg nên mặc size gì?",
"faq_2_a": "Nên chọn size M. Nếu thích mặc hơi rộng thoải mái thì lên L — nhưng M sẽ chuẩn form nhất.",
"faq_3_q": "Phối áo này với quần gì đẹp nhất?",
"faq_3_a": "Combo kinh điển: Chinos hoặc Jean slim + Loafer. Muốn casual hơn: Short khaki + Sneaker trắng.",
"hook_quang_cao": "Chiếc polo mà anh em mặc đi làm rồi đi cafe luôn — không cần thay đồ",
"cross_sell": "Quần Chinos Slim, Giày Loafer Da, Thắt Lưng Da Reversible",
"ly_do_mua": "Form hack dáng thanh thoát | Mặc đi làm hay đi chơi đều ổn | Cổ bẻ lịch sự không cần phối nhiều",
"luu_y_size": "Nếu nặng trên 75kg nên lên 1 size. Form regular nên mặc đúng size sẽ vừa nhất."
}"""
# ═══ Helpers ═══
# Label mapping for rendering JSON → readable text
_FIELD_LABELS = {
"ten_san_pham": "Tên sản phẩm",
"tagline": "Tagline",
"mo_ta_chinh": "Mô tả chính",
"chat_lieu": "Chất liệu",
"tinh_nang_vai": "Tính năng vải",
"huong_dan_bao_quan": "Hướng dẫn bảo quản",
"phong_cach": "Phong cách",
"tags": "Tags",
"do_tuoi": "Độ tuổi",
"gioi_tinh": "Giới tính",
"loi_song": "Lối sống",
"tinh_cach": "Tính cách",
"dip_mac": "Dịp mặc",
"phoi_do": "Gợi ý phối đồ",
"nguyen_tac_phoi_do": "Nguyên tắc phối đồ",
"tranh_phoi_cung": "Tránh phối cùng",
"layer": "Gợi ý layering",
"mua": "Mùa phù hợp",
"faq_1_q": "FAQ 1 - Câu hỏi",
"faq_1_a": "FAQ 1 - Trả lời",
"faq_2_q": "FAQ 2 - Câu hỏi",
"faq_2_a": "FAQ 2 - Trả lời",
"faq_3_q": "FAQ 3 - Câu hỏi",
"faq_3_a": "FAQ 3 - Trả lời",
"hook_quang_cao": "Hook quảng cáo",
"cross_sell": "Cross-sell",
"ly_do_mua": "Lý do nên mua",
"luu_y_size": "Lưu ý chọn size",
}
# Section grouping for rendered output
_SECTIONS = [
("📦 THÔNG TIN CƠ BẢN", ["ten_san_pham", "tagline", "mo_ta_chinh", "phong_cach", "tags"]),
("🧵 CHẤT LIỆU & BẢO QUẢN", ["chat_lieu", "tinh_nang_vai", "huong_dan_bao_quan"]),
("🎯 ĐỐI TƯỢNG", ["do_tuoi", "gioi_tinh", "loi_song", "tinh_cach"]),
("📅 DỊP MẶC & STYLING", ["dip_mac", "phoi_do", "nguyen_tac_phoi_do", "tranh_phoi_cung", "layer", "mua"]),
("❓ FAQ", ["faq_1_q", "faq_1_a", "faq_2_q", "faq_2_a", "faq_3_q", "faq_3_a"]),
("🛒 HỖ TRỢ BÁN HÀNG", ["hook_quang_cao", "cross_sell", "ly_do_mua", "luu_y_size"]),
]
def _render_description_text(data: dict, product_name: str = "") -> str:
"""Render JSON description_data into a single formatted text block."""
if not data:
return ""
lines = []
title = data.get("ten_san_pham", product_name or "Sản phẩm")
lines.append(f"{'=' * 50}")
lines.append(f" {title.upper()}")
lines.append(f"{'=' * 50}")
lines.append("")
for section_title, fields in _SECTIONS:
section_lines = []
for key in fields:
val = data.get(key, "")
if not val:
continue
label = _FIELD_LABELS.get(key, key)
# FAQ: group Q+A together
if key.endswith("_q"):
section_lines.append(f" 💬 {val}")
continue
elif key.endswith("_a"):
section_lines.append(f" → {val}")
continue
# Phối đồ: format combos nicely
if key == "phoi_do" and "|" in str(val):
section_lines.append(f" {label}:")
for combo in str(val).split("|"):
section_lines.append(f" • {combo.strip()}")
continue
# Lý do mua: format with bullets
if key == "ly_do_mua" and "|" in str(val):
section_lines.append(f" {label}:")
for reason in str(val).split("|"):
section_lines.append(f" • {reason.strip()}")
continue
# Dịp mặc: keep · separator
section_lines.append(f" {label}: {val}")
if section_lines:
lines.append(f"── {section_title} ──")
lines.extend(section_lines)
lines.append("")
return "\n".join(lines)
def _extract_json(raw_text: str | list | dict) -> dict | None:
"""Parse JSON safely from LLM output."""
if not raw_text:
return None
# Handle list response (e.g., from Langchain Gemini multimodal fallback)
if isinstance(raw_text, list):
text_parts = []
for part in raw_text:
if isinstance(part, dict) and "text" in part:
text_parts.append(part["text"])
elif isinstance(part, str):
text_parts.append(part)
raw_text = "".join(text_parts)
# If it's a dict somehow, just return it directly or stringify
if isinstance(raw_text, dict):
return raw_text
if not isinstance(raw_text, str):
raw_text = str(raw_text)
# Try ```json ... ``` block
if "```" in raw_text:
match = re.search(r'```(?:json)?\s*([\s\S]*?)```', raw_text)
if match:
try:
return json.loads(match.group(1).strip())
except json.JSONDecodeError:
pass
# Try { ... } directly
match = re.search(r'\{[\s\S]*\}', raw_text)
if match:
try:
return json.loads(match.group(0))
except json.JSONDecodeError:
pass
try:
return json.loads(raw_text.strip())
except json.JSONDecodeError:
return None
def _is_groq_model(model_name: str) -> bool:
"""Groq models: openai/gpt-oss-*, meta-llama/*, mixtral/*, etc."""
m = model_name.lower()
return any(kw in m for kw in ("gpt-oss", "llama", "mixtral", "gemma", "qwen", "deepseek")) or m.startswith("openai/")
async def _call_ai_with_fallback(messages: list, model: str, max_tokens: int = 3000, temperature: float = 0.7, is_vision: bool = False) -> str:
"""
Smart Groq-only pipeline with multi-key round-robin rotation.
3 API keys = 3x throughput. Each model tries all keys before giving up.
"""
# ── Groq httpx (fast, free, multi-key) ──
if _is_groq_model(model):
if is_vision:
groq_chain = [model]
else:
groq_chain = [model]
for backup in ["openai/gpt-oss-120b", "openai/gpt-oss-20b", "qwen/qwen3-32b"]:
if backup != model and backup not in groq_chain:
groq_chain.append(backup)
while True:
for groq_model in groq_chain:
# Try each key (round-robin) — if one key is rate-limited, try next key instantly
for key_attempt in range(len(GROQ_API_KEYS)):
api_key = _next_groq_key()
key_tag = f"K{(_groq_key_index - 1) % len(GROQ_API_KEYS) + 1}"
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
"https://api.groq.com/openai/v1/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": groq_model,
"max_tokens": max_tokens,
"temperature": temperature,
"messages": messages,
},
)
if resp.status_code == 200:
result = resp.json()
if "error" not in result:
logger.info(f"🏆 Groq [{key_tag}] {groq_model}")
return result["choices"][0]["message"]["content"]
logger.warning(f"⚠️ Groq [{key_tag}] {groq_model} API Error: {result['error']}")
break # Go to next model
elif resp.status_code == 429:
logger.warning(f"⚠️ Groq [{key_tag}] {groq_model} Rate limit → rotating key...")
continue # Try next key instantly!
elif resp.status_code >= 500:
logger.warning(f"⚠️ Groq [{key_tag}] {groq_model} Server Error {resp.status_code}: {resp.text[:100]}...")
continue
else:
logger.warning(f"⚠️ Groq [{key_tag}] {groq_model} HTTP {resp.status_code}: {resp.text[:200]}")
break # Other errors like 400 Bad request -> go to next model
except Exception as e:
logger.warning(f"⚠️ Groq [{key_tag}] {groq_model} Exception: {e}")
break
else:
# All keys exhausted for this model — wait a bit then try next model
logger.warning(f"⚠️ All {len(GROQ_API_KEYS)} keys exhausted for {groq_model} → waiting 4s...")
await asyncio.sleep(4)
continue
logger.warning("🔄 Toàn bộ model và key đã bị giới hạn, tiếp tục vòng mới sau 5s...")
await asyncio.sleep(5)
async def _fetch_image_base64(image_url: str) -> str:
"""Download image and convert to base64."""
import base64
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(image_url, headers={"User-Agent": "Mozilla/5.0"})
resp.raise_for_status()
return base64.b64encode(resp.content).decode("utf-8")
# ═══ 1. OVERVIEW — Stats on descriptions ═══
@router.get("/overview", summary="Overview stats: total products, how many have descriptions")
async def overview():
"""Return count of products with/without ultra descriptions (from Postgres)."""
db = get_db_connection()
try:
# Total products from StarRocks
rows = await db.execute_query_async(f"""
SELECT COUNT(DISTINCT internal_ref_code) AS total
FROM {TABLE_NAME}
""")
total = rows[0]["total"] if rows else 0
# Saved descriptions from Postgres
pg_stats = UltraDescriptionDB.get_stats()
has_desc = pg_stats.get("total", 0)
return {
"status": "success",
"total": total,
"has_desc": has_desc,
"approved": pg_stats.get("approved", 0),
"pending": pg_stats.get("pending", 0),
"missing": total - has_desc,
"progress": round(has_desc / total * 100, 1) if total > 0 else 0,
"db_stats": {
"enriched": pg_stats.get("enriched", 0),
"raw_only": pg_stats.get("raw_only", 0),
"last_updated": str(pg_stats.get("last_updated", "")) if pg_stats.get("last_updated") else None,
},
}
except Exception as e:
logger.error(f"Overview error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.get("/batch-status", summary="Get progress of current batch generation")
async def batch_status():
"""Return the progress of the background batch task."""
return BATCH_STATE
# ═══ 2. LIST — Products with description status ═══
@router.get("/list", summary="List products with description status")
async def product_desc_list(
status: Optional[str] = Query(None, description="Filter: 'missing' or 'has_desc'"),
search: Optional[str] = Query(None, description="Search by name or code"),
product_line: Optional[str] = Query(None, description="Filter by product line"),
limit: int = Query(30, ge=1, le=200),
page: int = Query(1, ge=1),
):
"""List products with their description status."""
db = get_db_connection()
offset = (page - 1) * limit
clauses = []
params = []
if search:
clauses.append("(LOWER(product_name) LIKE %s OR internal_ref_code LIKE %s)")
params.extend([f"%{search.lower()}%", f"%{search.upper()}%"])
if product_line:
clauses.append("LOWER(product_line_vn) LIKE %s")
params.append(f"%{product_line.lower()}%")
where = " AND ".join(clauses) if clauses else "1=1"
try:
# Get codes with their approval status from Postgres
code_status_map = UltraDescriptionDB.get_all_codes_with_status()
# Count
count_rows = await db.execute_query_async(
f"SELECT COUNT(DISTINCT internal_ref_code) AS total FROM {TABLE_NAME} WHERE {where}",
params=tuple(params) if params else None,
)
total = count_rows[0]["total"] if count_rows else 0
products = await db.execute_query_async(
f"""
SELECT
internal_ref_code,
ANY_VALUE(product_name) AS product_name,
ANY_VALUE(product_image_url_thumbnail) AS product_image_url_thumbnail,
ANY_VALUE(product_line_vn) AS product_line_vn,
ANY_VALUE(gender_by_product) AS gender_by_product,
MIN(sale_price) AS sale_price,
MAX(original_price) AS original_price,
MAX(quantity_sold) AS quantity_sold,
COUNT(DISTINCT product_color_code) AS color_count,
GROUP_CONCAT(DISTINCT master_color) AS colors
FROM {TABLE_NAME}
WHERE {where}
GROUP BY internal_ref_code
ORDER BY quantity_sold DESC
LIMIT %s OFFSET %s
""",
params=tuple(params + [limit, offset]),
)
# Mark desc_status: -1 = missing, 0 = pending, 1 = approved
for p in products:
code = p.get("internal_ref_code")
if code in code_status_map:
p["has_desc"] = 1
p["desc_status"] = code_status_map[code] # 0 or 1
else:
p["has_desc"] = 0
p["desc_status"] = -1 # no description
# Post-filter by status
if status == "missing":
products = [p for p in products if p["desc_status"] == -1]
elif status == "has_desc":
products = [p for p in products if p["desc_status"] >= 0]
elif status == "pending":
products = [p for p in products if p["desc_status"] == 0]
elif status == "approved":
products = [p for p in products if p["desc_status"] == 1]
return {
"status": "success",
"total": total,
"limit": limit,
"page": page,
"products": products,
}
except Exception as e:
logger.error(f"Product desc list error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
# ═══ 3. GENERATE — Generate description for a single product ═══
class GenerateRequest(BaseModel):
internal_ref_code: str
@router.post("/generate", summary="Generate ultra description for a product by code")
async def generate_description(req: GenerateRequest):
"""
Full pipeline:
1. Fetch product data + image from StarRocks
2. Phase 1: Vision → raw JSON from image
3. Phase 2: Enrichment → ultra description using real stock data
"""
db = get_db_connection()
try:
# ── Fetch product data ──
rows = await db.execute_query_async(
f"""
SELECT
internal_ref_code,
ANY_VALUE(product_name) AS product_name,
ANY_VALUE(product_image_url_thumbnail) AS image_url,
MIN(sale_price) AS sale_price,
MAX(original_price) AS original_price,
ANY_VALUE(product_line_vn) AS product_line,
ANY_VALUE(gender_by_product) AS gender,
ANY_VALUE(age_by_product) AS age,
MAX(quantity_sold) AS quantity_sold,
ANY_VALUE(size_scale) AS size_scale,
COUNT(DISTINCT product_color_code) AS color_count,
GROUP_CONCAT(DISTINCT master_color) AS colors,
ANY_VALUE(description_text) AS description_text
FROM {TABLE_NAME}
WHERE internal_ref_code = %s
GROUP BY internal_ref_code
""",
params=(req.internal_ref_code,),
)
if not rows:
return JSONResponse(status_code=404, content={"status": "error", "message": "Không tìm thấy sản phẩm"})
product = rows[0]
image_url = product.get("image_url", "")
if not image_url:
return JSONResponse(status_code=400, content={"status": "error", "message": "Sản phẩm không có ảnh"})
# ── Phase 1: Vision ──
logger.info(f"📸 Phase 1: Vision analyzing {req.internal_ref_code}...")
image_base64 = await _fetch_image_base64(image_url)
# ── Fetch Active Fields ──
active_fields = DescFieldConfig.get_active_fields()
num_fields = len(active_fields)
schema_lines = []
instruction_lines = []
for f in active_fields:
schema_lines.append(f' "{f["field_key"]}": "{f["field_label"]}"')
if f["field_instruction"]:
instruction_lines.append(f'- {f["field_key"]}: {f["field_instruction"]}')
schema_json = "{\n" + ",\n".join(schema_lines) + "\n}"
instruction_text = "\n".join(instruction_lines)
dynamic_vision_prompt = f"""Bạn là chuyên gia phân tích sản phẩm thời trang. Nhìn ảnh sản phẩm và trả về JSON với ĐÚNG {num_fields} trường sau.
QUAN TRỌNG: KHÔNG đoán chất liệu nếu không chắc. KHÔNG bỏ trống trường nào. Nếu không chắc, ghi "Không xác định".
{schema_json}
Trả về ĐÚNG JSON, không text thừa."""
vision_messages = [
{"role": "system", "content": dynamic_vision_prompt},
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_base64}"}},
{"type": "text", "text": "Phân tích kỹ sản phẩm thời trang trong ảnh. Trả về JSON 20 trường. KHÔNG đoán chất liệu."},
],
},
]
raw_output = await _call_ai_with_fallback(vision_messages, model=VISION_MODEL, temperature=0.2, is_vision=True)
raw_json = _extract_json(raw_output)
if not raw_json:
return JSONResponse(status_code=500, content={
"status": "error", "message": "Phase 1 thất bại: không parse được JSON từ ảnh",
"raw": raw_output[:500],
})
logger.info(f"✅ Phase 1 OK: {len(raw_json)} fields")
# ── Phase 2: Enrichment with real data ──
logger.info(f"✨ Phase 2: Enrichment with stock data...")
magento_desc = product.get('description_text', '') or ''
stock_context = f"""
THÔNG TIN THẬT TỪ DATABASE (ưu tiên dùng data này):
- Tên sản phẩm: {product.get('product_name', '')}
- Giá gốc: {product.get('original_price', '')}đ | Giá sale: {product.get('sale_price', '')}đ
- Giới tính: {product.get('gender', '')}
- Độ tuổi: {product.get('age', '')}
- Dòng sản phẩm: {product.get('product_line', '')}
- Bảng size: {product.get('size_scale', '')}
- Số màu: {product.get('color_count', '')} ({product.get('colors', '')})
- Lượt bán: {product.get('quantity_sold', '')}
{f'- MÔ TẢ GỐC TỪ MAGENTO (trích xuất chất liệu, tính năng vải, hướng dẫn giặt từ đây): {magento_desc[:500]}' if magento_desc else '- Mô tả gốc: Không có'}
DANH SÁCH DÒNG SẢN PHẨM CỦA CANIFA (dùng cho cross_sell và phoi_do):
{CANIFA_PRODUCT_LINES}
"""
user_content = f"""Đây là JSON thô từ AI Vision. Nhiệm vụ: VIẾT LẠI TOÀN BỘ {num_fields} trường với văn phong Stylist đỉnh cao.
QUY TẮC:
- KHÔNG copy-paste. Mỗi trường phải được VIẾT LẠI hoàn toàn.
{instruction_text}
- CẤM nói về giá cả.
{stock_context}
VÍ DỤ OUTPUT (học theo format):
{ENRICHMENT_EXAMPLE}
JSON THÔ CẦN VIẾT LẠI:
{json.dumps(raw_json, ensure_ascii=False, indent=2)}
Trả về ĐÚNG JSON, không text rác."""
enrich_messages = [
{"role": "system", "content": ENRICHMENT_SYSTEM},
{"role": "user", "content": user_content},
]
enriched_output = await _call_ai_with_fallback(enrich_messages, model=ENRICH_MODEL, max_tokens=3000, temperature=0.7)
enriched_json = _extract_json(enriched_output)
if enriched_json:
logger.info(f"✅ Phase 2 OK: {len(enriched_json)} fields enriched")
final_data = enriched_json
final_phase = "enriched"
else:
logger.warning("⚠️ Phase 2 failed JSON parse, saving raw text as metadata fallback")
final_data = raw_json.copy() if raw_json else {}
final_data["_ai_raw_metadata"] = {
"error": "JSON_PARSE_ERROR",
"content": enriched_output
}
final_phase = "raw_fallback"
# ── Save to PostgreSQL ──
try:
# Note: UltraDescriptionDB.save() already has ON CONFLICT DO UPDATE SET status = 0
# which automatically resets the approval status back to "Chờ duyệt" (0).
UltraDescriptionDB.save(
internal_ref_code=req.internal_ref_code,
product_name=product.get("product_name"),
product_image_url=image_url,
product_line=product.get("product_line"),
description_data=final_data,
phase=final_phase,
clean_description=_render_description_text(final_data, product.get("product_name", "")),
)
logger.info(f"💾 Saved to PostgreSQL: {req.internal_ref_code}")
except Exception as save_err:
logger.error(f"⚠️ Save to DB failed (still returning data): {save_err}")
result = {
"status": "success",
"phase": final_phase,
"product": product,
"data": final_data,
"phase1_raw": raw_json,
"saved": True,
}
if final_phase == "raw":
result["phase2_raw_text"] = enriched_output[:2000]
result["warning"] = "Phase 2 trả về text không phải JSON. Hiển thị data thô Phase 1."
return result
except httpx.TimeoutException:
return JSONResponse(status_code=504, content={"status": "error", "message": "Groq API timeout"})
except Exception as e:
logger.error(f"Generate error: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
# ═══ 4. PRODUCT LINE FILTERS ═══
@router.get("/filters", summary="Available product lines for filtering")
async def desc_filters():
db = get_db_connection()
try:
lines = await db.execute_query_async(f"""
SELECT product_line_vn, COUNT(DISTINCT internal_ref_code) AS cnt
FROM {TABLE_NAME}
WHERE product_line_vn IS NOT NULL AND product_line_vn != ''
GROUP BY product_line_vn
ORDER BY cnt DESC
""")
return {"status": "success", "product_lines": lines}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
# ═══ 5. GET SAVED — Retrieve saved description from Postgres ═══
@router.get("/saved/{internal_ref_code}", summary="Get saved ultra description")
async def get_saved_desc(internal_ref_code: str):
"""Retrieve a previously saved ultra description from Postgres."""
try:
row = UltraDescriptionDB.get_by_code(internal_ref_code)
if not row:
return JSONResponse(status_code=404, content={
"status": "not_found",
"message": f"Chưa có ultra description cho {internal_ref_code}",
})
# Convert datetime to string for JSON serialization
result = {}
for k, v in row.items():
if hasattr(v, 'isoformat'):
result[k] = v.isoformat()
else:
result[k] = v
# Render formatted text from description_data
desc_data = result.get("description_data", {})
if isinstance(desc_data, str):
import json as _json
try:
desc_data = _json.loads(desc_data)
except Exception:
desc_data = {}
result["rendered_text"] = _render_description_text(desc_data, result.get("product_name", ""))
return {"status": "success", "description": result}
except Exception as e:
logger.error(f"Get saved desc error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
class UpdateDescRequest(BaseModel):
description_data: dict
@router.post("/saved/{internal_ref_code}/update", summary="Manually update saved description fields")
async def update_saved_desc(internal_ref_code: str, req: UpdateDescRequest):
"""Update description_data manually from UI and re-render clean_description."""
try:
row = UltraDescriptionDB.get_by_code(internal_ref_code)
if not row:
return JSONResponse(status_code=404, content={"status": "error", "message": "Not found"})
# Render clean formatting
clean_desc = _render_description_text(req.description_data, row.get("product_name", ""))
# Update DB
updated = UltraDescriptionDB.update_data(internal_ref_code, req.description_data, clean_desc)
if updated:
return {"status": "success", "message": "Đã cập nhật nội dung", "clean_description": clean_desc}
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to update DB"})
except Exception as e:
logger.error(f"Update desc error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
class UpdateTextRequest(BaseModel):
clean_description: str
@router.post("/saved/{internal_ref_code}/update-text", summary="Manually update rendered text from popup")
async def update_saved_desc_text(internal_ref_code: str, req: UpdateTextRequest):
"""Save the manually edited HTML/text from the popup back to the database."""
try:
updated = UltraDescriptionDB.update_clean_text(internal_ref_code, req.clean_description)
if updated:
return {"status": "success", "message": "Đã lưu nội dung text"}
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to update DB"})
except Exception as e:
logger.error(f"Update text error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
class RewriteFieldRequest(BaseModel):
field_key: str
@router.post("/saved/{internal_ref_code}/rewrite-field", summary="Rewrite a specific field using AI")
async def rewrite_field_ai(internal_ref_code: str, req: RewriteFieldRequest):
"""Use AI to rewrite a single specific field in the description JSON."""
try:
from llm.api_factory import get_llm
row = UltraDescriptionDB.get_by_code(internal_ref_code)
if not row:
return JSONResponse(status_code=404, content={"status": "error", "message": "Not found"})
desc_data = row.get("description_data", {})
if isinstance(desc_data, str):
import json
desc_data = json.loads(desc_data)
old_value = desc_data.get(req.field_key, "")
# Get instructions for this field
fields = DescFieldConfig.get_active_fields()
field_instr = ""
for f in fields:
if f["field_key"] == req.field_key:
field_instr = f["field_instruction"]
break
prompt = f"""
Bạn là một chuyên gia sáng tạo nội dung thời trang của Canifa.
Nhiệm vụ của bạn là VIẾT LẠI MỘT trường thông tin duy nhất cho chuẩn, hay và tự nhiên hơn.
- Trường cần viết lại: {req.field_key}
- Định hướng (NẾU CÓ): {field_instr}
[Nội dung cũ của trường]:
{old_value}
Yêu cầu:
- Viết lại nội dung cho hấp dẫn hơn, đúng văn phong chuyên nghiệp.
- KHÔNG tạo ra thêm các định dạng markdown gạch đầu dòng nếu nội dung cũ không có.
- CHỈ TRẢ VỀ NỘI DUNG MỚI. KHÔNG GIẢI THÍCH, KHÔNG CHAT.
"""
llm = get_llm(temperature=0.7)
resp = await llm.ainvoke(prompt)
new_text = resp.content.strip()
new_text = new_text.replace("```json", "").replace("```html", "").replace("```", "").strip()
# update JSON
desc_data[req.field_key] = new_text
# Update clean desc
clean_desc = _render_description_text(desc_data, row.get("product_name", ""))
updated = UltraDescriptionDB.update_data(internal_ref_code, desc_data, clean_desc)
if updated:
return {"status": "success", "new_value": new_text, "clean_description": clean_desc}
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to update DB"})
except Exception as e:
logger.error(f"Rewrite error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
# ═══ 6. APPROVE / REJECT ═══
class StatusRequest(BaseModel):
internal_ref_code: str
status: int # 0 = pending, 1 = approved
@router.post("/approve", summary="Approve or reject a description")
async def approve_desc(req: StatusRequest):
"""Set status: 0 = chờ duyệt, 1 = đã duyệt. Khi duyệt → tự render clean_description."""
if req.status not in (0, 1):
return JSONResponse(status_code=400, content={"status": "error", "message": "Status phải là 0 hoặc 1"})
try:
clean_desc = ""
if req.status == 1:
# Render clean_description khi duyệt
row = UltraDescriptionDB.get_by_code(req.internal_ref_code)
if row:
desc_data = row.get("description_data", {})
if isinstance(desc_data, str):
try:
desc_data = json.loads(desc_data)
except Exception:
desc_data = {}
clean_desc = _render_description_text(desc_data, row.get("product_name", ""))
updated = UltraDescriptionDB.set_status(req.internal_ref_code, req.status, clean_description=clean_desc)
if updated:
label = "Đã duyệt" if req.status == 1 else "Hủy duyệt"
return {"status": "success", "message": f"{label}: {req.internal_ref_code}"}
return JSONResponse(status_code=404, content={
"status": "not_found",
"message": f"Không tìm thấy description cho {req.internal_ref_code}",
})
except Exception as e:
logger.error(f"Approve error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.post("/approve-all", summary="Bulk approve ALL pending descriptions")
async def approve_all_desc():
"""Set all status=0 to status=1 in one shot."""
try:
count = UltraDescriptionDB.approve_all_pending(render_fn=_render_description_text)
return {"status": "success", "message": f"✅ Đã duyệt toàn bộ {count} mô tả!", "approved_count": count}
except Exception as e:
logger.error(f"Approve all error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
# ═══ 7. DELETE ═══
@router.delete("/saved/{internal_ref_code}", summary="Delete saved ultra description")
async def delete_saved_desc(internal_ref_code: str):
"""Delete a saved ultra description from Postgres."""
try:
deleted = UltraDescriptionDB.delete_by_code(internal_ref_code)
if deleted:
return {"status": "success", "message": f"Đã xóa description cho {internal_ref_code}"}
return JSONResponse(status_code=404, content={
"status": "not_found",
"message": f"Không tìm thấy description cho {internal_ref_code}",
})
except Exception as e:
logger.error(f"Delete desc error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
# ═══ 8. BATCH GENERATE (REDIS QUEUE) ═══
class BatchGenerateRequest(BaseModel):
internal_ref_codes: list[str]
@router.post("/batch-generate", summary="Trigger batch generation via Redis Worker")
async def batch_generate_descriptions(req: BatchGenerateRequest):
"""Start background generation for multiple products via Redis queue."""
if not req.internal_ref_codes:
return JSONResponse(status_code=400, content={"status": "error", "message": "List products is empty"})
# Capping at 50 products per request for safety
codes = req.internal_ref_codes[:50]
redis = redis_cache.get_client()
if not redis:
return JSONResponse(status_code=500, content={"status": "error", "message": "Redis cache is offline, cannot run batch"})
job_id = str(uuid.uuid4())
job_payload = {
"job_id": job_id,
"codes": codes
}
try:
await redis.lpush("desc:queue", json.dumps(job_payload))
except Exception as e:
logger.error(f"Redis error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to enqueue job in Redis"})
return {
"status": "success",
"message": f"Đưa {len(codes)} sản phẩm vào hàng đợi xử lý Background Worker.",
"job_id": job_id,
"queued_codes": codes
}
# ═══ 9. BATCH GENERATE ALL (REDIS QUEUE) ═══
@router.post("/batch-generate-all", summary="Trigger batch generation for ALL missing products up to 500 items")
async def batch_generate_all():
try:
# 1. Fetch all existing codes from Postgres
code_status_map = UltraDescriptionDB.get_all_codes_with_status()
# 2. Fetch all unique product codes from StarRocks
db = get_db_connection()
query = f"SELECT DISTINCT internal_ref_code FROM {TABLE_NAME}"
rows = await db.execute_query_async(query)
all_codes = [r["internal_ref_code"] for r in rows if r.get("internal_ref_code")]
# 3. Filter to find missing codes
missing_codes = [code for code in all_codes if code not in code_status_map]
# 4. Limit to 500
codes = missing_codes[:500]
if not codes:
return {"status": "success", "message": "Tuyệt vời! Không có sản phẩm nào thiếu AI Description."}
redis = redis_cache.get_client()
if not redis:
return JSONResponse(status_code=500, content={"status": "error", "message": "Redis cache is offline"})
job_id = str(uuid.uuid4())
job_payload = {
"job_id": job_id,
"codes": codes
}
await redis.lpush("desc:queue", json.dumps(job_payload))
return {
"status": "success",
"message": f"Tìm thấy {len(codes)} sản phẩm thiếu. Đã đưa vào queue cho Background Worker.",
"job_id": job_id,
"queued_codes": codes
}
except Exception as e:
logger.error(f"Batch All Error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.get("/batch-status", summary="Get status of latest batch generation job")
@router.get("/batch-status/{job_id}", summary="Get status of a specific batch generation job")
async def get_batch_status(job_id: Optional[str] = None):
"""Retrieve progress status from Redis."""
try:
redis = redis_cache.get_client()
if not redis:
return JSONResponse(status_code=500, content={"status": "error", "message": "Redis is offline"})
if not job_id:
job_id = await redis.get("desc:progress:latest_job")
if not job_id:
return {
"status": "success",
"progress": None
}
if isinstance(job_id, bytes):
job_id = job_id.decode('utf-8')
progress_key = f"desc:progress:{job_id}"
data = await redis.hgetall(progress_key)
if not data:
return {
"status": "success",
"message": "Job not found or already expired",
"progress": None
}
# Since decode_responses=True, data is already string
decoded_data = data
return {
"status": "success",
"job_id": job_id,
"progress": {
"is_running": decoded_data.get("is_running") == "true",
"total": int(decoded_data.get("total", 0)),
"done": int(decoded_data.get("done", 0)),
"errors": int(decoded_data.get("errors", 0)),
"current_code": decoded_data.get("current_code", "")
}
}
except Exception as e:
logger.error(f"Batch Status Error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
# ═══ 10. DYNAMIC FIELDS CRUD ═══
class FieldCreateRequest(BaseModel):
field_key: str
field_label: str
field_instruction: str = ""
sort_order: int = 99
class FieldUpdateRequest(BaseModel):
field_label: str | None = None
field_instruction: str | None = None
is_active: bool | None = None
sort_order: int | None = None
@router.get("/fields", summary="Get all dynamic fields")
async def get_fields():
"""Get all dynamic fields (active and inactive) for admin UI."""
try:
fields = DescFieldConfig.get_all_fields()
return {"status": "success", "data": fields}
except Exception as e:
logger.error(f"Get fields error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.post("/fields", summary="Add a new dynamic field")
async def add_field(req: FieldCreateRequest):
"""Add a new custom field to the description schema."""
try:
success = DescFieldConfig.add_field(
req.field_key, req.field_label, req.field_instruction, req.sort_order
)
if success:
return {"status": "success", "message": f"Đã thêm trường {req.field_key}"}
return JSONResponse(status_code=400, content={"status": "error", "message": "Field key may already exist"})
except Exception as e:
logger.error(f"Add field error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.put("/fields/{field_key}", summary="Update a dynamic field")
async def update_field(field_key: str, req: FieldUpdateRequest):
"""Update a custom field (label, instruction, status, order)."""
try:
success = DescFieldConfig.update_field(
field_key, req.field_label, req.field_instruction, req.is_active, req.sort_order
)
if success:
return {"status": "success", "message": f"Đã cập nhật trường {field_key}"}
return JSONResponse(status_code=404, content={"status": "error", "message": f"Không tìm thấy trường {field_key}"})
except Exception as e:
logger.error(f"Update field error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.delete("/fields/{field_key}", summary="Delete a dynamic field")
async def delete_field(field_key: str):
"""Completely remove a field from the schema."""
try:
success = DescFieldConfig.delete_field(field_key)
if success:
return {"status": "success", "message": f"Đã xóa trường {field_key}"}
return JSONResponse(status_code=404, content={"status": "error", "message": f"Không tìm thấy trường {field_key}"})
except Exception as e:
logger.error(f"Delete field error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
"""
Roadmap & Flow API — data from Postgres tables.
"""
import logging
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from config import CHECKPOINT_POSTGRES_URL
from api.notes_route import _get_pool, _now
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/dashboard", tags=["Roadmap & Flow"])
# ═══════════════════════════════════════════════════
# ROADMAP API
# ═══════════════════════════════════════════════════
class RoadmapCreate(BaseModel):
version: str
title: str
description: str = ""
status: str = "planned"
badge: str = "planned"
target_date: str = ""
sort_order: int = 0
class RoadmapUpdate(BaseModel):
version: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
badge: Optional[str] = None
target_date: Optional[str] = None
sort_order: Optional[int] = None
async def _query(q: str, params: tuple = ()) -> list[dict]:
pool = await _get_pool()
async with pool.connection() as conn:
async with conn.cursor() as cur:
await cur.execute(q, params)
cols = [d[0] for d in cur.description] if cur.description else []
rows = await cur.fetchall()
return [dict(zip(cols, row)) for row in rows]
async def _execute(q: str, params: tuple = ()):
pool = await _get_pool()
async with pool.connection() as conn:
async with conn.cursor() as cur:
await cur.execute(q, params)
await conn.commit()
def _serialize(row: dict) -> dict:
r = {**row}
for k in ("created_at", "updated_at"):
if k in r and r[k]:
r[k] = r[k].isoformat()
return r
@router.get("/roadmap")
async def list_roadmap():
"""Get all roadmap items grouped by version."""
rows = await _query(
"SELECT * FROM roadmap_items ORDER BY version DESC, sort_order ASC"
)
items = [_serialize(r) for r in rows]
# Group by version
grouped = {}
for item in items:
v = item["version"]
if v not in grouped:
grouped[v] = {"version": v, "badge": item.get("badge", "planned"), "target_date": item.get("target_date", ""), "items": []}
grouped[v]["items"].append(item)
return {"status": "success", "versions": list(grouped.values()), "total": len(items)}
@router.post("/roadmap")
async def create_roadmap_item(item: RoadmapCreate):
"""Add a new roadmap item."""
rid = str(uuid.uuid4())[:8]
now = _now()
await _execute(
"""INSERT INTO roadmap_items (id, version, title, description, status, badge, target_date, sort_order, created_at, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(rid, item.version, item.title, item.description, item.status, item.badge, item.target_date, item.sort_order, now, now),
)
return {"status": "success", "id": rid}
@router.put("/roadmap/{item_id}")
async def update_roadmap_item(item_id: str, update: RoadmapUpdate):
"""Update a roadmap item."""
sets, vals = [], []
for field in ("version", "title", "description", "status", "badge", "target_date", "sort_order"):
val = getattr(update, field, None)
if val is not None:
sets.append(f"{field} = %s")
vals.append(val)
if not sets:
raise HTTPException(status_code=400, detail="Nothing to update")
sets.append("updated_at = %s")
vals.append(_now())
vals.append(item_id)
await _execute(f"UPDATE roadmap_items SET {', '.join(sets)} WHERE id = %s", tuple(vals))
return {"status": "success"}
@router.delete("/roadmap/{item_id}")
async def delete_roadmap_item(item_id: str):
"""Delete a roadmap item."""
await _execute("DELETE FROM roadmap_items WHERE id = %s", (item_id,))
return {"status": "success"}
# ═══════════════════════════════════════════════════
# FLOW API
# ═══════════════════════════════════════════════════
class FlowCreate(BaseModel):
title: str
description: str = ""
category: str = "general"
parent_id: Optional[str] = None
sort_order: int = 0
icon: str = ""
class FlowUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
category: Optional[str] = None
parent_id: Optional[str] = None
sort_order: Optional[int] = None
icon: Optional[str] = None
status: Optional[str] = None
@router.get("/flow")
async def list_flow():
"""Get all flow items ordered by sort_order."""
rows = await _query("SELECT * FROM flow_items ORDER BY sort_order ASC")
items = [_serialize(r) for r in rows]
return {"status": "success", "items": items, "total": len(items)}
@router.post("/flow")
async def create_flow_item(item: FlowCreate):
"""Add a new flow item."""
fid = str(uuid.uuid4())[:8]
now = _now()
await _execute(
"""INSERT INTO flow_items (id, title, description, category, parent_id, sort_order, icon, created_at, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(fid, item.title, item.description, item.category, item.parent_id, item.sort_order, item.icon, now, now),
)
return {"status": "success", "id": fid}
@router.put("/flow/{item_id}")
async def update_flow_item(item_id: str, update: FlowUpdate):
"""Update a flow item."""
sets, vals = [], []
for field in ("title", "description", "category", "parent_id", "sort_order", "icon", "status"):
val = getattr(update, field, None)
if val is not None:
sets.append(f"{field} = %s")
vals.append(val)
if not sets:
raise HTTPException(status_code=400, detail="Nothing to update")
sets.append("updated_at = %s")
vals.append(_now())
vals.append(item_id)
await _execute(f"UPDATE flow_items SET {', '.join(sets)} WHERE id = %s", tuple(vals))
return {"status": "success"}
@router.delete("/flow/{item_id}")
async def delete_flow_item(item_id: str):
"""Delete a flow item."""
await _execute("DELETE FROM flow_items WHERE id = %s", (item_id,))
return {"status": "success"}
......@@ -24,7 +24,7 @@ async def get_current_user(request: Request) -> dict:
raise HTTPException(status_code=401, detail="Token expired or invalid")
user_id = int(claims["sub"])
conn = get_conn()
try:
cur = conn.cursor()
......
......@@ -16,8 +16,8 @@ class CanifaDbPool:
try:
self._pool = ConnectionPool(
CHECKPOINT_POSTGRES_URL,
min_size=5,
max_size=40,
min_size=2,
max_size=15,
max_idle=300,
timeout=10,
kwargs={"autocommit": True}
......
"""
Ultra Description DB — Save/load ultra descriptions to/from PostgreSQL.
Schema: dashboard_canifa.ultra_descriptions
Pattern: Same as sql_agent/persistence.py (psycopg sync + pool).
"""
import json
import logging
from typing import Any
from common.pool_wrapper import get_pooled_connection_compat
logger = logging.getLogger(__name__)
SCHEMA = "dashboard_canifa"
TABLE = f"{SCHEMA}.ultra_descriptions"
__all__ = ["UltraDescriptionDB"]
class UltraDescriptionDB:
"""Manages ultra_descriptions table in dashboard_canifa schema."""
_initialized = False
@classmethod
def ensure_table(cls) -> None:
"""Create ultra_descriptions table if it doesn't exist."""
if cls._initialized:
return
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(f"""
CREATE TABLE IF NOT EXISTS {TABLE} (
id SERIAL PRIMARY KEY,
internal_ref_code VARCHAR(50) NOT NULL UNIQUE,
product_name VARCHAR(500),
product_image_url TEXT,
product_line VARCHAR(200),
description_data JSONB NOT NULL,
phase VARCHAR(20) DEFAULT 'enriched',
status SMALLINT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ultra_desc_ref_code
ON {TABLE}(internal_ref_code);
-- Migration: add columns if table already existed
ALTER TABLE {TABLE} ADD COLUMN IF NOT EXISTS status SMALLINT DEFAULT 0;
ALTER TABLE {TABLE} ADD COLUMN IF NOT EXISTS clean_description TEXT DEFAULT '';
""")
cur.close()
cls._initialized = True
logger.info("✅ Table %s ready", TABLE)
except Exception as e:
logger.error("Error creating ultra_descriptions table: %s", e)
finally:
if conn:
conn.close()
@classmethod
def save(
cls,
internal_ref_code: str,
product_name: str | None,
product_image_url: str | None,
product_line: str | None,
description_data: dict,
phase: str = "enriched",
clean_description: str = "",
) -> int | None:
"""Save or update an ultra description. Returns row id."""
cls.ensure_table()
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(
f"""
INSERT INTO {TABLE}
(internal_ref_code, product_name, product_image_url, product_line, description_data, phase, status, clean_description, updated_at)
VALUES (%s, %s, %s, %s, %s::jsonb, %s, 0, %s, NOW())
ON CONFLICT (internal_ref_code) DO UPDATE SET
product_name = EXCLUDED.product_name,
product_image_url = EXCLUDED.product_image_url,
product_line = EXCLUDED.product_line,
description_data = EXCLUDED.description_data,
phase = EXCLUDED.phase,
status = 0,
clean_description = EXCLUDED.clean_description,
updated_at = NOW()
RETURNING id
""",
(
internal_ref_code,
product_name,
product_image_url,
product_line,
json.dumps(description_data, ensure_ascii=False),
phase,
clean_description,
),
)
row = cur.fetchone()
cur.close()
row_id = row[0] if row else None
logger.info("💾 Saved ultra desc: %s (id=%s)", internal_ref_code, row_id)
return row_id
except Exception as e:
logger.error("Error saving ultra desc %s: %s", internal_ref_code, e)
return None
finally:
if conn:
conn.close()
@classmethod
def get_by_code(cls, internal_ref_code: str) -> dict | None:
"""Get a single ultra description by code."""
cls.ensure_table()
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(
f"SELECT * FROM {TABLE} WHERE internal_ref_code = %s",
(internal_ref_code,),
)
row = cur.fetchone()
if not row:
cur.close()
return None
cols = [desc[0] for desc in cur.description]
cur.close()
return dict(zip(cols, row))
except Exception as e:
logger.error("Error getting ultra desc %s: %s", internal_ref_code, e)
return None
finally:
if conn:
conn.close()
@classmethod
def update_data(cls, internal_ref_code: str, description_data: dict, clean_description: str) -> bool:
"""Update description_data and clean_description from manual edits."""
cls.ensure_table()
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(
f"""
UPDATE {TABLE}
SET description_data = %s::jsonb, clean_description = %s, updated_at = NOW()
WHERE internal_ref_code = %s
""",
(json.dumps(description_data, ensure_ascii=False), clean_description, internal_ref_code),
)
updated = cur.rowcount > 0
cur.close()
if updated:
logger.info("✅ Manually updated description for: %s", internal_ref_code)
return updated
except Exception as e:
logger.error("Error updating desc data for %s: %s", internal_ref_code, e)
return False
finally:
if conn:
conn.close()
@classmethod
def update_clean_text(cls, internal_ref_code: str, clean_description: str) -> bool:
"""Update ONLY the clean_description (manual text/HTML edits)."""
cls.ensure_table()
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(
f"UPDATE {TABLE} SET clean_description = %s, updated_at = NOW() WHERE internal_ref_code = %s",
(clean_description, internal_ref_code),
)
updated = cur.rowcount > 0
cur.close()
if updated:
logger.info("✅ Manually updated clean_description text for: %s", internal_ref_code)
return updated
except Exception as e:
logger.error("Error updating clean text for %s: %s", internal_ref_code, e)
return False
finally:
if conn:
conn.close()
@classmethod
def get_all_codes_with_status(cls) -> dict[str, int]:
"""Get all codes with their status: {code: status}."""
cls.ensure_table()
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(f"SELECT internal_ref_code, COALESCE(status, 0) FROM {TABLE}")
result = {r[0]: r[1] for r in cur.fetchall()}
cur.close()
return result
except Exception as e:
logger.error("Error getting all ultra desc codes: %s", e)
return {}
finally:
if conn:
conn.close()
@classmethod
def get_stats(cls) -> dict:
"""Get count of stored descriptions."""
cls.ensure_table()
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(f"""
SELECT
COUNT(*) AS total,
COUNT(CASE WHEN status = 1 THEN 1 END) AS approved,
COUNT(CASE WHEN status = 0 THEN 1 END) AS pending,
COUNT(CASE WHEN phase = 'enriched' THEN 1 END) AS enriched,
COUNT(CASE WHEN phase = 'raw' THEN 1 END) AS raw_only,
MIN(created_at) AS first_created,
MAX(updated_at) AS last_updated
FROM {TABLE}
""")
row = cur.fetchone()
cur.close()
if not row:
return {}
return {
"total": row[0],
"approved": row[1],
"pending": row[2],
"enriched": row[3],
"raw_only": row[4],
"first_created": row[5],
"last_updated": row[6],
}
except Exception as e:
logger.error("Error getting ultra desc stats: %s", e)
return {}
finally:
if conn:
conn.close()
@classmethod
def delete_by_code(cls, internal_ref_code: str) -> bool:
"""Delete a description by code."""
cls.ensure_table()
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(
f"DELETE FROM {TABLE} WHERE internal_ref_code = %s",
(internal_ref_code,),
)
deleted = cur.rowcount > 0
cur.close()
return deleted
except Exception as e:
logger.error("Error deleting ultra desc %s: %s", internal_ref_code, e)
return False
finally:
if conn:
conn.close()
@classmethod
def set_status(cls, internal_ref_code: str, status: int, clean_description: str = "") -> bool:
"""Set status: 0 = pending, 1 = approved. Optionally save clean_description."""
cls.ensure_table()
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
if clean_description:
cur.execute(
f"UPDATE {TABLE} SET status = %s, clean_description = %s, updated_at = NOW() WHERE internal_ref_code = %s",
(status, clean_description, internal_ref_code),
)
else:
cur.execute(
f"UPDATE {TABLE} SET status = %s, updated_at = NOW() WHERE internal_ref_code = %s",
(status, internal_ref_code),
)
updated = cur.rowcount > 0
cur.close()
if updated:
logger.info("✅ Status updated: %s → %s", internal_ref_code, status)
return updated
except Exception as e:
logger.error("Error updating status %s: %s", internal_ref_code, e)
return False
finally:
if conn:
conn.close()
@classmethod
def get_approved_codes(cls) -> set[str]:
"""Get codes with status = 1 (approved)."""
cls.ensure_table()
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(f"SELECT internal_ref_code FROM {TABLE} WHERE status = 1")
codes = {r[0] for r in cur.fetchall()}
cur.close()
return codes
except Exception as e:
logger.error("Error getting approved codes: %s", e)
return set()
finally:
if conn:
conn.close()
@classmethod
def approve_all_pending(cls, render_fn=None) -> int:
"""Bulk approve all pending (status=0) → approved (status=1).
If render_fn provided, also generates clean_description for each row."""
cls.ensure_table()
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
if render_fn:
# Fetch pending rows, render clean_description, update individually
cur.execute(f"SELECT internal_ref_code, product_name, description_data FROM {TABLE} WHERE status = 0")
rows = cur.fetchall()
count = 0
for code, name, desc_data in rows:
if isinstance(desc_data, str):
import json as _json
try:
desc_data = _json.loads(desc_data)
except Exception:
desc_data = {}
clean = render_fn(desc_data, name or "")
cur.execute(
f"UPDATE {TABLE} SET status = 1, clean_description = %s, updated_at = NOW() WHERE internal_ref_code = %s",
(clean, code),
)
count += 1
else:
cur.execute(f"UPDATE {TABLE} SET status = 1, updated_at = NOW() WHERE status = 0")
count = cur.rowcount
cur.close()
logger.info("✅ Bulk approved %d descriptions", count)
return count
except Exception as e:
logger.error("Error bulk approving: %s", e)
return 0
finally:
if conn:
conn.close()
# Auto-init table on import
try:
UltraDescriptionDB.ensure_table()
except Exception:
pass
# ═══════════════════════════════════════════════════════════════
# DescFieldConfig — Dynamic field management for AI descriptions
# ═══════════════════════════════════════════════════════════════
FIELD_TABLE = f"{SCHEMA}.desc_field_config"
# Default 28 fields (seeded on first run)
_DEFAULT_FIELDS = [
("ten_san_pham", "Tên sản phẩm", "Dùng tên thật từ database", 1),
("mo_ta_chinh", "Mô tả chính", "3 câu: form dáng + thiết kế + tác dụng lên cơ thể", 2),
("tagline", "Tagline", "1 câu slogan ngắn, gợi cảm xúc", 3),
("hook_quang_cao", "Hook quảng cáo", "1 câu marketing hook thu hút click", 4),
("chat_lieu", "Chất liệu", "NẾU có mô tả gốc Magento → trích xuất. Nếu không → ghi Không xác định", 5),
("tinh_nang_vai", "Tính năng vải", "Co giãn, kháng khuẩn, thấm hút... NẾU có Magento → trích xuất", 6),
("huong_dan_bao_quan", "Hướng dẫn bảo quản", "NẾU có mô tả gốc → trích xuất. Nếu không → chuẩn theo loại vải", 7),
("phong_cach", "Phong cách", "Casual, minimalist, streetwear...", 8),
("gioi_tinh", "Giới tính", "Nam / Nữ / Unisex", 9),
("do_tuoi", "Độ tuổi", "Phạm vi tuổi phù hợp", 10),
("mua", "Mùa", "Mùa phù hợp để mặc", 11),
("dip_mac", "Dịp mặc", "4+ dịp cụ thể, ngăn dấu ·", 12),
("phoi_do", "Phối đồ", "3 combo cụ thể với format: item1 + item2 → mô tả", 13),
("nguyen_tac_phoi_do", "Nguyên tắc phối đồ", "Giải thích RÕ lý do phối đồ, tỷ lệ cơ thể, sự phù hợp", 14),
("tranh_phoi_cung", "Tránh phối cùng", "Negative constraints cảnh báo chatbot", 15),
("layer", "Layer", "Cách layer khi thời tiết thay đổi", 16),
("cross_sell", "Cross-sell", "2-3 dòng SP Canifa khác nên mua kèm", 17),
("loi_song", "Lối sống", "Mô tả lifestyle khách hàng mục tiêu", 18),
("tinh_cach", "Tính cách", "Tính cách phù hợp với SP", 19),
("ly_do_mua", "Lý do mua", "Lý do thuyết phục khách mua", 20),
("luu_y_size", "Lưu ý size", "Hướng dẫn chọn size phù hợp", 21),
("tags", "Tags", "Từ khóa SEO ngắn, phân tách bằng dấu phẩy", 22),
("faq_1_q", "FAQ 1 - Câu hỏi", "Câu hỏi về form dáng/fit", 23),
("faq_1_a", "FAQ 1 - Trả lời", "Trả lời chi tiết", 24),
("faq_2_q", "FAQ 2 - Câu hỏi", "Câu hỏi về size", 25),
("faq_2_a", "FAQ 2 - Trả lời", "Trả lời chi tiết", 26),
("faq_3_q", "FAQ 3 - Câu hỏi", "Câu hỏi về mix match", 27),
("faq_3_a", "FAQ 3 - Trả lời", "Trả lời chi tiết", 28),
]
class DescFieldConfig:
"""Manages desc_field_config table — dynamic field schema for AI descriptions."""
_initialized = False
@classmethod
def ensure_table(cls) -> None:
if cls._initialized:
return
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(f"""
CREATE TABLE IF NOT EXISTS {FIELD_TABLE} (
field_key VARCHAR(100) PRIMARY KEY,
field_label VARCHAR(200) NOT NULL,
field_instruction TEXT NOT NULL DEFAULT '',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
""")
# Seed defaults if table is empty
cur.execute(f"SELECT COUNT(*) FROM {FIELD_TABLE}")
count = cur.fetchone()[0]
if count == 0:
for key, label, instruction, sort in _DEFAULT_FIELDS:
cur.execute(
f"""INSERT INTO {FIELD_TABLE} (field_key, field_label, field_instruction, is_active, sort_order)
VALUES (%s, %s, %s, TRUE, %s) ON CONFLICT DO NOTHING""",
(key, label, instruction, sort),
)
logger.info("🌱 Seeded %d default fields into %s", len(_DEFAULT_FIELDS), FIELD_TABLE)
cur.close()
cls._initialized = True
logger.info("✅ Table %s ready", FIELD_TABLE)
except Exception as e:
logger.error("Error creating desc_field_config table: %s", e)
finally:
if conn:
conn.close()
@classmethod
def get_active_fields(cls) -> list[dict]:
"""Get active fields ordered by sort_order (used by prompt builder)."""
cls.ensure_table()
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(f"""
SELECT field_key, field_label, field_instruction, sort_order
FROM {FIELD_TABLE}
WHERE is_active = TRUE
ORDER BY sort_order
""")
rows = cur.fetchall()
cur.close()
return [
{"field_key": r[0], "field_label": r[1], "field_instruction": r[2], "sort_order": r[3]}
for r in rows
]
except Exception as e:
logger.error("Error getting active fields: %s", e)
return []
finally:
if conn:
conn.close()
@classmethod
def get_all_fields(cls) -> list[dict]:
"""Get ALL fields (active + inactive) for admin UI."""
cls.ensure_table()
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(f"""
SELECT field_key, field_label, field_instruction, is_active, sort_order
FROM {FIELD_TABLE}
ORDER BY sort_order
""")
rows = cur.fetchall()
cur.close()
return [
{"field_key": r[0], "field_label": r[1], "field_instruction": r[2],
"is_active": r[3], "sort_order": r[4]}
for r in rows
]
except Exception as e:
logger.error("Error getting all fields: %s", e)
return []
finally:
if conn:
conn.close()
@classmethod
def add_field(cls, field_key: str, field_label: str, field_instruction: str = "", sort_order: int = 99) -> bool:
cls.ensure_table()
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(
f"""INSERT INTO {FIELD_TABLE} (field_key, field_label, field_instruction, is_active, sort_order)
VALUES (%s, %s, %s, TRUE, %s)""",
(field_key, field_label, field_instruction, sort_order),
)
cur.close()
logger.info("➕ Added field: %s", field_key)
return True
except Exception as e:
logger.error("Error adding field %s: %s", field_key, e)
return False
finally:
if conn:
conn.close()
@classmethod
def update_field(cls, field_key: str, field_label: str | None = None,
field_instruction: str | None = None, is_active: bool | None = None,
sort_order: int | None = None) -> bool:
cls.ensure_table()
sets = []
params = []
if field_label is not None:
sets.append("field_label = %s")
params.append(field_label)
if field_instruction is not None:
sets.append("field_instruction = %s")
params.append(field_instruction)
if is_active is not None:
sets.append("is_active = %s")
params.append(is_active)
if sort_order is not None:
sets.append("sort_order = %s")
params.append(sort_order)
if not sets:
return False
sets.append("updated_at = NOW()")
params.append(field_key)
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(
f"UPDATE {FIELD_TABLE} SET {', '.join(sets)} WHERE field_key = %s",
params,
)
updated = cur.rowcount > 0
cur.close()
if updated:
logger.info("✏️ Updated field: %s", field_key)
return updated
except Exception as e:
logger.error("Error updating field %s: %s", field_key, e)
return False
finally:
if conn:
conn.close()
@classmethod
def delete_field(cls, field_key: str) -> bool:
cls.ensure_table()
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(f"DELETE FROM {FIELD_TABLE} WHERE field_key = %s", (field_key,))
deleted = cur.rowcount > 0
cur.close()
if deleted:
logger.info("🗑️ Deleted field: %s", field_key)
return deleted
except Exception as e:
logger.error("Error deleting field %s: %s", field_key, e)
return False
finally:
if conn:
conn.close()
# Auto-init field config table
try:
DescFieldConfig.ensure_table()
except Exception:
pass
[
{
"id": "exp_chatbot_prod",
"name": "Chatbot (Production)",
"url": "http://172.16.2.207:5005/static/index.html",
"icon": "💬",
"badge": null,
"badge_type": null,
"section": "main",
"pinned": true,
"description": "Chatbot AI Stylist Canifa — phiên bản production đang chạy trên server 172.16.2.207. Đây là bản ổn định dùng cho team test và demo.",
"versions": [
{ "version": "v5.10", "date": "2026-03-11", "author": "Dev", "note": "Phiên bản ổn định, đã deploy production" }
]
},
{
"id": "exp_1",
"name": "Text-to-SQL",
"url": "/static/test_sql.html",
"icon": "🗄️",
"badge": "BETA",
"badge_type": "beta",
"pinned": true,
"description": "Cho phép hỏi database bằng tiếng Việt, tự convert thành SQL query và trả kết quả trực tiếp.",
"versions": [
{ "version": "v0.1", "date": "2026-03-08", "author": "Dev", "note": "Bản đầu tiên — hỗ trợ SELECT cơ bản" }
]
},
{
"id": "exp_2",
"name": "DB Test",
"url": "/static/test_db.html",
"icon": "🔍",
"badge": "BETA",
"badge_type": "beta",
"pinned": true,
"description": "Test trực tiếp các truy vấn database, xem dữ liệu sản phẩm và stock.",
"versions": [
{ "version": "v0.1", "date": "2026-03-08", "author": "Dev", "note": "Bản đầu tiên — query trực tiếp" }
]
},
{
"id": "exp_3",
"name": "Feedback Demo",
"url": "/static/feedback_demo.html",
"icon": "📝",
"badge": "NEW",
"badge_type": "new",
"pinned": true,
"description": "Demo hệ thống feedback Like/Dislike/Complaint cho chatbot, phân loại tự động bằng AI.",
"versions": [
{ "version": "v0.1", "date": "2026-03-10", "author": "Dev", "note": "Bản đầu tiên — Like/Dislike + Complaint form" }
]
},
{
"id": "exp_chatbot_dev",
"name": "Chatbot (Dev)",
"url": "/static/index.html",
"icon": "💬",
"badge": "DEV",
"badge_type": "dev",
"pinned": true,
"description": "Phiên bản dev của Chatbot, chạy trên localhost. Dùng để test tính năng mới trước khi deploy lên production.",
"versions": [
{ "version": "v5.10", "date": "2026-03-11", "author": "Dev", "note": "Version hiện tại — dev localhost" }
]
}
{
"id": "exp_chatbot_prod",
"name": "Chatbot (Production)",
"url": "http://172.16.2.207:5000/static/index.html",
"icon": "💬",
"badge": null,
"badge_type": null,
"section": "main",
"pinned": true,
"description": "Chatbot AI Stylist Canifa — phiên bản production đang chạy trên server 172.16.2.207. Đây là bản ổn định dùng cho team test và demo.",
"versions": [
{
"version": "v5.10",
"date": "2026-03-11",
"author": "Dev",
"note": "Phiên bản ổn định, đã deploy production"
}
]
},
{
"id": "exp_1",
"name": "Text-to-SQL",
"url": "/static/test_sql.html",
"icon": "🗄️",
"badge": "BETA",
"badge_type": "beta",
"pinned": true,
"description": "Cho phép hỏi database bằng tiếng Việt, tự convert thành SQL query và trả kết quả trực tiếp.",
"versions": [
{
"version": "v0.1",
"date": "2026-03-08",
"author": "Dev",
"note": "Bản đầu tiên — hỗ trợ SELECT cơ bản"
}
]
},
{
"id": "exp_2",
"name": "DB Test",
"url": "/static/test_db.html",
"icon": "🔍",
"badge": "BETA",
"badge_type": "beta",
"pinned": true,
"description": "Test trực tiếp các truy vấn database, xem dữ liệu sản phẩm và stock.",
"versions": [
{
"version": "v0.1",
"date": "2026-03-08",
"author": "Dev",
"note": "Bản đầu tiên — query trực tiếp"
}
]
},
{
"id": "exp_3",
"name": "Feedback Demo",
"url": "/static/feedback_demo.html",
"icon": "📝",
"badge": "NEW",
"badge_type": "new",
"pinned": true,
"description": "Demo hệ thống feedback Like/Dislike/Complaint cho chatbot, phân loại tự động bằng AI.",
"versions": [
{
"version": "v0.1",
"date": "2026-03-10",
"author": "Dev",
"note": "Bản đầu tiên — Like/Dislike + Complaint form"
}
]
},
{
"id": "exp_chatbot_dev",
"name": "Chatbot (Dev)",
"url": "/static/index.html",
"icon": "💬",
"badge": "DEV",
"badge_type": "dev",
"pinned": true,
"description": "Phiên bản dev của Chatbot, chạy trên localhost. Dùng để test tính năng mới trước khi deploy lên production.",
"versions": [
{
"version": "v5.10",
"date": "2026-03-11",
"author": "Dev",
"note": "Version hiện tại — dev localhost"
}
]
}
]
\ No newline at end of file
name: canifa-feedback
services:
# --- Backend Service ---
backend:
build: .
build:
context: .
dockerfile: Dockerfile.dev
container_name: canifa_backend
env_file: .env
ports:
- "5005:5000"
- "5006:5000"
volumes:
- .:/app
environment:
- PORT=5000
- PORT=5006
restart: unless-stopped
deploy:
resources:
limits:
memory: 8g
networks:
- backend_network
- canifa_feedback_network
logging:
driver: "json-file"
options:
tag: "{{.Name}}"
networks:
backend_network:
canifa_feedback_network:
driver: bridge
ipam:
driver: default
......
"""Seed flow_items with proper UTF-8 via HTTP API."""
import urllib.request, json
BASE = "http://localhost:5000/api/dashboard/flow"
# Delete all existing
resp = urllib.request.urlopen(BASE)
data = json.loads(resp.read())
for item in data.get("items", []):
req = urllib.request.Request(f"{BASE}/{item['id']}", method="DELETE")
urllib.request.urlopen(req)
print("Cleared all existing flow items")
items = [
{"id":"f01","title":"Kh\u00e1ch g\u1eedi tin nh\u1eafn","description":"Kh\u00e1ch h\u00e0ng nh\u1eadp c\u00e2u h\u1ecfi qua chatbot widget tr\u00ean web/app","category":"input","parent_id":None,"sort_order":1,"icon":"\ud83d\udcac"},
{"id":"f02","title":"API Gateway","description":"FastAPI nh\u1eadn request, x\u00e1c th\u1ef1c, rate limit","category":"process","parent_id":None,"sort_order":2,"icon":"\ud83d\udd0c"},
{"id":"f03","title":"Ki\u1ec3m tra cache","description":"Tra b\u1ed9 nh\u1edb t\u1ea1m \u2014 c\u00e2u h\u1ecfi n\u00e0y \u0111\u00e3 \u0111\u01b0\u1ee3c h\u1ecfi ch\u01b0a?","category":"process","parent_id":None,"sort_order":3,"icon":"\u26a1"},
{"id":"f04","title":"L\u1ea5y l\u1ecbch s\u1eed + h\u1ed3 s\u01a1 kh\u00e1ch","description":"10 tin nh\u1eafn g\u1ea7n nh\u1ea5t + h\u1ed3 s\u01a1 d\u00e0i h\u1ea1n (size, s\u1edf th\u00edch)","category":"process","parent_id":None,"sort_order":4,"icon":"\ud83d\udcbe"},
{"id":"f05","title":"AI Agent suy ngh\u0129","description":"Gemini ph\u00e2n t\u00edch \u00fd \u0111\u1ecbnh, quy\u1ebft \u0111\u1ecbnh c\u1ea7n tra c\u1ee9u g\u00ec","category":"process","parent_id":None,"sort_order":5,"icon":"\ud83e\udde0"},
{"id":"f06","title":"Product Search (Vector)","description":"T\u00ecm s\u1ea3n ph\u1ea9m b\u1eb1ng semantic search + SQL filters","category":"tool","parent_id":"f05","sort_order":6,"icon":"\ud83d\udd0d"},
{"id":"f07","title":"Stock Check","description":"Ki\u1ec3m tra t\u1ed3n kho realtime qua API Magento","category":"tool","parent_id":"f05","sort_order":7,"icon":"\ud83d\udce6"},
{"id":"f08","title":"Knowledge Base","description":"Tra ch\u00ednh s\u00e1ch \u0111\u1ed5i tr\u1ea3, khuy\u1ebfn m\u00e3i, th\u00f4ng tin c\u1eeda h\u00e0ng","category":"tool","parent_id":"f05","sort_order":8,"icon":"\ud83d\udcda"},
{"id":"f09","title":"Streaming Response","description":"Tr\u1ea3 l\u1eddi t\u1eebng ch\u1eef nh\u01b0 ChatGPT, k\u00e8m product cards","category":"output","parent_id":None,"sort_order":9,"icon":"\ud83d\udce1"},
{"id":"f10","title":"Background Tasks","description":"L\u01b0u l\u1ecbch s\u1eed, c\u1eadp nh\u1eadt h\u1ed3 s\u01a1 kh\u00e1ch, cache, log Langfuse","category":"output","parent_id":None,"sort_order":10,"icon":"\u2699\ufe0f"},
{"id":"f11","title":"Langfuse Trace","description":"Log observability: latency, tokens, cost, scores","category":"monitor","parent_id":None,"sort_order":11,"icon":"\ud83d\udcca"},
]
for item in items:
body = json.dumps(item).encode("utf-8")
req = urllib.request.Request(BASE, data=body, headers={"Content-Type":"application/json"}, method="POST")
urllib.request.urlopen(req)
print(f"Seeded {len(items)} flow items OK")
"""Seed roadmap notes via the notes API."""
import urllib.request, json, time
BASE = "http://localhost:5000/api/dashboard/notes"
notes = [
{"title": "v1.0 \u2014 Production", "content": """Model: GPT-4.1-mini
Search: Vector search (pgvector, IVFFlat)
Stock: API Magento realtime
Prompt: Module v1 (persona, guardrails, sales flow)
Observability: Langfuse tracing + scoring
Chat: L\u01b0u l\u1ecbch s\u1eed + Like/Dislike feedback""", "category": "note", "pinned": True},
{"title": "v1.1 \u2014 \u0110\u1ed5i model (th\u1eed nghi\u1ec7m)", "content": """Chuy\u1ec3n GPT-4.1-mini \u2192 Gemini 3.1 Flash-Lite
Gi\u1ea3m ~55% chi ph\u00ed per request
R\u00fat g\u1ecdn prompt 56%, format ph\u00f9 h\u1ee3p Gemini
Benchmark: 20 test cases so s\u00e1nh Score/Latency/Cost
C\u01a1 ch\u1ebf rollback v\u1ec1 GPT n\u1ebfu Gemini kh\u00f4ng \u1ed5n""", "category": "note", "pinned": True},
{"title": "v1.2 \u2014 \u0110\u1ed5i logic l\u1ea5y d\u1eef li\u1ec7u (th\u1eed nghi\u1ec7m)", "content": """Hybrid Search: vector + keyword thay v\u00ec vector-only
HNSW Index thay IVFFlat \u2192 recall t\u0103ng ~15%
Filters n\u00e2ng cao: l\u1ecdc gi\u00e1, m\u00e0u, size, danh m\u1ee5c tr\u01b0\u1edbc khi search
Caching layer (Redis): cache k\u1ebft qu\u1ea3 search ph\u1ed5 bi\u1ebfn, gi\u1ea3m latency""", "category": "note", "pinned": True},
{"title": "v2.0 \u2014 N\u00e2ng c\u1ea5p l\u1edbn (k\u1ebf ho\u1ea1ch)", "content": """Prompt Engineering v2: rewrite to\u00e0n b\u1ed9, multi-turn context, persona n\u00e2ng cao
Giao di\u1ec7n chatbot m\u1edbi: dark mode, product cards, quick replies
Multi-Agent: Sales Agent + Support Agent + Analytics Agent ph\u1ed1i h\u1ee3p
A/B Testing Engine: so s\u00e1nh prompt/model variants tr\u00ean live traffic
Customer Analytics Dashboard: ph\u00e2n t\u00edch h\u00e0nh vi kh\u00e1ch h\u00e0ng t\u1eeb chat data""", "category": "note", "pinned": True},
]
for note in notes:
body = json.dumps(note).encode("utf-8")
req = urllib.request.Request(BASE, data=body, headers={"Content-Type":"application/json"}, method="POST")
urllib.request.urlopen(req)
time.sleep(0.3)
print(f"Seeded {len(notes)} roadmap notes OK")
......@@ -31,7 +31,10 @@ from api.prompt_optimizer_route import router as prompt_optimizer_router
from api.user_simulator_route import router as user_simulator_router
from api.regression_test_route import router as regression_test_router
from api.stress_test_route import router as stress_test_router
from api.roadmap_flow_route import router as roadmap_flow_router
from api.experiment_log_route import router as experiment_log_router
from api.auth_route import router as auth_router
from api.product_desc_route import router as product_desc_router
from common.cache import redis_cache
from common.middleware import middleware_manager
from config import PORT, REDIS_CACHE_TURN_ON
......@@ -79,7 +82,7 @@ async def startup_event():
@app.get("/")
async def root():
return RedirectResponse(url="/static/main.html")
return RedirectResponse(url="/static/main.html?page=roadmap.html")
@app.get("/health")
async def health():
......@@ -89,8 +92,8 @@ async def health():
@app.get("/static/{file_path:path}")
async def serve_static(file_path: str):
"""Serve static files explicitly to avoid middleware conflict."""
if not file_path:
file_path = "index.html"
if not file_path or file_path == "index.html":
return RedirectResponse(url="/static/main.html")
full_path = os.path.join(STATIC_DIR, file_path)
if os.path.isfile(full_path):
return FileResponse(full_path)
......@@ -133,7 +136,12 @@ app.include_router(prompt_optimizer_router) # Prompt Optimizer
app.include_router(user_simulator_router) # User Simulator
app.include_router(regression_test_router) # Regression Test
app.include_router(stress_test_router) # Stress Test
app.include_router(roadmap_flow_router) # Roadmap & Flow
app.include_router(experiment_log_router) # Experiment Log
app.include_router(auth_router) # Auth (login/me/logout)
app.include_router(product_desc_router) # Ultra Description Manager
from api.limit_route import router as limit_router
app.include_router(limit_router)
if __name__ == "__main__":
......
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Experiment Log — Canifa AI</title>
<link rel="stylesheet" href="/static/css/theme.css">
<link rel="stylesheet" href="/static/css/components.css">
<script src="/static/js/frame-detect.js"></script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{min-height:100vh;background:var(--bg);font-family:var(--font-sans);color:var(--foreground)}
/* 2-column Memos layout */
.layout{display:flex;max-width:1080px;margin:0 auto;min-height:100vh}
.sidebar-col{width:240px;flex-shrink:0;padding:20px 16px;border-right:1px solid var(--border);position:sticky;top:0;height:100vh;overflow-y:auto}
.main-col{flex:1;padding:20px 24px 60px;min-width:0}
/* Sidebar sections */
.sb-title{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-fg);margin-bottom:8px}
.sb-search{width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:6px;font:inherit;font-size:12px;background:var(--card);color:var(--foreground);outline:none;margin-bottom:16px}
.sb-search:focus{border-color:var(--foreground)}
/* Heatmap */
.heatmap{margin-bottom:16px}
.hm-month{font-size:11px;color:var(--muted-fg);margin-bottom:6px;display:flex;justify-content:space-between}
.hm-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:2px}
.hm-cell{aspect-ratio:1;border-radius:2px;background:var(--muted,#f0efec)}
.hm-cell.l1{background:#c6e48b}.hm-cell.l2{background:#7bc96f}.hm-cell.l3{background:#239a3b}.hm-cell.l4{background:#196127}
/* Tags */
.tag-list{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:16px}
.tag-chip{font-size:11px;padding:3px 8px;border-radius:999px;border:1px solid var(--border);background:var(--card);color:var(--muted-fg);cursor:pointer;transition:all .15s;white-space:nowrap}
.tag-chip:hover{border-color:var(--foreground);color:var(--foreground)}
.tag-chip.active{background:var(--foreground);color:var(--bg);border-color:var(--foreground)}
.tag-count{opacity:.6;margin-left:2px}
/* Status filters */
.status-filters{display:flex;flex-direction:column;gap:2px;margin-bottom:16px}
.status-row{display:flex;align-items:center;gap:6px;padding:5px 8px;border-radius:6px;cursor:pointer;font-size:12px;color:var(--muted-fg);transition:all .12s}
.status-row:hover{background:var(--muted,#f0efec);color:var(--foreground)}
.status-row.active{color:var(--foreground);font-weight:600;background:var(--muted,#f0efec)}
.status-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
.dot-live{background:#16a34a}.dot-testing{background:#d97706}.dot-draft{background:var(--border)}.dot-archived{background:var(--muted-fg)}
/* Page header */
.page-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}
.page-head h1{font-size:18px;font-weight:700}
.page-head p{font-size:12px;color:var(--muted-fg);margin-top:2px}
.head-actions{display:flex;gap:8px}
.btn-sm{padding:6px 12px;border:1px solid var(--border);border-radius:6px;background:var(--card);font:inherit;font-size:11px;font-weight:600;cursor:pointer;color:var(--muted-fg);transition:all .15s}
.btn-sm:hover{border-color:var(--foreground);color:var(--foreground)}
.btn-sm.active{background:var(--foreground);color:var(--bg);border-color:var(--foreground)}
/* Experiment cards — Memos memo-card style */
.card-list{display:flex;flex-direction:column;gap:8px}
.exp-card{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:14px 16px;transition:box-shadow .15s;position:relative}
.exp-card:hover{box-shadow:0 2px 8px rgba(0,0,0,.04)}
.exp-card:hover .card-actions{opacity:1}
.exp-card.pinned{border-left:3px solid var(--foreground)}
.card-top{display:flex;align-items:center;gap:8px;margin-bottom:6px}
.card-ver{font-size:12px;font-weight:700;color:var(--foreground)}
.card-date{font-size:11px;color:var(--muted-fg);margin-left:auto}
.card-status{font-size:10px;font-weight:700;padding:2px 7px;border-radius:999px;text-transform:uppercase;letter-spacing:.03em}
.s-live{background:#dcfce7;color:#16a34a}.s-testing{background:#fef3c7;color:#d97706}.s-draft{background:var(--muted,#f0efec);color:var(--muted-fg)}.s-archived{background:var(--muted,#f0efec);color:var(--muted-fg)}
.card-title{font-size:14px;font-weight:600;margin-bottom:4px}
.card-desc{font-size:12px;color:var(--muted-fg);line-height:1.5;margin-bottom:8px}
.card-model{font-size:11px;color:var(--muted-fg);font-family:monospace;margin-bottom:6px}
/* Tags on card */
.card-tags{display:flex;gap:4px;margin-bottom:8px;flex-wrap:wrap}
.card-tag{font-size:10px;padding:2px 6px;border-radius:999px;background:var(--muted,#f0efec);color:var(--muted-fg)}
/* Metrics bar */
.metrics{display:flex;gap:16px;flex-wrap:wrap}
.metric{display:flex;flex-direction:column;align-items:flex-start}
.metric-label{font-size:10px;color:var(--muted-fg);text-transform:uppercase;letter-spacing:.05em}
.metric-val{font-size:14px;font-weight:700;font-variant-numeric:tabular-nums}
.mv-good{color:#16a34a}.mv-warn{color:#d97706}.mv-bad{color:#dc2626}
/* Card hover actions */
.card-actions{position:absolute;top:10px;right:10px;display:flex;gap:2px;opacity:0;transition:opacity .15s}
.card-actions button{width:24px;height:24px;border:none;background:transparent;cursor:pointer;border-radius:4px;font-size:12px;color:var(--muted-fg);display:flex;align-items:center;justify-content:center}
.card-actions button:hover{background:var(--muted,#f0efec);color:var(--foreground)}
.card-actions .del:hover{background:#fef2f2;color:#dc2626}
/* Compare checkbox */
.cmp-check{width:14px;height:14px;accent-color:var(--foreground);cursor:pointer;flex-shrink:0}
/* Compare panel */
.compare-bar{position:fixed;bottom:0;left:0;right:0;background:var(--card);border-top:1px solid var(--border);padding:10px 24px;display:flex;align-items:center;justify-content:center;gap:12px;z-index:100;box-shadow:0 -2px 12px rgba(0,0,0,.05);transform:translateY(100%);transition:transform .2s}
.compare-bar.visible{transform:translateY(0)}
/* Compare view */
.compare-view{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:12px}
.cmp-col{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:14px}
.cmp-col h3{font-size:14px;font-weight:700;margin-bottom:8px}
.cmp-row{display:flex;justify-content:space-between;padding:4px 0;font-size:12px;border-bottom:1px solid var(--border)}
.cmp-row:last-child{border-bottom:none}
.cmp-label{color:var(--muted-fg)}
.cmp-val{font-weight:600;font-variant-numeric:tabular-nums}
.cmp-better{color:#16a34a}.cmp-worse{color:#dc2626}
.empty{text-align:center;padding:40px;color:var(--muted-fg);font-size:13px}
@media(max-width:768px){.sidebar-col{display:none}.main-col{padding:16px}}
</style>
</head>
<body>
<div class="layout">
<!-- Sidebar -->
<div class="sidebar-col">
<input type="text" class="sb-search" placeholder="Tìm kiếm..." id="searchInput" oninput="doSearch()">
<div class="sb-title">Activity</div>
<div class="heatmap" id="heatmap"></div>
<div class="sb-title">Tags</div>
<div class="tag-list" id="tagList"></div>
<div class="sb-title">Status</div>
<div class="status-filters" id="statusFilters">
<div class="status-row active" data-status="" onclick="filterStatus(this,'')">
<div class="status-dot" style="background:var(--foreground)"></div> Tất cả
</div>
<div class="status-row" data-status="live" onclick="filterStatus(this,'live')">
<div class="status-dot dot-live"></div> Live
</div>
<div class="status-row" data-status="testing" onclick="filterStatus(this,'testing')">
<div class="status-dot dot-testing"></div> Testing
</div>
<div class="status-row" data-status="draft" onclick="filterStatus(this,'draft')">
<div class="status-dot dot-draft"></div> Draft
</div>
<div class="status-row" data-status="archived" onclick="filterStatus(this,'archived')">
<div class="status-dot dot-archived"></div> Archived
</div>
</div>
</div>
<!-- Main -->
<div class="main-col">
<div class="page-head">
<div>
<h1>Experiment Log</h1>
<p>Nhật ký thử nghiệm các phiên bản AI</p>
</div>
<div class="head-actions">
<button class="btn-sm" id="compareToggle" onclick="toggleCompareMode()">So sánh</button>
<button class="btn-sm" onclick="addExperiment()">+ Thêm</button>
</div>
</div>
<div id="compareView"></div>
<div class="card-list" id="cardList"><div class="empty">Đang tải...</div></div>
</div>
</div>
<!-- Compare bar -->
<div class="compare-bar" id="compareBar">
<span style="font-size:12px;color:var(--muted-fg)" id="cmpText">Chọn 2 experiment để so sánh</span>
<button class="btn-sm" onclick="runCompare()" id="cmpBtn" disabled>So sánh</button>
<button class="btn-sm" onclick="toggleCompareMode()">Hủy</button>
</div>
<script>
let allData = [], currentTag = '', currentStatus = '', searchTerm = '', compareMode = false, compareIds = new Set();
async function load() {
let url = '/api/dashboard/experiments?';
if (currentTag) url += `tag=${currentTag}&`;
if (currentStatus) url += `status=${currentStatus}&`;
const res = await fetch(url);
const data = await res.json();
allData = data.experiments || [];
renderCards(allData);
renderTags(data.tags || {});
renderHeatmap(data.activity || {});
}
function renderCards(items) {
let filtered = items;
if (searchTerm) {
const s = searchTerm.toLowerCase();
filtered = items.filter(e => e.title.toLowerCase().includes(s) || e.version.toLowerCase().includes(s) || (e.content||'').toLowerCase().includes(s));
}
if (!filtered.length) { document.getElementById('cardList').innerHTML = '<div class="empty">Không có experiment nào</div>'; return; }
document.getElementById('cardList').innerHTML = filtered.map(e => {
const d = new Date(e.created_at);
const date = `${d.getDate()}/${d.getMonth()+1}/${d.getFullYear()}`;
return `
<div class="exp-card ${e.pinned ? 'pinned' : ''}">
<div class="card-actions">
<button onclick="pinExp('${e.id}')" title="Pin">📌</button>
<button onclick="editExp('${e.id}')" title="Edit">✎</button>
<button class="del" onclick="deleteExp('${e.id}','${esc(e.title)}')" title="Xóa">✕</button>
</div>
<div class="card-top">
${compareMode ? `<input type="checkbox" class="cmp-check" onchange="toggleCompare('${e.id}', this)" ${compareIds.has(e.id)?'checked':''}>`:''}
<span class="card-ver">${e.version}</span>
<span class="card-status s-${e.status}">${e.status}</span>
<span class="card-date">${date}</span>
</div>
<div class="card-title">${e.title}</div>
${e.content ? `<div class="card-desc">${e.content}</div>` : ''}
${e.model_name ? `<div class="card-model">${e.model_name}</div>` : ''}
${(e.tags||[]).length ? `<div class="card-tags">${e.tags.map(t=>`<span class="card-tag">#${t}</span>`).join('')}</div>` : ''}
<div class="metrics">
${e.score != null ? `<div class="metric"><span class="metric-label">Score</span><span class="metric-val ${e.score>=8?'mv-good':e.score>=7?'':'mv-bad'}">${e.score}</span></div>` : ''}
${e.latency_avg != null ? `<div class="metric"><span class="metric-label">Latency</span><span class="metric-val ${e.latency_avg<=1800?'mv-good':e.latency_avg<=2200?'':'mv-bad'}">${e.latency_avg}ms</span></div>` : ''}
${e.cost_per_req != null ? `<div class="metric"><span class="metric-label">Cost/req</span><span class="metric-val ${e.cost_per_req<=0.003?'mv-good':e.cost_per_req<=0.008?'':'mv-bad'}">$${e.cost_per_req}</span></div>` : ''}
${e.error_rate != null ? `<div class="metric"><span class="metric-label">Error</span><span class="metric-val ${e.error_rate<=1?'mv-good':e.error_rate<=2?'mv-warn':'mv-bad'}">${e.error_rate}%</span></div>` : ''}
</div>
</div>`;
}).join('');
}
function renderTags(tags) {
document.getElementById('tagList').innerHTML = Object.entries(tags).map(([t,c]) =>
`<span class="tag-chip ${currentTag===t?'active':''}" onclick="filterTag('${t}')">#${t}<span class="tag-count">${c}</span></span>`
).join('');
}
function renderHeatmap(activity) {
// Last 12 weeks
const today = new Date(); const cells = [];
for (let i = 83; i >= 0; i--) {
const d = new Date(today); d.setDate(d.getDate() - i);
const key = d.toISOString().split('T')[0];
const count = activity[key] || 0;
const level = count === 0 ? '' : count === 1 ? 'l1' : count === 2 ? 'l2' : count <= 4 ? 'l3' : 'l4';
cells.push(`<div class="hm-cell ${level}" title="${key}: ${count}"></div>`);
}
const m1 = new Date(today); m1.setDate(m1.getDate()-83);
document.getElementById('heatmap').innerHTML = `
<div class="hm-month"><span>${m1.toLocaleDateString('vi',{month:'short'})}</span><span>${today.toLocaleDateString('vi',{month:'short'})}</span></div>
<div class="hm-grid">${cells.join('')}</div>`;
}
function filterTag(tag) { currentTag = currentTag === tag ? '' : tag; load(); }
function filterStatus(el, status) {
currentStatus = status;
document.querySelectorAll('.status-row').forEach(r => r.classList.remove('active'));
el.classList.add('active');
load();
}
function doSearch() { searchTerm = document.getElementById('searchInput').value; renderCards(allData); }
function esc(s) { return (s||'').replace(/'/g,"\\'"); }
function toggleCompareMode() {
compareMode = !compareMode;
compareIds.clear();
document.getElementById('compareToggle').classList.toggle('active', compareMode);
document.getElementById('compareBar').classList.toggle('visible', compareMode);
document.getElementById('compareView').innerHTML = '';
renderCards(allData);
}
function toggleCompare(id, cb) {
if (cb.checked) { if (compareIds.size >= 2) { cb.checked = false; return; } compareIds.add(id); }
else compareIds.delete(id);
document.getElementById('cmpText').textContent = `Đã chọn ${compareIds.size}/2`;
document.getElementById('cmpBtn').disabled = compareIds.size !== 2;
}
async function runCompare() {
const [id1, id2] = [...compareIds];
const res = await fetch(`/api/dashboard/experiments/compare?id1=${id1}&id2=${id2}`);
const data = await res.json();
if (!data.experiments || data.experiments.length < 2) return;
const [a, b] = data.experiments;
const rows = [
['Score', a.score, b.score, true],
['Latency (ms)', a.latency_avg, b.latency_avg, false],
['Cost/req ($)', a.cost_per_req, b.cost_per_req, false],
['Error (%)', a.error_rate, b.error_rate, false],
];
document.getElementById('compareView').innerHTML = `
<div class="compare-view">
<div class="cmp-col">
<h3>${a.version}${a.title}</h3>
<div class="card-model">${a.model_name||'-'}</div>
${rows.map(([l,va])=>`<div class="cmp-row"><span class="cmp-label">${l}</span><span class="cmp-val">${va??'-'}</span></div>`).join('')}
</div>
<div class="cmp-col">
<h3>${b.version}${b.title}</h3>
<div class="card-model">${b.model_name||'-'}</div>
${rows.map(([l,,vb,higher])=>{
const va = rows.find(r=>r[0]===l)[1];
let cls = '';
if(va!=null&&vb!=null){cls=higher?(vb>va?'cmp-better':vb<va?'cmp-worse':''):(vb<va?'cmp-better':vb>va?'cmp-worse':'');}
return `<div class="cmp-row"><span class="cmp-label">${l}</span><span class="cmp-val ${cls}">${vb??'-'}</span></div>`;
}).join('')}
</div>
</div>`;
toggleCompareMode();
}
async function pinExp(id) { await fetch(`/api/dashboard/experiments/${id}/pin`,{method:'PATCH'}); load(); }
async function deleteExp(id,t) { if(!confirm(`Xóa "${t}"?`))return; await fetch(`/api/dashboard/experiments/${id}`,{method:'DELETE'}); load(); }
async function editExp(id) {
const e = allData.find(x=>x.id===id); if(!e)return;
const title = prompt('Tên:', e.title); if(title===null)return;
const content = prompt('Mô tả:', e.content||''); if(content===null)return;
const score = prompt('Score (0-10):', e.score||'');
const body = {title, content};
if(score) body.score = parseFloat(score);
await fetch(`/api/dashboard/experiments/${id}`,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
load();
}
async function addExperiment() {
const version = prompt('Version (vd: v2.3):'); if(!version)return;
const title = prompt('Tên thử nghiệm:'); if(!title)return;
const content = prompt('Mô tả chi tiết:') || '';
const model_name = prompt('Model (vd: gemini-3.1-flash-lite):') || '';
const tagsStr = prompt('Tags (cách nhau bằng dấu phẩy, vd: model,prompt):') || '';
const tags = tagsStr ? tagsStr.split(',').map(t=>t.trim()) : [];
const score = prompt('Score (0-10, để trống nếu chưa có):');
const body = {version, title, content, model_name, tags, status:'draft'};
if(score) body.score = parseFloat(score);
await fetch('/api/dashboard/experiments',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
load();
}
load();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sơ đồ hoạt động - Canifa AI</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="/static/css/theme.css">
<link rel="stylesheet" href="/static/css/components.css">
<link rel="stylesheet" href="/static/css/lab.css"> <script src="/static/js/frame-detect.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/theme.css">
<link rel="stylesheet" href="/static/css/components.css">
<link rel="stylesheet" href="/static/css/lab.css">
<script src="/static/js/frame-detect.js"></script>
<link rel="stylesheet" href="/static/css/flow.css">
</head>
<body>
<button class="sidebar-toggle" id="sidebarToggle" title="Đóng/Mở sidebar">
<span class="toggle-icon"></span>
</button>
<aside class="sidebar" id="sidebar">
<div class="sidebar-brand">
<div class="brand-icon" onclick="document.getElementById('sidebarToggle').click()">CA</div>
<div class="brand-text">
<h2>Canifa AI</h2><span>Admin Console</span>
</div>
</div>
<div class="nav-group">
<div class="nav-group-label">Main</div>
<a href="/static/flow.html" class="nav-item active"><span class="nav-icon"></span><span>Sơ đồ hoạt
động</span></a>
<a href="/static/experiment_detail.html?id=exp_chatbot_prod" class="nav-item"><span
class="nav-icon"></span><span>Chatbot</span><span class="nav-badge badge-live">LIVE</span></a>
<a href="/static/history.html" class="nav-item"><span class="nav-icon"></span><span>History</span></a>
</div>
<div class="nav-group">
<div class="nav-group-label">Workspace</div>
<a href="/static/resources.html" class="nav-item"><span class="nav-icon"></span><span>Resources</span></a>
<a href="/static/notes.html" class="nav-item"><span class="nav-icon"></span><span>Team Notes</span></a>
<a href="/static/changelog.html" class="nav-item"><span class="nav-icon"></span><span>Changelog</span></a>
<a href="/static/guide.html" class="nav-item"><span class="nav-icon"></span><span>Hướng dẫn</span></a>
</div>
<div class="nav-group">
<div class="nav-group-label">Thử nghiệm</div>
<a href="#" class="nav-item"><span class="nav-icon"></span><span>Loading...</span></a>
</div>
<div class="nav-group">
<div class="nav-group-label">External</div>
<a href="/docs" target="_blank" class="nav-item"><span class="nav-icon"></span><span>API Docs</span></a>
<a href="/redoc" target="_blank" class="nav-item"><span class="nav-icon"></span><span>ReDoc</span></a>
</div>
<div class="sidebar-footer">
<div class="version-info"><span class="version-dot"></span><span
class="version-text"><strong>v2.5.0</strong> · Online</span></div>
</div>
</aside>
<div class="main" id="mainContent">
<div class="topbar">
<h1>Sơ đồ hoạt động</h1>
......@@ -62,7 +19,7 @@
</div>
<div class="panels">
<!-- ═══ LEFT: FLOW DIAGRAM (sticky) ═══ -->
<!-- ═══ LEFT: FLOW DIAGRAM ═══ -->
<div class="flow-panel" id="flowPanel">
<div class="zoom-controls">
<button class="zoom-btn" id="zoomOut" title="Thu nhỏ"></button>
......@@ -72,21 +29,12 @@
</div>
<div class="flow-content" id="flowContent">
<div class="flow-node node-start" data-step="1">Khách gửi tin nhắn</div>
<div class="flow-arrow">
<div class="arrow-line"></div>
<div class="arrow-head"></div>
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-process" data-step="2">Tiếp nhận<div class="node-sub">Máy chủ</div>
</div>
<div class="flow-arrow">
<div class="arrow-line"></div>
<div class="arrow-head"></div>
</div>
<div class="flow-node node-process" data-step="2">Tiếp nhận<div class="node-sub">Máy chủ</div></div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-decision" data-step="3">Đã hỏi trước?<div class="node-sub">Bộ nhớ tạm
</div>
</div>
<div class="flow-node node-decision" data-step="3">Đã hỏi trước?<div class="node-sub">Bộ nhớ tạm</div></div>
<div class="flow-arrow">
<div class="arrow-line" style="height:10px"></div>
<div class="arrow-label">Chưa → tiếp</div>
......@@ -94,22 +42,13 @@
<div class="arrow-head"></div>
</div>
<div class="flow-node node-process" data-step="4">Nhớ lại khách<div class="node-sub">Lịch sử + Hồ
</div>
</div>
<div class="flow-arrow">
<div class="arrow-line"></div>
<div class="arrow-head"></div>
</div>
<div class="flow-node node-process" data-step="4">Nhớ lại khách<div class="node-sub">Lịch sử + Hồ sơ</div></div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="loop-indicator" data-step="5">
<div class="loop-label">VÒNG LẶP AI</div>
<div class="flow-node node-process"
style="border-color:#667eea;border-width:2px;min-width:160px">AI suy nghĩ</div>
<div class="flow-arrow">
<div class="arrow-line" style="height:12px"></div>
<div class="arrow-head"></div>
</div>
<div class="flow-node node-process" style="border-color:#667eea;border-width:2px;min-width:160px">AI suy nghĩ</div>
<div class="flow-arrow"><div class="arrow-line" style="height:12px"></div><div class="arrow-head"></div></div>
<div class="flow-node node-decision" style="font-size:0.75em;min-width:140px">Cần tra?</div>
<div class="flow-arrow">
<div class="arrow-line" style="height:8px"></div>
......@@ -118,101 +57,63 @@
<div class="arrow-head"></div>
</div>
<div class="flow-branch">
<div class="branch-item">
<div class="branch-connector"></div>
<div class="flow-node node-tool"
style="font-size:0.72em;padding:8px 12px;min-width:auto">Tra</div>
</div>
<div class="branch-item">
<div class="branch-connector"></div>
<div class="flow-node node-tool"
style="font-size:0.72em;padding:8px 12px;min-width:auto">Ghi</div>
</div>
</div>
<div class="flow-arrow">
<div class="arrow-line" style="height:8px"></div>
<div class="arrow-label">↩ Lặp</div>
<div class="branch-item"><div class="branch-connector"></div><div class="flow-node node-tool" style="font-size:0.72em;padding:8px 12px;min-width:auto">Tra</div></div>
<div class="branch-item"><div class="branch-connector"></div><div class="flow-node node-tool" style="font-size:0.72em;padding:8px 12px;min-width:auto">Ghi</div></div>
</div>
<div class="flow-arrow"><div class="arrow-line" style="height:8px"></div><div class="arrow-label">↩ Lặp</div></div>
</div>
<div class="flow-arrow">
<div class="arrow-line"></div>
<div class="arrow-head"></div>
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-process" data-step="6">Hiển thị dần<div class="node-sub">Streaming
</div>
</div>
<div class="flow-arrow">
<div class="arrow-line"></div>
<div class="arrow-head"></div>
</div>
<div class="flow-node node-process" data-step="6">Hiển thị dần<div class="node-sub">Streaming</div></div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-process" data-step="7">Ghi nhớ ngầm<div class="node-sub">4 tác vụ nền
</div>
</div>
<div class="flow-arrow">
<div class="arrow-line"></div>
<div class="arrow-head"></div>
</div>
<div class="flow-node node-process" data-step="7">Ghi nhớ ngầm<div class="node-sub">4 tác vụ nền</div></div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-end" data-step="8">Hoàn thành</div>
</div><!-- /flow-content -->
</div>
</div>
<!-- Resizer -->
<div class="panel-resizer" id="panelResizer"></div>
<!-- ═══ RIGHT: EXPLANATIONS (temporarily hidden) ═══ -->
<div class="explain-panel" style="display:none">
<!-- ═══ RIGHT: EXPLANATIONS ═══ -->
<div class="explain-panel">
<!-- Step 1 -->
<div class="explain-card" id="step-1">
<h4>📥 Đầu vào — Nhận tin nhắn từ khách</h4>
<p>Khách gửi tin nhắn qua cửa sổ chat trên web hoặc app. Hệ thống nhận được cả <strong>chữ</strong>
lẫn <strong>ảnh</strong> — ví dụ khách chụp ảnh một chiếc áo và hỏi <em>"có sản phẩm nào giống
thế này không?"</em>, AI sẽ nhận diện được.</p>
<p>Khách gửi tin nhắn qua cửa sổ chat trên web hoặc app. Hệ thống nhận được cả <strong>chữ</strong> lẫn <strong>ảnh</strong> — ví dụ khách chụp ảnh một chiếc áo và hỏi <em>"có sản phẩm nào giống thế này không?"</em>, AI sẽ nhận diện được.</p>
<p>Hệ thống nhận diện khách bằng <strong>2 cách song song</strong>:</p>
<p><strong>Khách đã đăng nhập</strong> → biết chính xác là ai (theo mã khách hàng)<br>
<strong>Khách vãng lai</strong> → nhận diện qua thiết bị đang dùng</p>
<strong>Khách vãng lai</strong> → nhận diện qua thiết bị đang dùng</p>
<div class="why-box">
<div class="why-label">💡 Tại sao nhận diện 2 cách?</div>
Nếu chỉ dùng tài khoản → khách chưa đăng nhập không chat được. Nếu chỉ dùng thiết bị → đổi điện
thoại là mất hết lịch sử. Làm 2 cách thì <strong>khách vãng lai vẫn chat bình thường</strong>,
còn khách đăng nhập thì lịch sử đi theo tài khoản.
Nếu chỉ dùng tài khoản → khách chưa đăng nhập không chat được. Nếu chỉ dùng thiết bị → đổi điện thoại là mất hết lịch sử. Làm 2 cách thì <strong>khách vãng lai vẫn chat bình thường</strong>, còn khách đăng nhập thì lịch sử đi theo tài khoản.
</div>
</div>
<!-- Step 2 -->
<div class="explain-card" id="step-2">
<h4>🌐 Máy chủ tiếp nhận — Xử lý song song</h4>
<p>Tin nhắn của khách được gửi đến máy chủ. Máy chủ chạy theo mô hình <strong>không chờ
đợi</strong>: khi đang chờ AI suy nghĩ cho khách A (mất 2-20 giây), server vẫn tiếp tục nhận
tin của khách B, C, D... không bị tắc nghẽn.</p>
<p>Máy chủ chạy <strong>4 tiến trình song song</strong>, mỗi tiến trình phục vụ hàng trăm khách cùng
lúc.</p>
<p>Tin nhắn của khách được gửi đến máy chủ. Máy chủ chạy theo mô hình <strong>không chờ đợi</strong>: khi đang chờ AI suy nghĩ cho khách A (mất 2-20 giây), server vẫn tiếp tục nhận tin của khách B, C, D... không bị tắc nghẽn.</p>
<p>Máy chủ chạy <strong>4 tiến trình song song</strong>, mỗi tiến trình phục vụ hàng trăm khách cùng lúc.</p>
<div class="why-box">
<div class="why-label">💡 Tại sao quan trọng?</div>
Vì AI mất 2-25 giây để suy nghĩ. Nếu máy chủ chờ xong mới phục vụ khách tiếp → 4 tiến trình chỉ
serve 4 khách cùng lúc. Với mô hình song song → <strong>4 tiến trình serve hàng trăm
khách</strong> vì trong lúc chờ AI trả lời, máy chủ rảnh để nhận khách khác.
Vì AI mất 2-25 giây để suy nghĩ. Nếu máy chủ chờ xong mới phục vụ khách tiếp → 4 tiến trình chỉ serve 4 khách cùng lúc. Với mô hình song song → <strong>4 tiến trình serve hàng trăm khách</strong> vì trong lúc chờ AI trả lời, máy chủ rảnh để nhận khách khác.
</div>
</div>
<!-- Step 3 -->
<div class="explain-card" id="step-3">
<h4>⚡ Kiểm tra câu hỏi trùng <span class="step-badge">Tiết kiệm 30-40% chi phí</span></h4>
<p>Trước khi gọi AI (tốn tiền + mất 15-20 giây), hệ thống kiểm tra: <em>"Câu hỏi này đã có ai hỏi
trước đó chưa?"</em></p>
<p>Trước khi gọi AI (tốn tiền + mất 15-20 giây), hệ thống kiểm tra: <em>"Câu hỏi này đã có ai hỏi trước đó chưa?"</em></p>
<p><strong>Đã từng hỏi rồi</strong> → Trả câu trả lời cũ ngay lập tức, không cần gọi AI lại<br>
<strong>Chưa từng hỏi</strong> → Tiếp tục gọi AI xử lý bình thường</p>
<p>Câu trả lời cũ được <strong>lưu trong 1 giờ</strong>. Những câu hỏi mang tính cá nhân (VD: "đơn
hàng của tôi đâu?") sẽ <strong>không lưu</strong> vì mỗi khách khác nhau.</p>
<strong>Chưa từng hỏi</strong> → Tiếp tục gọi AI xử lý bình thường</p>
<p>Câu trả lời cũ được <strong>lưu trong 1 giờ</strong>. Những câu hỏi mang tính cá nhân (VD: "đơn hàng của tôi đâu?") sẽ <strong>không lưu</strong> vì mỗi khách khác nhau.</p>
<div class="why-box">
<div class="why-label">💡 Tại sao cần bước này?</div>
Nhiều khách hay hỏi giống nhau: "có khuyến mãi gì?", "áo polo nam?", "chính sách đổi trả?".
Thống kê cho thấy ~35% câu hỏi là trùng lặp. Mỗi lần gọi AI tốn tiền + mất 15-20 giây. Nhớ lại
câu trả lời cũ giúp <strong>tiết kiệm 30-40% chi phí</strong> và khách được trả lời nhanh gần
như ngay lập tức.
Nhiều khách hay hỏi giống nhau: "có khuyến mãi gì?", "áo polo nam?", "chính sách đổi trả?". Thống kê cho thấy ~35% câu hỏi là trùng lặp. Mỗi lần gọi AI tốn tiền + mất 15-20 giây. Nhớ lại câu trả lời cũ giúp <strong>tiết kiệm 30-40% chi phí</strong> và khách được trả lời nhanh gần như ngay lập tức.
</div>
</div>
......@@ -220,244 +121,81 @@
<div class="explain-card" id="step-4">
<h4>💾 Nhớ lại khách hàng là ai</h4>
<p>Trước khi AI trả lời, hệ thống tìm lại <strong>2 loại thông tin</strong> về khách:</p>
<p><strong>1. Trí nhớ ngắn hạn</strong> — 10 tin nhắn gần nhất trong cuộc hội thoại hiện tại. Giúp
AI nhớ đang nói chuyện về chủ đề gì (VD: khách đang hỏi về áo khoác → AI không nhảy sang đề xuất
áo polo).</p>
<p><strong>2. Trí nhớ dài hạn (Hồ sơ khách)</strong> — Bản tóm tắt ngắn gọn: giới tính, size hay
mặc, phong cách yêu thích, sản phẩm đã xem. Được AI <strong>tự động cập nhật</strong> sau mỗi
cuộc hội thoại.</p>
<p>Cả hai được đưa cho AI trước khi suy nghĩ, giúp AI tư vấn <strong>cá nhân hóa</strong> — giống
nhân viên bán hàng nhớ khách quen.</p>
<p><strong>1. Trí nhớ ngắn hạn</strong> — 10 tin nhắn gần nhất trong cuộc hội thoại hiện tại. Giúp AI nhớ đang nói chuyện về chủ đề gì.</p>
<p><strong>2. Trí nhớ dài hạn (Hồ sơ khách)</strong> — Bản tóm tắt ngắn gọn: giới tính, size hay mặc, phong cách yêu thích, sản phẩm đã xem. Được AI <strong>tự động cập nhật</strong> sau mỗi cuộc hội thoại.</p>
<div class="why-box">
<div class="why-label">💡 Tại sao tách ngắn hạn vs dài hạn?</div>
Ngắn hạn giúp AI nhớ cuộc chat <strong>hiện tại</strong>. Dài hạn giúp AI nhớ khách
<strong>xuyên suốt nhiều lần chat</strong> mà không cần lưu toàn bộ lịch sử (rất tốn kém). Nếu
chỉ có ngắn hạn → mỗi lần quay lại AI "quên hết". Nếu lưu hết lịch sử → quá dài, AI xử lý không
nổi.
Ngắn hạn giúp AI nhớ cuộc chat <strong>hiện tại</strong>. Dài hạn giúp AI nhớ khách <strong>xuyên suốt nhiều lần chat</strong> mà không cần lưu toàn bộ lịch sử (rất tốn kém).
</div>
</div>
<!-- Step 5 -->
<!-- Step 5 — CORE -->
<div class="explain-card core-highlight" id="step-5">
<h4>🤖 AI suy nghĩ — Vòng lặp Agent <span class="step-badge">⭐ Trái tim hệ thống</span></h4>
<p>Đây là phần quan trọng nhất. AI đóng vai trò <strong>một tư vấn viên thông minh</strong> — tự đọc câu hỏi, tự phân tích ý định, tự quyết định cần tra gì.</p>
<p>Đây là phần quan trọng nhất của toàn bộ hệ thống. AI đóng vai trò <strong>một tư vấn viên thông
minh</strong> — tự đọc câu hỏi, tự phân tích ý định, tự quyết định cần tra gì, và tra bao
nhiêu lần cho đến khi đủ thông tin trả lời.</p>
<p>🧠 <strong>Tại sao cần AI làm việc này?</strong> Tư vấn viên con người <strong>đắt, không
scale</strong> — và catalog Canifa có <strong>hàng nghìn sản phẩm</strong>, không ai nhớ hết.
Bot AI đứng trước, <strong>tự phân tích câu hỏi</strong> và tra cứu thay.</p>
<div class="product-preview">
<img src="/static/public/image.png" alt="Trang sản phẩm Canifa">
<div class="preview-caption">📸 <strong>Ví dụ 1 sản phẩm trên web Canifa.</strong> Nhìn kỹ: mô
tả sản phẩm chỉ ghi <strong>"Áo phông nữ"</strong> — không có thông tin nào về phong cách,
chất liệu, dáng, hay dịp mặc.</div>
</div>
<h4 style="margin-top: 24px; color: #f85149;">⚠️ Thực trạng: Data sản phẩm nghèo</h4>
<p>Mô tả sản phẩm trong DB Canifa chỉ ghi tên ngắn gọn (<em>"Áo phông nữ"</em>, <em>"Quần soóc
nam"</em>). <strong>Không có</strong> phong cách, chất liệu, dáng, dịp mặc. Bot bắt buộc
phải dùng các trường có sẵn (tên, màu, giá, giới tính) để sinh query.</p>
<div class="why-box"
style="border-left-color: #f85149; background: linear-gradient(135deg, rgba(248,81,73,0.06), rgba(255,107,53,0.04)); padding: 10px 14px;">
<div class="why-label" style="color: #f85149;">🚀 Cơ hội: Bổ sung metadata</div>
Nếu thêm: phong cách (casual, công sở), chất liệu (cotton, lụa), dáng (slim, oversize), dịp mặc
(hẹn hò, đi biển) → bot lọc <strong>chính xác</strong> thay vì đoán. <strong>Data càng giàu → Bot
càng thông minh.</strong>
</div>
<h4 style="margin-top: 24px; color: #f85149;">🔍 Hybrid Search — 2 cách tìm cùng lúc</h4>
<h4 style="margin-top:24px;color:#f85149;">🔍 Hybrid Search — 2 cách tìm cùng lúc</h4>
<p>Bot dùng <strong>2 kỹ thuật song song</strong>, mỗi cách bù yếu điểm của cách kia:</p>
<table style="width:100%; border-collapse:collapse; margin:12px 0; font-size:0.84em;">
<thead>
<tr style="border-bottom: 2px solid #30363d;">
<th style="padding:8px 12px; text-align:left; color:#e6edf3; width:30%;">Kỹ thuật</th>
<th style="padding:8px 12px; text-align:left; color:#e6edf3;">Cách hoạt động</th>
<th style="padding:8px 12px; text-align:left; color:#56d364; width:22%;">✅ Mạnh</th>
<th style="padding:8px 12px; text-align:left; color:#f85149; width:22%;">❌ Yếu</th>
</tr>
</thead>
<table style="width:100%;border-collapse:collapse;margin:12px 0;font-size:0.84em;">
<thead><tr style="border-bottom:2px solid #30363d;">
<th style="padding:8px 12px;text-align:left;width:30%;">Kỹ thuật</th>
<th style="padding:8px 12px;text-align:left;">Cách hoạt động</th>
<th style="padding:8px 12px;text-align:left;color:#56d364;width:22%;">✅ Mạnh</th>
<th style="padding:8px 12px;text-align:left;color:#f85149;width:22%;">❌ Yếu</th>
</tr></thead>
<tbody>
<tr style="border-bottom: 1px solid #1b2030;">
<td style="padding:8px 12px;"><strong style="color:#ff9800;">Semantic Search</strong></td>
<td style="padding:8px 12px; color:#8b949e;">AI viết mô tả → chuyển thành vector →
so sánh với tất cả SP → lấy <strong style="color:#c9d1d9;">top 200</strong> giống nhất
</td>
<td style="padding:8px 12px; color:#8b949e;">Hiểu ý nghĩa mơ hồ: "xinh xinh", "đi chơi"
</td>
<td style="padding:8px 12px; color:#8b949e;">Có thể sai màu, sai giá</td>
</tr>
<tr>
<td style="padding:8px 12px;"><strong style="color:#667eea;">SQL Filter</strong></td>
<td style="padding:8px 12px; color:#8b949e;">AI điền filter → <code>WHERE color='trắng'
AND gender='men' AND price≤500K</code></td>
<td style="padding:8px 12px; color:#8b949e;">Lọc chính xác 100% màu/giá/giới tính</td>
<td style="padding:8px 12px; color:#8b949e;">Không hiểu "thanh lịch", "tối giản"</td>
</tr>
<tr style="border-bottom:1px solid #1b2030;"><td style="padding:8px 12px;"><strong style="color:#ff9800;">Semantic Search</strong></td><td style="padding:8px 12px;">AI viết mô tả → chuyển thành vector → top 200 giống nhất</td><td style="padding:8px 12px;">Hiểu ý nghĩa mơ hồ: "xinh xinh", "đi chơi"</td><td style="padding:8px 12px;">Có thể sai màu, sai giá</td></tr>
<tr><td style="padding:8px 12px;"><strong style="color:#667eea;">SQL Filter</strong></td><td style="padding:8px 12px;">AI điền filter → <code>WHERE color='trắng' AND gender='men' AND price≤500K</code></td><td style="padding:8px 12px;">Lọc chính xác 100% màu/giá/giới tính</td><td style="padding:8px 12px;">Không hiểu "thanh lịch", "tối giản"</td></tr>
</tbody>
</table>
<p style="font-size:0.82em; color:#667eea;"><strong>→ Kết hợp:</strong> Semantic tìm top 200 giống ý
→ SQL lọc chính xác (đúng màu, giá, giới tính) → Kết quả vừa <strong>đúng ý</strong> vừa
<strong>chính xác</strong></p>
<p style="font-size:0.82em;color:#667eea;"><strong>→ Kết hợp:</strong> Semantic tìm top 200 giống ý → SQL lọc chính xác → Kết quả vừa <strong>đúng ý</strong> vừa <strong>chính xác</strong></p>
<h4 style="margin-top: 24px; color: #f85149;">📋 Ví dụ: AI điền query như nào?</h4>
<div class="query-example" style="padding:10px 14px;">
<div class="q-label" style="margin-bottom:4px;">Khách: "Có áo phông nam trắng regular không?"
</div>
<div class="q-output">
<span class="q-tag style">📝 description: "Áo phông regular fit"</span>
<span class="q-tag cat">product_name: Áo phông</span>
<span class="q-tag gender">gender: men</span>
<span class="q-tag color">color: trắng</span>
</div>
</div>
<div class="query-example" style="padding:10px 14px;">
<div class="q-label" style="margin-bottom:4px;">Khách: "Set đồ công sở nữ" → AI tách 2 query
song song</div>
<div class="q-output">
<span class="q-tag cat">Q1: Áo công sở</span>
<span class="q-tag gender">women</span>
<span class="q-tag style">Smart Casual</span>
</div>
<div class="q-output" style="margin-top:4px;">
<span class="q-tag cat">Q2: Quần công sở</span>
<span class="q-tag gender">women</span>
<span class="q-tag style">Smart Casual</span>
</div>
</div>
<div class="query-example" style="padding:10px 14px;">
<div class="q-label" style="margin-bottom:4px;">Khách: "Em muốn gì đó xinh xinh đi chơi cuối
tuần" (mơ hồ)</div>
<div class="q-output">
<span class="q-tag style">📝 description: "đi chơi thoải mái trẻ trung"</span>
<span class="q-tag occasion">style: Dynamic</span>
</div>
</div>
<p style="font-size:0.82em;">📌 Khách nói tự nhiên, viết tắt, dùng từ đồng nghĩa ("áo thun"→"áo
phông", "quần bò"→"quần jean") — AI vẫn hiểu và tự điền query chính xác.</p>
<h4 style="margin-top: 24px;">⚙️ Sau khi phân tích xong, AI làm gì tiếp?</h4>
<p>AI liên tục lặp qua 3 bước <strong>Suy nghĩ → Quyết định → Hành động</strong> cho đến khi đủ
thông tin:</p>
<p><strong>Bước 1: Đọc + Phân tích</strong> — AI đọc câu hỏi, kết hợp với trí nhớ ngắn hạn + dài
hạn, phân tích ra các tiêu chí tìm kiếm (như ví dụ trên).</p>
<p><strong>Bước 2: Quyết định</strong> — AI tự hỏi: <em>"Cần tra thêm thông tin gì không?"</em><br>
• Cần → Gọi công cụ tra cứu phù hợp<br>
• Đủ rồi → Viết câu trả lời cho khách</p>
<p><strong>Bước 3: Tra cứu</strong> — AI có 6 "công cụ" để tra:</p>
<h4 style="margin-top:24px;">⚙️ AI có 6 công cụ tra cứu</h4>
<div class="tool-grid-inline">
<div class="tool-chip"><span class="chip-icon">🔍</span>
<div><span class="chip-name">Tìm sản phẩm</span><span class="chip-type">Tên, loại, màu,
giá</span></div>
</div>
<div class="tool-chip"><span class="chip-icon">📚</span>
<div><span class="chip-name">Kiến thức Canifa</span><span class="chip-type">Chính sách, đổi
trả, bảo hành</span></div>
</div>
<div class="tool-chip"><span class="chip-icon">🎁</span>
<div><span class="chip-name">Khuyến mãi</span><span class="chip-type">Giảm giá, voucher hiện
</span></div>
</div>
<div class="tool-chip"><span class="chip-icon">📦</span>
<div><span class="chip-name">Tồn kho</span><span class="chip-type">Còn hàng không, size nào
</span></div>
</div>
<div class="tool-chip"><span class="chip-icon">🏪</span>
<div><span class="chip-name">Cửa hàng</span><span class="chip-type">Chi nhánh gần
nhất</span></div>
</div>
<div class="tool-chip"><span class="chip-icon">📋</span>
<div><span class="chip-name">Thu thập liên hệ</span><span class="chip-type">Ghi lại SĐT, tên
khách</span></div>
</div>
<div class="tool-chip"><span class="chip-icon">🔍</span><div><span class="chip-name">Tìm sản phẩm</span><span class="chip-type">Tên, loại, màu, giá</span></div></div>
<div class="tool-chip"><span class="chip-icon">📚</span><div><span class="chip-name">Kiến thức Canifa</span><span class="chip-type">Chính sách, đổi trả, bảo hành</span></div></div>
<div class="tool-chip"><span class="chip-icon">🎁</span><div><span class="chip-name">Khuyến mãi</span><span class="chip-type">Giảm giá, voucher hiện có</span></div></div>
<div class="tool-chip"><span class="chip-icon">📦</span><div><span class="chip-name">Tồn kho</span><span class="chip-type">Còn hàng không, size nào có</span></div></div>
<div class="tool-chip"><span class="chip-icon">🏪</span><div><span class="chip-name">Cửa hàng</span><span class="chip-type">Chi nhánh gần nhất</span></div></div>
<div class="tool-chip"><span class="chip-icon">📋</span><div><span class="chip-name">Thu thập liên hệ</span><span class="chip-type">Ghi lại SĐT, tên khách</span></div></div>
</div>
<p><strong>Lặp lại nếu cần</strong> — VD: Khách hỏi "áo polo xanh size L" → AI phân tích (loại=polo,
màu=xanh, size=L) → gọi tìm sản phẩm → check tồn kho → hết hàng size L → tự tìm sản phẩm thay
thế → check tồn kho lần 2. Trung bình 2-3 lần tra/câu hỏi, tối đa 5 vòng.</p>
<div class="why-box">
<div class="why-label">💡 Tại sao để AI tự quyết thay vì lập trình sẵn?</div>
Nếu lập trình sẵn kiểu A→B→C, phải dự đoán trước <strong>mọi kịch bản</strong> — rất khó và cứng
nhắc. Catalog Canifa có hàng nghìn sản phẩm, khách hỏi đủ kiểu: hỏi theo màu, theo phong cách,
theo dịp, theo giá, kết hợp nhiều tiêu chí... Không thể lập trình hết. Để AI <strong>tự phân
tích + tự quyết định</strong> tra gì → linh hoạt hơn nhiều, giống nhân viên tư vấn giỏi tự
suy nghĩ chứ không theo script.
Nếu lập trình sẵn kiểu A→B→C, phải dự đoán trước <strong>mọi kịch bản</strong> — rất khó và cứng nhắc. Để AI <strong>tự phân tích + tự quyết định</strong> tra gì → linh hoạt hơn nhiều, giống nhân viên tư vấn giỏi tự suy nghĩ chứ không theo script.
</div>
</div>
<!-- Step 6 -->
<div class="explain-card" id="step-6">
<h4>📡 Hiển thị câu trả lời dần dần (Streaming)</h4>
<p>Thay vì đợi AI viết xong toàn bộ rồi mới hiện, câu trả lời được <strong>hiện ra từng chữ</strong>
— giống cách ChatGPT hoạt động.</p>
<p>Khách thấy chữ bắt đầu xuất hiện sau <strong>2-3 giây</strong>. Ngoài text, hệ thống cũng gửi kèm
<strong>card sản phẩm</strong> (hình ảnh, giá, link mua, tồn kho) nếu AI gợi ý sản phẩm.</p>
<p>Thay vì đợi AI viết xong toàn bộ rồi mới hiện, câu trả lời được <strong>hiện ra từng chữ</strong> — giống cách ChatGPT hoạt động.</p>
<p>Khách thấy chữ bắt đầu xuất hiện sau <strong>2-3 giây</strong>. Ngoài text, hệ thống cũng gửi kèm <strong>card sản phẩm</strong> (hình ảnh, giá, link mua, tồn kho) nếu AI gợi ý sản phẩm.</p>
<div class="why-box">
<div class="why-label">💡 Tại sao không đợi xong mới hiện?</div>
Vì AI cần 15-25 giây để viết xong toàn bộ. Nếu bắt khách đợi 25 giây mới hiện → khách tưởng bị
lỗi. Hiện dần dần giúp khách bắt đầu đọc ngay sau 2-3 giây — <strong>cảm giác chờ đợi giảm
~85%</strong> dù tổng thời gian không đổi.
Vì AI cần 15-25 giây để viết xong toàn bộ. Hiện dần dần giúp khách bắt đầu đọc ngay sau 2-3 giây — <strong>cảm giác chờ đợi giảm ~85%</strong> dù tổng thời gian không đổi.
</div>
</div>
<!-- Step 7 -->
<div class="explain-card" id="step-7">
<h4>⚙️ Ghi nhớ và xử lý ngầm <span class="step-badge">Chạy sau khi khách đã nhận câu trả lời</span>
</h4>
<p>Sau khi khách đã nhận được câu trả lời, hệ thống <strong>âm thầm làm thêm 4 việc</strong> (song
song, không bắt khách đợi):</p>
<p>1. 💾 <strong>Lưu lịch sử</strong> — Ghi lại cuộc hội thoại để lần sau AI nhớ (trí nhớ ngắn hạn ở
Step 4)</p>
<p>2. 🧠 <strong>Cập nhật hồ sơ khách</strong> — AI dùng riêng 1 lần suy nghĩ để tóm tắt thông tin
mới về khách (size, sở thích...) → bổ sung vào trí nhớ dài hạn</p>
<p>3. ⚡ <strong>Lưu câu trả lời</strong> — Nhớ lại để nếu khách khác hỏi giống thì trả nhanh hơn
(chính là bộ nhớ tạm ở Step 3)</p>
<p>4. 📊 <strong>Ghi log theo dõi</strong> — Ghi lại thời gian trả lời, chi phí, công cụ đã dùng →
team dùng để review chất lượng</p>
<h4>⚙️ Ghi nhớ và xử lý ngầm <span class="step-badge">Chạy sau khi khách đã nhận câu trả lời</span></h4>
<p>Sau khi khách đã nhận được câu trả lời, hệ thống <strong>âm thầm làm thêm 4 việc</strong> (song song, không bắt khách đợi):</p>
<p>1. 💾 <strong>Lưu lịch sử</strong> — Ghi lại cuộc hội thoại để lần sau AI nhớ</p>
<p>2. 🧠 <strong>Cập nhật hồ sơ khách</strong> — AI tóm tắt thông tin mới (size, sở thích...) → bổ sung vào trí nhớ dài hạn</p>
<p>3. ⚡ <strong>Lưu câu trả lời</strong> — Nhớ lại để khách khác hỏi giống thì trả nhanh hơn</p>
<p>4. 📊 <strong>Ghi log theo dõi</strong> — Thời gian, chi phí, công cụ đã dùng → team review chất lượng</p>
<div class="why-box">
<div class="why-label">💡 Tại sao chạy ngầm?</div>
4 việc này mất thêm 3-8 giây. Nếu bắt khách đợi → trải nghiệm kém vì khách đã nhận câu trả lời
rồi mà vẫn phải chờ. Nên chạy ngầm: <strong>khách nhận xong → xong việc với khách → server tự
ghi nhớ phía sau</strong>.
4 việc này mất thêm 3-8 giây. Chạy ngầm: <strong>khách nhận xong → xong việc với khách → server tự ghi nhớ phía sau</strong>.
</div>
</div>
<!-- Step 8 -->
<div class="explain-card" id="step-8">
<h4>✅ Khách nhận câu trả lời</h4>
<p>Khách nhận câu trả lời dạng text có định dạng đẹp, kèm <strong>card sản phẩm</strong> nếu AI gợi
ý: ảnh, tên, giá, link mua, trạng thái còn hàng (thông tin tồn kho được lấy lúc AI tra cứu ở
Step 5).</p>
<p>Mỗi câu trả lời kèm nút <strong>👍 👎</strong> để khách đánh giá → team dùng để review và cải
thiện chất lượng.</p>
</div>
<!-- Tools summary -->
<h2 class="section-title">🛠️ 6 công cụ — Chia 2 nhóm theo cách lưu nhớ</h2>
<div class="explain-card">
<h4>Nhóm tra cứu vs Nhóm ghi nhận</h4>
<p><strong>Nhóm tra cứu (5 công cụ)</strong> — Tìm sản phẩm, khuyến mãi, tồn kho, cửa hàng, kiến
thức. Kết quả được <strong>nhớ lại trong 1 giờ</strong>: cùng câu "áo polo nam" hỏi lần 2 sẽ trả
kết quả cũ thay vì tra lại từ đầu.</p>
<p><strong>Nhóm ghi nhận (1 công cụ)</strong> — Ghi lại SĐT/tên khách khi khách muốn được tư vấn
viên liên hệ. Mỗi lần ghi là thông tin mới nên <strong>không nhớ lại</strong>.</p>
<div class="why-box">
<div class="why-label">💡 Tại sao có 2 lớp nhớ (Step 3 + ở đây)?</div>
Step 3 nhớ <strong>toàn bộ câu trả lời cuối cùng</strong> — chỉ dùng được khi câu hỏi gần giống
hệt. Ở đây nhớ <strong>kết quả từng công cụ riêng</strong> — dùng được khi AI gọi cùng công cụ
dù câu hỏi khác nhau. VD: "áo polo" và "áo polo size L" → câu trả lời khác nhau (không dùng được
Step 3), nhưng phần tìm sản phẩm giống nhau (dùng lại được). Hai lớp nhớ bổ sung nhau.
</div>
<p>Khách nhận câu trả lời dạng text có định dạng đẹp, kèm <strong>card sản phẩm</strong> nếu AI gợi ý: ảnh, tên, giá, link mua, trạng thái còn hàng.</p>
<p>Mỗi câu trả lời kèm nút <strong>👍 👎</strong> để khách đánh giá → team dùng để review và cải thiện chất lượng.</p>
</div>
</div>
......@@ -465,50 +203,30 @@
</div>
<script>
// ═══ SIDEBAR TOGGLE ═══
const sidebar = document.getElementById('sidebar');
const mainContent = document.getElementById('mainContent');
const toggleBtn = document.getElementById('sidebarToggle');
const SIDEBAR_KEY = 'canifa_sidebar_collapsed';
const FLOW_WIDTH_KEY = 'canifa_flow_width';
if (localStorage.getItem(SIDEBAR_KEY) === 'true') {
sidebar.classList.add('collapsed');
mainContent.classList.add('expanded');
toggleBtn.classList.add('collapsed');
}
// ═══ PANEL RESIZER ═══
const flowPanel = document.getElementById('flowPanel');
const resizer = document.getElementById('panelResizer');
const FLOW_WIDTH_KEY = 'canifa_flow_width';
let isResizing = false;
// Restore saved width
const savedWidth = localStorage.getItem(FLOW_WIDTH_KEY);
if (savedWidth) flowPanel.style.width = savedWidth + 'px';
resizer.addEventListener('mousedown', function (e) {
isResizing = true;
resizer.classList.add('dragging');
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
resizer.addEventListener('mousedown', function(e) {
isResizing = true; resizer.classList.add('dragging');
document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none';
e.preventDefault();
});
document.addEventListener('mousemove', function (e) {
document.addEventListener('mousemove', function(e) {
if (!isResizing) return;
const panelsRect = flowPanel.parentElement.getBoundingClientRect();
let newWidth = e.clientX - panelsRect.left;
let newWidth = e.clientX - flowPanel.parentElement.getBoundingClientRect().left;
newWidth = Math.max(180, Math.min(window.innerWidth * 0.8, newWidth));
flowPanel.style.width = newWidth + 'px';
});
document.addEventListener('mouseup', function () {
document.addEventListener('mouseup', function() {
if (!isResizing) return;
isResizing = false;
resizer.classList.remove('dragging');
document.body.style.cursor = '';
document.body.style.userSelect = '';
isResizing = false; resizer.classList.remove('dragging');
document.body.style.cursor = ''; document.body.style.userSelect = '';
localStorage.setItem(FLOW_WIDTH_KEY, parseInt(flowPanel.style.width));
});
......@@ -525,46 +243,19 @@
}
applyZoom();
document.getElementById('zoomIn').addEventListener('click', function () {
currentZoom = Math.min(2, currentZoom + 0.1);
applyZoom();
});
document.getElementById('zoomOut').addEventListener('click', function () {
currentZoom = Math.max(0.5, currentZoom - 0.1);
applyZoom();
});
document.getElementById('zoomReset').addEventListener('click', function () {
currentZoom = 1;
applyZoom();
});
document.getElementById('zoomIn').addEventListener('click', () => { currentZoom = Math.min(2, currentZoom + 0.1); applyZoom(); });
document.getElementById('zoomOut').addEventListener('click', () => { currentZoom = Math.max(0.5, currentZoom - 0.1); applyZoom(); });
document.getElementById('zoomReset').addEventListener('click', () => { currentZoom = 1; applyZoom(); });
// Ctrl + scroll wheel zoom
flowPanel.addEventListener('wheel', function (e) {
if (e.ctrlKey) {
e.preventDefault();
if (e.deltaY < 0) {
currentZoom = Math.min(2, currentZoom + 0.05);
} else {
currentZoom = Math.max(0.5, currentZoom - 0.05);
}
applyZoom();
}
flowPanel.addEventListener('wheel', function(e) {
if (e.ctrlKey) { e.preventDefault(); currentZoom = e.deltaY < 0 ? Math.min(2, currentZoom + 0.05) : Math.max(0.5, currentZoom - 0.05); applyZoom(); }
}, { passive: false });
toggleBtn.addEventListener('click', function () {
sidebar.classList.toggle('collapsed');
mainContent.classList.toggle('expanded');
toggleBtn.classList.toggle('collapsed');
localStorage.setItem(SIDEBAR_KEY, sidebar.classList.contains('collapsed'));
});
// ═══ CLICK FLOW NODE → SCROLL TO EXPLANATION ═══
// ═══ CLICK NODE → SCROLL TO EXPLANATION ═══
document.querySelectorAll('.flow-node[data-step]').forEach(node => {
node.addEventListener('click', function () {
const step = this.dataset.step;
const target = document.getElementById('step-' + step);
node.addEventListener('click', function() {
const target = document.getElementById('step-' + this.dataset.step);
if (target) {
// Highlight effect
document.querySelectorAll('.flow-node').forEach(n => n.classList.remove('active-step'));
document.querySelectorAll('.explain-card').forEach(c => c.classList.remove('highlight'));
this.classList.add('active-step');
......@@ -574,9 +265,8 @@
});
});
// Also handle loop-indicator click
document.querySelector('.loop-indicator').addEventListener('click', function (e) {
if (e.target.closest('.flow-node')) return; // let child nodes handle
document.querySelector('.loop-indicator').addEventListener('click', function(e) {
if (e.target.closest('.flow-node')) return;
const target = document.getElementById('step-5');
if (target) {
document.querySelectorAll('.explain-card').forEach(c => c.classList.remove('highlight'));
......@@ -585,7 +275,5 @@
}
});
</script>
<script src="/static/js/sidebar-experiments.js"></script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canifa Chatbot Test</title>
<link rel="stylesheet" href="/static/css/theme.css">
<link rel="stylesheet" href="/static/css/components.css">
<link rel="stylesheet" href="/static/css/lab.css">
<link rel="stylesheet" href="/static/css/warm-override.css">
<script src="/static/js/frame-detect.js"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
margin: 0;
padding: 0;
background-color: #F5F4F0;
color: #18181B;
}
/* Navigation Header */
.main-content {
max-width: 900px;
margin: 0 auto;
padding: 20px;
}
.container {
background: #FFFFFF;
padding: 20px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
height: 90vh;
border: 1px solid #E2E0D8;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #E2E0D8;
}
.header h2 {
margin: 0;
color: #18181B;
}
.config-area {
display: flex;
gap: 10px;
align-items: center;
}
input[type="text"] {
padding: 8px 12px;
border: 1px solid #E2E0D8;
border-radius: 6px;
background: #F5F4F0;
color: #18181B;
}
button {
padding: 8px 16px;
background: #007acc;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
transition: opacity 0.2s;
font-weight: 500;
}
button:hover {
opacity: 0.9;
}
button:disabled {
background: #D6D3D1;
cursor: not-allowed;
}
.chat-box {
flex: 1;
overflow-y: auto;
padding: 20px;
border: 1px solid #E2E0D8;
border-radius: 8px;
margin-bottom: 20px;
background: #FAFAF8;
display: flex;
flex-direction: column;
gap: 10px;
}
.message-container {
display: flex;
flex-direction: column;
max-width: 80%;
}
.message-container.user {
align-self: flex-end;
align-items: flex-end;
}
.message-container.bot {
align-self: flex-start;
align-items: flex-start;
}
.sender-name {
font-size: 0.8em;
margin-bottom: 4px;
color: #78716C;
margin-left: 4px;
margin-right: 4px;
}
.message {
padding: 12px 16px;
border-radius: 12px;
line-height: 1.5;
word-wrap: break-word;
position: relative;
}
.message.user {
background: #007acc;
color: white;
border-bottom-right-radius: 2px;
}
.message.bot {
background: #FFFFFF;
color: #334155;
border-bottom-left-radius: 2px;
border: 1px solid #E2E0D8;
box-shadow: 0 1px 2px rgba(0,0,0,.04);
}
.message.system {
background: #FEF2F2;
color: #DC2626;
align-self: center;
font-size: 0.9em;
max-width: 90%;
border: 1px solid #FECACA;
}
.message.rate-limit-error {
background: linear-gradient(135deg, #FEF2F2 0%, #FFF7ED 100%);
border: 1px solid #FECACA;
padding: 16px;
max-width: 350px;
}
.timestamp {
font-size: 0.7em;
opacity: 0.7;
margin-top: 6px;
display: block;
text-align: right;
}
.input-area {
display: flex;
gap: 10px;
}
.input-area input {
flex: 1;
padding: 12px;
border: 1px solid #E2E0D8;
border-radius: 8px;
font-size: 16px;
background: #F5F4F0;
color: #18181B;
}
.input-area input:focus {
outline: 2px solid #007acc;
border-color: transparent;
}
.load-more {
text-align: center;
margin-bottom: 10px;
}
.load-more button {
background: #F5F4F0;
color: #78716C;
font-size: 0.85em;
width: 100%;
border: 1px dashed #E2E0D8;
}
.load-more button:hover {
background: #E2E0D8;
color: #18181B;
}
.typing-indicator {
font-style: italic;
color: #888;
font-size: 0.9em;
margin-bottom: 10px;
display: none;
margin-left: 10px;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #F5F4F0;
}
::-webkit-scrollbar-thumb {
background: #D6D3D1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #A8A29E;
}
/* Product Cards Styling */
.product-cards-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #E2E0D8;
}
.product-card {
background: #FFFFFF;
border-radius: 8px;
overflow: hidden;
transition: all 0.3s;
border: 1px solid #E2E0D8;
display: flex;
flex-direction: column;
}
.product-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
border-color: #667eea;
}
.product-card img {
width: 100%;
height: 200px;
object-fit: cover;
background: #F5F4F0;
}
.product-card-body {
padding: 12px;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.product-sku {
font-size: 0.75em;
color: #667eea;
font-weight: bold;
margin-bottom: 5px;
}
.product-name {
font-size: 0.9em;
color: #18181B;
margin-bottom: 10px;
line-height: 1.3;
flex-grow: 1;
}
.product-price {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.price-original {
font-size: 0.85em;
color: #888;
text-decoration: line-through;
}
.price-sale {
font-size: 1.1em;
color: #ff6b6b;
font-weight: bold;
}
.price-regular {
font-size: 1.1em;
color: #4caf50;
font-weight: bold;
}
.product-link {
display: block;
text-align: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-decoration: none;
padding: 8px 12px;
border-radius: 6px;
font-size: 0.85em;
transition: all 0.3s;
}
.product-link:hover {
opacity: 0.9;
transform: scale(1.02);
}
/* Response Time */
.response-time {
font-size: 0.75em;
color: #888;
margin-top: 8px;
font-style: italic;
}
.user-insight {
margin-top: 12px;
padding: 10px 12px;
border-radius: 10px;
background: #263238;
border: 1px solid #37474f;
font-size: 0.85em;
color: #b2ebf2;
}
/* Per-Message Toggle Button */
.message-view-toggle {
display: flex;
gap: 5px;
background: #3d3d3d;
border-radius: 6px;
padding: 4px;
border: 1px solid #555;
margin-top: 10px;
width: fit-content;
}
.message-view-toggle button {
padding: 6px 12px;
font-size: 0.8em;
background: transparent;
color: #aaa;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
}
.message-view-toggle button.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.message-view-toggle button:hover:not(.active) {
background: #4d4d4d;
color: #fff;
}
/* Raw JSON View */
.raw-json-view {
background: #1e1e1e;
border: 1px solid #555;
border-radius: 8px;
padding: 12px;
margin-top: 10px;
overflow-x: auto;
}
.raw-json-view pre {
margin: 0;
font-family: 'Courier New', monospace;
font-size: 0.85em;
color: #d4d4d4;
white-space: pre-wrap;
word-wrap: break-word;
}
/* Filtered content view */
.filtered-content {
display: block;
}
.raw-content {
display: none;
}
/* --- Modern Layout & Animations --- */
.main-content {
max-width: 1400px;
/* Wider container */
margin: 0 auto;
padding: 20px;
height: calc(100vh - 80px);
/* Fill remaining height */
box-sizing: border-box;
}
.main-layout {
display: flex;
height: 100%;
gap: 0;
/* Gap handled by margin in panel for smooth transition */
position: relative;
}
/* Chat Container flex fix */
.container {
flex: 1;
display: flex;
flex-direction: column;
background: #2d2d2d;
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
border: 1px solid #444;
height: 100%;
padding: 0;
overflow: hidden;
transition: all 0.3s ease;
z-index: 10;
}
/* Internal padding for chat container */
.chat-internal-wrapper {
padding: 20px;
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
}
/* PROMPT PANEL - Slide In Style */
.prompt-panel {
width: 0;
opacity: 0;
background: #1e1e1e;
/* Darker contrast */
border-left: 1px solid #444;
border-radius: 16px;
display: flex;
flex-direction: column;
padding: 0;
/* Padding handled internally to avoid width jump */
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
overflow: hidden;
margin-left: 0;
box-shadow: -5px 0 20px rgba(0, 0, 0, 0.3);
white-space: nowrap;
/* Prevent content flicker during width change */
}
.prompt-panel.open {
width: 500px;
/* Wider editor */
opacity: 1;
margin-left: 20px;
padding: 20px;
}
.prompt-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
border-bottom: 1px solid #333;
padding-bottom: 15px;
}
.prompt-tabs {
display: flex;
gap: 8px;
background: #2b2b2b;
border: 1px solid #3a3a3a;
border-radius: 999px;
padding: 4px;
}
.prompt-tab-btn {
background: transparent;
border: none;
color: #aaa;
padding: 6px 12px;
border-radius: 999px;
font-size: 0.85em;
cursor: pointer;
transition: all 0.2s ease;
}
.prompt-tab-btn.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
.prompt-tab-btn:hover:not(.active) {
color: #fff;
background: #3a3a3a;
}
.prompt-header h3 {
font-size: 1.2em;
color: #4fc3f7;
/* Nice blue accent */
display: flex;
align-items: center;
gap: 10px;
}
.prompt-textarea {
flex: 1;
background: #111;
color: #dcdccc;
/* Soft code color */
border: 1px solid #333;
border-radius: 8px;
padding: 15px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px;
line-height: 1.6;
resize: none;
margin-bottom: 15px;
white-space: pre-wrap;
/* Wrap code */
overflow-y: auto;
box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.5);
}
.prompt-textarea:focus {
outline: none;
border-color: #667eea;
box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.5), 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.panel-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 10px;
border-top: 1px solid #333;
}
.prompt-section {
display: none;
flex: 1;
flex-direction: column;
gap: 12px;
}
.prompt-section.active {
display: flex;
}
.tool-prompt-toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.tool-prompt-select {
min-width: 220px;
padding: 8px 12px;
border: 1px solid #444;
border-radius: 8px;
background: #2d2d2d;
color: #e0e0e0;
}
.status-text {
font-size: 0.8em;
color: #666;
font-style: italic;
}
/* Buttons Update */
.action-btn {
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
font-size: 0.9em;
border: none;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
}
.btn-reload {
background: #333;
color: #aaa;
}
.btn-reload:hover {
background: #444;
color: white;
}
.btn-save {
background: linear-gradient(135deg, #43a047 0%, #2e7d32 100%);
color: white;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
}
.btn-save:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
}
.btn-close-panel {
background: transparent;
border: none;
color: #666;
font-size: 1.5rem;
cursor: pointer;
transition: color 0.2s;
line-height: 1;
}
.btn-close-panel:hover {
color: #ff6b6b;
}
/* ── Feedback Styles ────────────────────────── */
.feedback-bar {
display: flex;
gap: 6px;
margin-top: 10px;
align-items: center;
}
.feedback-bar .fb-btn {
padding: 5px 12px;
border: 1px solid #555;
border-radius: 20px;
background: transparent;
color: #aaa;
font-size: 0.82em;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 4px;
}
.feedback-bar .fb-btn:hover {
border-color: #667eea;
color: #667eea;
background: rgba(102, 126, 234, 0.1);
}
.feedback-bar .fb-btn.active-like {
border-color: #4caf50;
color: #4caf50;
background: rgba(76, 175, 80, 0.15);
}
.feedback-bar .fb-btn.active-dislike {
border-color: #ff6b6b;
color: #ff6b6b;
background: rgba(255, 107, 107, 0.15);
}
.feedback-bar .fb-btn:disabled {
cursor: default;
opacity: 0.7;
}
.complaint-form {
margin-top: 10px;
padding: 12px;
background: #1e1e1e;
border: 1px solid #444;
border-radius: 10px;
display: none;
animation: slideDown 0.2s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.complaint-form .cat-chips {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.complaint-form .cat-chip {
padding: 5px 12px;
border: 1px solid #555;
border-radius: 16px;
background: #2d2d2d;
color: #ccc;
font-size: 0.8em;
cursor: pointer;
transition: all 0.2s;
}
.complaint-form .cat-chip:hover {
border-color: #667eea;
color: #667eea;
}
.complaint-form .cat-chip.selected {
border-color: #667eea;
background: rgba(102, 126, 234, 0.2);
color: #667eea;
}
.complaint-form textarea {
width: 100%;
min-height: 60px;
border: 1px solid #444;
border-radius: 8px;
background: #252526;
color: #e0e0e0;
padding: 8px;
font-size: 0.85em;
resize: vertical;
box-sizing: border-box;
margin-bottom: 8px;
}
.complaint-form .submit-row {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.complaint-form .submit-row button {
padding: 6px 16px;
border-radius: 8px;
border: none;
font-size: 0.82em;
cursor: pointer;
font-weight: 600;
}
.complaint-form .btn-cancel {
background: #333;
color: #aaa;
}
.complaint-form .btn-submit {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.feedback-toast {
position: fixed;
bottom: 30px;
right: 30px;
padding: 12px 24px;
border-radius: 10px;
color: white;
font-size: 0.9em;
font-weight: 500;
z-index: 9999;
animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
}
.feedback-toast.success {
background: linear-gradient(135deg, #43a047, #2e7d32);
}
.feedback-toast.error {
background: linear-gradient(135deg, #d32f2f, #b71c1c);
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes toastOut {
from {
opacity: 1;
}
to {
opacity: 0;
transform: translateY(20px);
}
}
/* ── Markdown Rendered Content ────────────── */
.filtered-content .md-content { line-height: 1.7; }
.filtered-content .md-content strong { color: #fff; font-weight: 700; }
.filtered-content .md-content em { color: #c9d1d9; font-style: italic; }
.filtered-content .md-content del { color: #8b949e; text-decoration: line-through; }
.filtered-content .md-content code {
background: rgba(110,118,129,0.25); padding: 2px 6px; border-radius: 4px;
font-family: 'Consolas','Monaco',monospace; font-size: 0.88em; color: #f0883e;
}
.filtered-content .md-content ol,
.filtered-content .md-content ul {
margin: 8px 0; padding-left: 22px;
}
.filtered-content .md-content li { margin: 4px 0; }
.filtered-content .md-content li::marker { color: #667eea; }
.filtered-content .md-content .md-heading {
font-weight: 700; color: #fff; margin: 12px 0 6px;
}
.filtered-content .md-content .md-heading.h1 { font-size: 1.2em; }
.filtered-content .md-content .md-heading.h2 { font-size: 1.1em; }
.filtered-content .md-content .md-heading.h3 { font-size: 1.0em; }
</style>
</head>
<body>
<!-- Navigation Header -->
<div class="main-content">
<div class="main-layout">
<!-- Chat Container -->
<div class="container">
<div class="chat-internal-wrapper">
<div class="header">
<h2>🤖 Canifa AI Chat</h2>
<div class="config-area"
style="flex-wrap: wrap; display: flex; align-items: center; gap: 10px;">
<div style="display: flex; gap: 5px; align-items: center;">
<label style="font-size: 0.8em; color: #aaa;">Device ID:</label>
<input type="text" id="deviceId" placeholder="auto-generated" style="width: 150px;"
onblur="saveConfig()" onchange="saveConfig()">
</div>
<div style="display: flex; gap: 5px; align-items: center;">
<label style="font-size: 0.8em; color: #aaa;">Status:</label>
<!-- Status Indicator logic could go here later -->
</div>
<div style="display: flex; gap: 5px; align-items: center;">
<label style="font-size: 0.8em; color: #aaa;">Access Token:</label>
<input type="text" id="accessToken" placeholder="Token (optional)" style="width: 150px;"
onblur="saveConfig()" onchange="saveConfig()">
</div>
<!-- Action Buttons -->
<button onclick="loadHistory(true)" title="Load History">↻ History</button>
<button onclick="forceRefreshPrompts()" id="refreshPromptBtn"
title="Force Refresh All Prompts from Langfuse"
style="background: #00bcd4; color: #fff; font-weight: bold; border: none; border-radius: 8px; padding: 8px 14px; cursor: pointer; transition: all 0.3s; display: flex; align-items: center; gap: 5px;"
onmouseover="this.style.background='#00acc1'; this.style.transform='scale(1.05)'"
onmouseout="this.style.background='#00bcd4'; this.style.transform='scale(1)'">⚡ Refresh
Prompts</button>
<button onclick="togglePromptEditor()"
style="background: #e6b800; color: #2d2d2d; font-weight: bold;">📝 Prompt</button>
<button onclick="clearUI()" style="background: #d32f2f;">✗ UI</button>
</div>
</div>
<div class="chat-box" id="chatBox">
<div class="load-more" id="loadMoreBtn" style="display: none;">
<button onclick="loadHistory(false)">Load Older Messages ⬆️</button>
</div>
<div id="messagesArea" style="display: flex; flex-direction: column; gap: 15px;"></div>
</div>
<div class="typing-indicator" id="typingIndicator">
<span style="font-style: normal;">🤖</span> AI is thinking...
</div>
<!-- Image Preview Strip -->
<div id="imagePreviewStrip"
style="display: none; padding: 8px 0; gap: 8px; overflow-x: auto; white-space: nowrap;">
</div>
<div class="input-area">
<input type="file" id="imageFileInput" accept="image/*" style="display: none;"
onchange="handleImageSelect(event)">
<button onclick="document.getElementById('imageFileInput').click()" id="imgBtn"
title="Upload Image (Experimental 📸)"
style="background: #4a4a4a; color: #ccc; padding: 0 14px; border: 1px dashed #666; border-radius: 8px; font-size: 1.2em; cursor: pointer; transition: all 0.2s;"
onmouseover="this.style.background='#5a5a5a'; this.style.borderColor='#667eea'; this.style.color='#667eea'"
onmouseout="this.style.background='#4a4a4a'; this.style.borderColor='#666'; this.style.color='#ccc'">📷</button>
<input type="text" id="userInput" placeholder="Type your message..."
onkeypress="handleKeyPress(event)" autocomplete="off">
<button onclick="sendMessage()" id="sendBtn">➤ Send</button>
<button onclick="resetChat()" id="resetBtn" title="Reset Session"
style="background: #ffc107; color: #333; font-weight: bold; padding: 0 20px; margin-left: 10px; border: none; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center;">🔄
Reset</button>
</div>
</div>
</div>
<!-- Prompt Editor Panel -->
<div class="prompt-panel" id="promptPanel">
<div class="prompt-header">
<h3>📝 Prompt Editor</h3>
<div style="display: flex; gap: 10px; align-items: center;">
<div class="prompt-tabs">
<button class="prompt-tab-btn active" id="tab-system"
onclick="switchPromptTab('system')">System</button>
<button class="prompt-tab-btn" id="tab-tool" onclick="switchPromptTab('tool')">Tool</button>
</div>
<button class="btn-close-panel" onclick="togglePromptEditor()">×</button>
</div>
</div>
<div class="prompt-section active" id="systemPromptSection">
<textarea id="systemPromptInput" class="prompt-textarea"
placeholder="Loading system prompt content..." spellcheck="false"></textarea>
<div class="panel-footer">
<span class="status-text" id="promptStatus">Ready to edit</span>
<div style="display: flex; gap: 10px;">
<button class="action-btn btn-reload" onclick="loadSystemPrompt()">↻ Reset</button>
<button class="action-btn btn-save" onclick="saveSystemPrompt()">💾 Save & Apply</button>
</div>
</div>
</div>
<div class="prompt-section" id="toolPromptSection">
<div class="tool-prompt-toolbar">
<select id="toolPromptSelect" class="tool-prompt-select" onchange="loadToolPromptFromSelect()">
<option value="">Loading tools...</option>
</select>
<button class="action-btn btn-reload" onclick="refreshToolPromptList()">↻ Refresh</button>
</div>
<textarea id="toolPromptInput" class="prompt-textarea" placeholder="Select a tool prompt to load..."
spellcheck="false"></textarea>
<div class="panel-footer">
<span class="status-text" id="toolPromptStatus">Ready to edit</span>
<div style="display: flex; gap: 10px;">
<button class="action-btn btn-reload" onclick="reloadToolPromptContent()">↻ Reset</button>
<button class="action-btn btn-save" onclick="saveToolPrompt()">💾 Save Tool Prompt</button>
</div>
</div>
</div>
</div>
</div>
<script>
/* ═══ MARKDOWN RENDERER ═══ */
function renderMarkdown(text) {
if (!text) return '';
// Escape HTML first to prevent XSS
let html = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Headings (### / ## / #)
html = html.replace(/^### (.+)$/gm, '<div class="md-heading h3">$1</div>');
html = html.replace(/^## (.+)$/gm, '<div class="md-heading h2">$1</div>');
html = html.replace(/^# (.+)$/gm, '<div class="md-heading h1">$1</div>');
// Bold **text** or __text__
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
// Italic *text* or _text_ (but not inside words with underscores)
html = html.replace(/(?<![\w*])\*([^*]+?)\*(?![\w*])/g, '<em>$1</em>');
// Strikethrough ~~text~~
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
// Inline code `text`
html = html.replace(/`([^`]+?)`/g, '<code>$1</code>');
// Numbered lists: lines starting with 1. 2. etc.
html = html.replace(/^(\d+)\.\s+(.+)$/gm, '<li value="$1">$2</li>');
html = html.replace(/(<li[^>]*>.*<\/li>\n?)+/g, (match) => '<ol>' + match + '</ol>');
// Bullet lists: lines starting with - or *
html = html.replace(/^[-*]\s+(.+)$/gm, '<li>$1</li>');
// Wrap consecutive <li> not already in <ol> into <ul>
html = html.replace(/(?<!<\/ol>\n?)(<li>.*<\/li>\n?)+/g, (match) => '<ul>' + match + '</ul>');
// Line breaks (convert remaining newlines to <br>)
html = html.replace(/\n/g, '<br>');
// Clean up extra <br> around block elements
html = html.replace(/<br>(<(?:ol|ul|div|li))/g, '$1');
html = html.replace(/(<\/(?:ol|ul|div|li)>)<br>/g, '$1');
return '<div class="md-content">' + html + '</div>';
}
let messageHistory = []; // Store messages for reference
let isPromptPanelOpen = false;
let currentPromptTab = 'system';
let selectedToolPrompt = '';
let pendingImages = []; // 📸 Experimental: images to send with next message
// ==================== IMAGE HANDLING (Experimental) ====================
function handleImageSelect(event) {
const file = event.target.files[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
alert('⚠️ Image too large (max 5MB)');
return;
}
const reader = new FileReader();
reader.onload = function (e) {
pendingImages.push(e.target.result); // data:image/...;base64,...
renderImagePreview();
};
reader.readAsDataURL(file);
event.target.value = ''; // Reset so same file can be re-selected
}
function renderImagePreview() {
const strip = document.getElementById('imagePreviewStrip');
if (pendingImages.length === 0) {
strip.style.display = 'none';
strip.innerHTML = '';
return;
}
strip.style.display = 'flex';
strip.innerHTML = pendingImages.map((img, i) => `
<div style="position: relative; display: inline-block; flex-shrink: 0;">
<img src="${img}" style="height: 60px; border-radius: 8px; border: 1px solid #555; object-fit: cover;">
<button onclick="removePendingImage(${i})" style="position: absolute; top: -5px; right: -5px; background: #d32f2f; color: white; border: none; border-radius: 50%; width: 18px; height: 18px; font-size: 10px; cursor: pointer; line-height: 1;">✕</button>
</div>
`).join('') + `<button onclick="clearPendingImages()" style="background: #555; color: #ccc; border: none; border-radius: 6px; padding: 4px 10px; font-size: 0.75em; cursor: pointer; align-self: center;">Clear all</button>`;
}
function removePendingImage(index) {
pendingImages.splice(index, 1);
renderImagePreview();
}
function clearPendingImages() {
pendingImages = [];
renderImagePreview();
}
// 📋 Clipboard Paste — Ctrl+V ảnh vào ô chat
document.getElementById('userInput').addEventListener('paste', function (e) {
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const blob = item.getAsFile();
if (blob.size > 5 * 1024 * 1024) {
alert('⚠️ Image too large (max 5MB)');
return;
}
const reader = new FileReader();
reader.onload = function (ev) {
pendingImages.push(ev.target.result);
renderImagePreview();
};
reader.readAsDataURL(blob);
break;
}
}
});
async function resetChat() {
if (!confirm('Bạn có chắc muốn làm mới cuộc trò chuyện? Lịch sử cũ sẽ được lưu trữ.')) return;
const deviceId = document.getElementById('deviceId').value;
const accessToken = document.getElementById('accessToken').value.trim();
if (!deviceId) return alert("Missing Device ID");
const headers = {
'Content-Type': 'application/json',
'device_id': deviceId
};
if (accessToken) {
headers['Authorization'] = 'Bearer ' + accessToken;
}
try {
const response = await fetch('/api/history/archive', {
method: 'POST',
headers: headers,
body: JSON.stringify({})
});
const data = await response.json();
if (response.ok && data.status === 'success') {
document.getElementById('messagesArea').innerHTML = '';
const remaining = data.remaining_resets !== undefined ? ` (Còn ${data.remaining_resets} lượt)` : '';
alert('✅ ' + (data.message || 'Reset thành công!') + remaining);
} else {
const errorMsg = data.message || data.detail || 'Không thể reset';
const prefix = data.error_code === 'RESET_LIMIT_EXCEEDED' ? '⚠️ ' : '❌ Lỗi: ';
if (data.require_login) {
if (confirm('🔒 ' + errorMsg + '\nBạn có muốn nhập Token để đăng nhập không?')) {
const token = prompt("Nhập Access Token của bạn:");
if (token) {
document.getElementById('accessToken').value = token;
saveConfig();
alert("Token đã lưu! Hãy thử Reset lại.");
}
}
} else {
alert(prefix + errorMsg);
}
}
} catch (error) {
console.error('Reset error:', error);
alert('Có lỗi xảy ra khi reset.');
}
}
function togglePromptEditor() {
const panel = document.getElementById('promptPanel');
isPromptPanelOpen = !isPromptPanelOpen;
if (isPromptPanelOpen) {
panel.classList.add('open');
if (currentPromptTab === 'system') {
loadSystemPrompt();
} else {
refreshToolPromptList();
}
} else {
panel.classList.remove('open');
}
}
function switchPromptTab(tab) {
currentPromptTab = tab;
const systemSection = document.getElementById('systemPromptSection');
const toolSection = document.getElementById('toolPromptSection');
const systemTab = document.getElementById('tab-system');
const toolTab = document.getElementById('tab-tool');
if (tab === 'system') {
systemSection.classList.add('active');
toolSection.classList.remove('active');
systemTab.classList.add('active');
toolTab.classList.remove('active');
loadSystemPrompt();
} else {
systemSection.classList.remove('active');
toolSection.classList.add('active');
systemTab.classList.remove('active');
toolTab.classList.add('active');
refreshToolPromptList();
}
}
async function loadSystemPrompt() {
const textarea = document.getElementById('systemPromptInput');
textarea.value = "Loading...";
textarea.disabled = true;
try {
const response = await fetch('/api/agent/system-prompt');
const data = await response.json();
if (data.status === 'success') {
textarea.value = data.content;
} else {
textarea.value = "Error loading prompt: " + data.message;
}
} catch (error) {
textarea.value = "Error connecting to server.";
console.error(error);
} finally {
textarea.disabled = false;
}
}
async function saveSystemPrompt() {
const content = document.getElementById('systemPromptInput').value;
const statusLabel = document.getElementById('promptStatus');
if (!content) return;
if (!confirm('Bạn có chắc muốn lưu Prompt mới? Bot sẽ bị reset graph để học prompt mới này.')) {
return;
}
statusLabel.innerText = "Saving...";
try {
const response = await fetch('/api/agent/system-prompt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: content })
});
const data = await response.json();
if (data.status === 'success') {
statusLabel.innerText = "Saved!";
alert('✅ Đã lưu Prompt thành công!\nBot đã sẵn sàng với prompt mới.');
} else {
statusLabel.innerText = "Error!";
alert('❌ Lỗi: ' + data.detail);
}
} catch (error) {
statusLabel.innerText = "Connection Error";
alert('❌ Lỗi kết nối server');
console.error(error);
}
}
async function refreshToolPromptList() {
const select = document.getElementById('toolPromptSelect');
select.innerHTML = '<option value="">Loading tools...</option>';
try {
const response = await fetch('/api/agent/tool-prompts');
const data = await response.json();
if (data.status !== 'success' || !Array.isArray(data.files)) {
throw new Error(data.message || 'Failed to load tool prompts');
}
const files = data.files;
select.innerHTML = '<option value="">Select tool prompt...</option>';
files.forEach(file => {
const option = document.createElement('option');
option.value = file;
option.textContent = file;
select.appendChild(option);
});
if (selectedToolPrompt && files.includes(selectedToolPrompt)) {
select.value = selectedToolPrompt;
loadToolPrompt(selectedToolPrompt);
}
} catch (error) {
select.innerHTML = '<option value="">Error loading tool list</option>';
console.error(error);
alert('❌ Không thể tải danh sách tool prompts.');
}
}
function loadToolPromptFromSelect() {
const select = document.getElementById('toolPromptSelect');
const filename = select.value;
if (!filename) return;
selectedToolPrompt = filename;
loadToolPrompt(filename);
}
async function loadToolPrompt(filename) {
const textarea = document.getElementById('toolPromptInput');
const statusLabel = document.getElementById('toolPromptStatus');
textarea.value = 'Loading...';
textarea.disabled = true;
statusLabel.innerText = `Loading ${filename}...`;
try {
const response = await fetch(`/api/agent/tool-prompts/${encodeURIComponent(filename)}`);
const data = await response.json();
if (data.status === 'success') {
textarea.value = data.content || '';
statusLabel.innerText = `Loaded ${filename}`;
} else {
textarea.value = '';
statusLabel.innerText = 'Error loading prompt';
alert('❌ Lỗi: ' + (data.detail || data.message || 'Không thể load prompt'));
}
} catch (error) {
textarea.value = '';
statusLabel.innerText = 'Connection Error';
alert('❌ Lỗi kết nối server');
console.error(error);
} finally {
textarea.disabled = false;
}
}
function reloadToolPromptContent() {
if (!selectedToolPrompt) {
alert('Vui lòng chọn tool prompt trước.');
return;
}
loadToolPrompt(selectedToolPrompt);
}
async function saveToolPrompt() {
const statusLabel = document.getElementById('toolPromptStatus');
const textarea = document.getElementById('toolPromptInput');
const content = textarea.value;
if (!selectedToolPrompt) {
alert('Vui lòng chọn tool prompt trước.');
return;
}
if (!confirm(`Lưu prompt cho ${selectedToolPrompt}? Graph sẽ reload.`)) {
return;
}
statusLabel.innerText = 'Saving...';
try {
const response = await fetch(`/api/agent/tool-prompts/${encodeURIComponent(selectedToolPrompt)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content })
});
const data = await response.json();
if (data.status === 'success') {
statusLabel.innerText = 'Saved!';
alert(`✅ ${data.message || 'Đã lưu prompt thành công!'}`);
} else {
statusLabel.innerText = 'Error!';
alert('❌ Lỗi: ' + (data.detail || data.message || 'Không thể lưu prompt'));
}
} catch (error) {
statusLabel.innerText = 'Connection Error';
alert('❌ Lỗi kết nối server');
console.error(error);
}
}
function toggleMessageView(messageId) {
const filteredContent = document.getElementById('filtered-' + messageId);
const rawContent = document.getElementById('raw-' + messageId);
const filteredBtn = document.getElementById('filtered-btn-' + messageId);
const rawBtn = document.getElementById('raw-btn-' + messageId);
if (filteredContent.style.display === 'none') {
// Switch to filtered
filteredContent.style.display = 'block';
rawContent.style.display = 'none';
filteredBtn.classList.add('active');
rawBtn.classList.remove('active');
} else {
// Switch to raw
filteredContent.style.display = 'none';
rawContent.style.display = 'block';
rawBtn.classList.add('active');
filteredBtn.classList.remove('active');
}
}
let currentCursor = null;
let isTyping = false;
async function loadHistory(isRefresh) {
const deviceId = document.getElementById('deviceId').value;
const accessToken = document.getElementById('accessToken').value.trim();
const messagesArea = document.getElementById('messagesArea');
const loadMoreBtn = document.getElementById('loadMoreBtn');
if (!deviceId) {
alert('Please enter a Device ID');
return;
}
if (isRefresh) {
messagesArea.innerHTML = '';
currentCursor = null;
}
// Gọi API với device_id trong URL, nhưng gửi kèm headers để middleware resolve đúng identity
const url = `/api/history/${deviceId}?limit=20${currentCursor ? `&before_id=${currentCursor}` : ''}`;
// Build headers for identity resolution (middleware sẽ dùng token để override nếu có)
const headers = {
'Content-Type': 'application/json',
'device_id': deviceId
};
if (accessToken) {
headers['Authorization'] = 'Bearer ' + accessToken;
}
try {
const response = await fetch(url, { headers: headers });
const data = await response.json();
const messages = data.data || data;
const cursor = data.next_cursor || null;
if (Array.isArray(messages) && messages.length > 0) {
currentCursor = cursor;
if (isRefresh) {
// Refresh: reverse để oldest ở trên, newest ở dưới
const batch = [...messages].reverse();
batch.forEach(msg => appendMessage(msg, 'bottom'));
setTimeout(() => {
const chatBox = document.getElementById('chatBox');
chatBox.scrollTop = chatBox.scrollHeight;
}, 100);
} else {
// Load more: messages từ API theo DESC (newest first của batch cũ)
const chatBox = document.getElementById('chatBox');
const oldHeight = chatBox.scrollHeight;
for (let i = 0; i < messages.length; i++) {
appendMessage(messages[i], 'top');
}
// Adjust scroll to keep view stable
chatBox.scrollTop = chatBox.scrollHeight - oldHeight;
}
loadMoreBtn.style.display = currentCursor ? 'block' : 'none';
} else {
if (isRefresh) {
messagesArea.innerHTML = '<div class="message system">No history found. Start chatting!</div>';
}
loadMoreBtn.style.display = 'none';
}
} catch (error) {
console.error('Error loading history:', error);
alert('Failed to load history');
}
}
function appendMessage(msg, position = 'bottom') {
const messagesArea = document.getElementById('messagesArea');
// Container wrapper for alignment
const container = document.createElement('div');
container.className = `message-container ${msg.is_human ? 'user' : 'bot'}`;
// Sender Name Label
const sender = document.createElement('div');
sender.className = 'sender-name';
sender.innerText = msg.is_human ? 'You' : 'Canifa AI';
container.appendChild(sender);
// Message Bubble
const div = document.createElement('div');
div.className = `message ${msg.is_human ? 'user' : 'bot'}`;
// Generate unique message ID for toggle
const messageId = 'hist-' + (msg.id || Date.now() + Math.random());
if (msg.is_human) {
// User message: show images (if any) + text
if (msg.images && msg.images.length > 0) {
const imgStrip = document.createElement('div');
imgStrip.style.cssText = 'display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap;';
msg.images.forEach(src => {
const img = document.createElement('img');
img.src = src;
img.style.cssText = 'max-height: 120px; max-width: 180px; border-radius: 8px; object-fit: cover; border: 1px solid rgba(255,255,255,0.2);';
imgStrip.appendChild(img);
});
div.appendChild(imgStrip);
}
const textSpan = document.createElement('span');
textSpan.innerText = msg.message;
div.appendChild(textSpan);
} else {
// Bot message: add Widget/Raw JSON toggle
// FILTERED CONTENT (default visible)
const filteredDiv = document.createElement('div');
filteredDiv.id = 'filtered-' + messageId;
filteredDiv.className = 'filtered-content';
filteredDiv.innerHTML = renderMarkdown(msg.message);
div.appendChild(filteredDiv);
// RAW CONTENT (hidden by default)
const rawDiv = document.createElement('div');
rawDiv.id = 'raw-' + messageId;
rawDiv.className = 'raw-content';
rawDiv.style.display = 'none';
const rawJsonDiv = document.createElement('div');
rawJsonDiv.className = 'raw-json-view';
const pre = document.createElement('pre');
pre.textContent = JSON.stringify({
id: msg.id,
message: msg.message,
product_ids: msg.product_ids || [],
timestamp: msg.timestamp,
is_human: msg.is_human
}, null, 2);
rawJsonDiv.appendChild(pre);
rawDiv.appendChild(rawJsonDiv);
div.appendChild(rawDiv);
// Toggle Buttons
const toggleDiv = document.createElement('div');
toggleDiv.className = 'message-view-toggle';
const filteredBtn = document.createElement('button');
filteredBtn.id = 'filtered-btn-' + messageId;
filteredBtn.className = 'active';
filteredBtn.innerText = '🎨 Widget';
filteredBtn.onclick = () => toggleMessageView(messageId);
const rawBtn = document.createElement('button');
rawBtn.id = 'raw-btn-' + messageId;
rawBtn.innerText = '👁️ Raw JSON';
rawBtn.onclick = () => toggleMessageView(messageId);
toggleDiv.appendChild(filteredBtn);
toggleDiv.appendChild(rawBtn);
div.appendChild(toggleDiv);
}
// Timestamp inside bubble
const time = document.createElement('span');
time.className = 'timestamp';
time.innerText = new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
div.appendChild(time);
container.appendChild(div);
if (position === 'top') {
messagesArea.insertBefore(container, messagesArea.firstChild);
} else {
messagesArea.appendChild(container);
}
}
async function sendMessage() {
const input = document.getElementById('userInput');
const deviceIdInput = document.getElementById('deviceId');
const accessTokenInput = document.getElementById('accessToken');
const deviceId = deviceIdInput.value.trim();
const accessToken = accessTokenInput.value.trim();
const text = input.value.trim();
const sendBtn = document.getElementById('sendBtn');
const typingIndicator = document.getElementById('typingIndicator');
const chatBox = document.getElementById('chatBox');
if (!deviceId) {
alert('Please enter a Device ID first!');
deviceIdInput.focus();
return;
}
if (!text) return;
// Disable input
input.disabled = true;
sendBtn.disabled = true;
typingIndicator.style.display = 'block';
// Capture images before clearing
const imagesToSend = [...pendingImages];
// Add user message immediately (with image previews)
appendMessage({
message: text,
is_human: true,
timestamp: new Date().toISOString(),
id: 'pending',
images: imagesToSend.length > 0 ? imagesToSend : undefined
});
input.value = '';
clearPendingImages(); // Clear image previews after send
chatBox.scrollTop = chatBox.scrollHeight;
// Save config to localStorage
saveConfig();
// Track response time
const startTime = Date.now();
try {
// Build headers
const headers = {
'Content-Type': 'application/json',
'device_id': deviceId
};
// Add Authorization if access token provided
if (accessToken) {
headers['Authorization'] = 'Bearer ' + accessToken;
}
const response = await fetch('/api/agent/chat-dev', {
method: 'POST',
headers: headers,
body: JSON.stringify({
user_query: text,
device_id: deviceId,
...(imagesToSend.length > 0 && { images: imagesToSend })
})
});
// Handle API Errors (Rate Limit, System Error) with Widget/Raw View
if (response.status === 429 || response.status === 500) {
const errorData = await response.json();
const responseTime = ((Date.now() - startTime) / 1000).toFixed(2);
const messageId = 'msg-error-' + Date.now();
// Create bot message container
const messagesArea = document.getElementById('messagesArea');
const container = document.createElement('div');
container.className = 'message-container bot';
const sender = document.createElement('div');
sender.className = 'sender-name';
sender.innerText = 'Canifa AI';
container.appendChild(sender);
const botMsgDiv = document.createElement('div');
botMsgDiv.className = 'message bot';
// 1. FILTERED CONTENT (Widget View)
const filteredDiv = document.createElement('div');
filteredDiv.id = 'filtered-' + messageId;
filteredDiv.className = 'filtered-content';
filteredDiv.style.color = '#ff6b6b';
// Extract message
const errorMessage = errorData.message ||
errorData.detail?.message ||
'Có lỗi xảy ra!';
filteredDiv.innerHTML = `
<div style="font-weight: bold; margin-bottom: 8px;">⚠️ ${errorData.error_code || 'ERROR'}</div>
<div>${errorMessage}</div>
${errorData.require_login ? '<div style="margin-top: 10px; padding: 8px; background: #3d2d2d; border-radius: 6px;">👉 Đăng nhập ngay để tiếp tục!</div>' : ''}
`;
botMsgDiv.appendChild(filteredDiv);
// 2. RAW CONTENT (JSON View)
const rawDiv = document.createElement('div');
rawDiv.id = 'raw-' + messageId;
rawDiv.className = 'raw-content';
rawDiv.style.display = 'none';
const rawJsonDiv = document.createElement('div');
rawJsonDiv.className = 'raw-json-view';
const pre = document.createElement('pre');
pre.textContent = JSON.stringify(errorData, null, 2);
rawJsonDiv.appendChild(pre);
rawDiv.appendChild(rawJsonDiv);
botMsgDiv.appendChild(rawDiv);
// 3. Toggle Buttons
const toggleDiv = document.createElement('div');
toggleDiv.className = 'message-view-toggle';
const filteredBtn = document.createElement('button');
filteredBtn.id = 'filtered-btn-' + messageId;
filteredBtn.className = 'active';
filteredBtn.innerText = '🎨 Widget';
filteredBtn.onclick = () => toggleMessageView(messageId);
const rawBtn = document.createElement('button');
rawBtn.id = 'raw-btn-' + messageId;
rawBtn.innerText = '👁️ Raw JSON';
rawBtn.onclick = () => toggleMessageView(messageId);
toggleDiv.appendChild(filteredBtn);
toggleDiv.appendChild(rawBtn);
botMsgDiv.appendChild(toggleDiv);
// Response time
const timeDiv = document.createElement('div');
timeDiv.className = 'response-time';
timeDiv.innerText = `⏱️ ${responseTime}s`;
botMsgDiv.appendChild(timeDiv);
container.appendChild(botMsgDiv);
messagesArea.appendChild(container);
chatBox.scrollTop = chatBox.scrollHeight;
input.disabled = false;
sendBtn.disabled = false;
typingIndicator.style.display = 'none';
return;
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail?.message || errorData.detail || 'Có lỗi xảy ra');
}
const data = await response.json();
const responseTime = (data.response_ready_s !== undefined && data.response_ready_s !== null)
? Number(data.response_ready_s).toFixed(2)
: ((Date.now() - startTime) / 1000).toFixed(2);
// Generate unique message ID
const messageId = 'msg-' + Date.now();
// Store message data
messageHistory.push({
type: 'bot',
data: data,
responseTime: responseTime,
timestamp: new Date().toISOString(),
id: messageId
});
const messagesArea = document.getElementById('messagesArea');
// ============================================
// MESSAGE 1: AI Response + Products (Immediate)
// ============================================
if (data.status === 'success') {
const container1 = document.createElement('div');
container1.className = 'message-container bot';
const sender1 = document.createElement('div');
sender1.className = 'sender-name';
sender1.innerText = 'Canifa AI';
container1.appendChild(sender1);
const botMsgDiv1 = document.createElement('div');
botMsgDiv1.className = 'message bot';
// FILTERED CONTENT
const filteredDiv = document.createElement('div');
filteredDiv.id = 'filtered-' + messageId;
filteredDiv.className = 'filtered-content';
// Display AI text response
const textDiv = document.createElement('div');
textDiv.innerHTML = renderMarkdown(data.ai_response || 'No response');
filteredDiv.appendChild(textDiv);
// Render product cards if available
if (data.product_ids && data.product_ids.length > 0) {
const productsContainer = document.createElement('div');
productsContainer.className = 'product-cards-container';
data.product_ids.forEach(product => {
const card = document.createElement('div');
card.className = 'product-card';
// Product image
const img = document.createElement('img');
img.src = product.thumbnail_image_url || 'https://via.placeholder.com/200';
img.alt = product.name;
img.onerror = function () { this.src = 'https://via.placeholder.com/200?text=No+Image'; };
card.appendChild(img);
// Product body
const body = document.createElement('div');
body.className = 'product-card-body';
// SKU
const sku = document.createElement('div');
sku.className = 'product-sku';
sku.innerText = product.sku;
body.appendChild(sku);
// Name
const name = document.createElement('div');
name.className = 'product-name';
name.innerText = product.name;
body.appendChild(name);
// Price
const priceDiv = document.createElement('div');
priceDiv.className = 'product-price';
if (product.sale_price && product.price && product.sale_price < product.price) {
const originalPrice = document.createElement('span');
originalPrice.className = 'price-original';
originalPrice.innerText = (product.price || 0).toLocaleString('vi-VN') + 'đ';
priceDiv.appendChild(originalPrice);
const salePrice = document.createElement('span');
salePrice.className = 'price-sale';
salePrice.innerText = (product.sale_price || 0).toLocaleString('vi-VN') + 'đ';
priceDiv.appendChild(salePrice);
} else if (product.price) {
const regularPrice = document.createElement('span');
regularPrice.className = 'price-regular';
regularPrice.innerText = (product.price || 0).toLocaleString('vi-VN') + 'đ';
priceDiv.appendChild(regularPrice);
} else {
const noPrice = document.createElement('span');
noPrice.className = 'price-regular';
noPrice.innerText = 'Liên hệ';
priceDiv.appendChild(noPrice);
}
body.appendChild(priceDiv);
// Link button
const link = document.createElement('a');
link.className = 'product-link';
link.href = product.url;
link.target = '_blank';
link.innerText = '🛍️ Xem chi tiết';
body.appendChild(link);
card.appendChild(body);
productsContainer.appendChild(card);
});
filteredDiv.appendChild(productsContainer);
}
botMsgDiv1.appendChild(filteredDiv);
// RAW CONTENT
const rawDiv = document.createElement('div');
rawDiv.id = 'raw-' + messageId;
rawDiv.className = 'raw-content';
rawDiv.style.display = 'none';
const rawJsonDiv = document.createElement('div');
rawJsonDiv.className = 'raw-json-view';
const pre = document.createElement('pre');
pre.textContent = JSON.stringify({
status: data.status,
ai_response: data.ai_response,
product_ids: data.product_ids,
response_ready_s: data.response_ready_s,
response_ready_stream_s: data.response_ready_stream_s,
limit_info: data.limit_info || null,
}, null, 2);
rawJsonDiv.appendChild(pre);
rawDiv.appendChild(rawJsonDiv);
botMsgDiv1.appendChild(rawDiv);
// Toggle Buttons
const toggleDiv = document.createElement('div');
toggleDiv.className = 'message-view-toggle';
const filteredBtn = document.createElement('button');
filteredBtn.id = 'filtered-btn-' + messageId;
filteredBtn.className = 'active';
filteredBtn.innerText = '🎨 Widget';
filteredBtn.onclick = () => toggleMessageView(messageId);
const rawBtn = document.createElement('button');
rawBtn.id = 'raw-btn-' + messageId;
rawBtn.innerText = '👁️ Raw JSON';
rawBtn.onclick = () => toggleMessageView(messageId);
toggleDiv.appendChild(filteredBtn);
toggleDiv.appendChild(rawBtn);
botMsgDiv1.appendChild(toggleDiv);
// Response time for message 1
const timeDiv = document.createElement('div');
timeDiv.className = 'response-time';
timeDiv.innerText = `⏱️ ${responseTime}s`;
botMsgDiv1.appendChild(timeDiv);
// Limit info display
if (data.limit_info) {
const limitDiv = document.createElement('div');
limitDiv.className = 'limit-info';
limitDiv.style.fontSize = '0.85em';
limitDiv.style.color = '#a0a0a0';
limitDiv.style.marginTop = '8px';
limitDiv.style.padding = '8px';
limitDiv.style.background = 'rgba(255, 255, 255, 0.05)';
limitDiv.style.borderRadius = '6px';
limitDiv.style.borderLeft = '3px solid #667eea';
const limitText = `📊 Message Limit: ${data.limit_info.used}/${data.limit_info.limit} (Còn ${data.limit_info.remaining} tin nhắn)`;
limitDiv.innerText = limitText;
botMsgDiv1.appendChild(limitDiv);
}
// ── Feedback Buttons ──
const traceId = data.trace_id || '';
if (traceId) {
const fbBar = document.createElement('div');
fbBar.className = 'feedback-bar';
fbBar.id = 'fb-bar-' + messageId;
fbBar.setAttribute('data-trace-id', traceId);
fbBar.setAttribute('data-rating', ''); // lưu rating đã chọn
fbBar.innerHTML = `
<button class="fb-btn" onclick="handleFeedbackClick('${messageId}', '${traceId}', 1, this)" title="Câu trả lời hữu ích">👍</button>
<button class="fb-btn" onclick="handleFeedbackClick('${messageId}', '${traceId}', 0, this)" title="Câu trả lời chưa tốt">👎</button>
<button class="fb-btn fb-comment-btn" onclick="toggleCommentForm('${messageId}')" title="Viết nhận xét">💬 Comment</button>
`;
botMsgDiv1.appendChild(fbBar);
// Comment Form (hidden by default, adapts to Like/Dislike)
const formDiv = document.createElement('div');
formDiv.className = 'complaint-form';
formDiv.id = 'comment-form-' + messageId;
formDiv.innerHTML = `
<div class="cat-chips-section" id="cat-section-${messageId}" style="display:none;">
<div style="font-size:0.85em;color:#aaa;margin-bottom:8px;">Chọn loại vấn đề:</div>
<div class="cat-chips">
<span class="cat-chip" onclick="selectCategory(this)" data-cat="du_lieu_sai">❌ Dữ liệu sai</span>
<span class="cat-chip" onclick="selectCategory(this)" data-cat="thieu_du_lieu">📭 Thiếu dữ liệu</span>
<span class="cat-chip" onclick="selectCategory(this)" data-cat="bot_khong_hieu">🤷 Bot không hiểu</span>
<span class="cat-chip" onclick="selectCategory(this)" data-cat="trai_nghiem_kem">😤 Trải nghiệm kém</span>
<span class="cat-chip" onclick="selectCategory(this)" data-cat="khac">💬 Khác</span>
</div>
</div>
<textarea placeholder="Viết nhận xét của bạn..." id="comment-text-${messageId}"></textarea>
<div class="submit-row">
<button class="btn-cancel" onclick="hideCommentForm('${messageId}')">Huỷ</button>
<button class="btn-submit" onclick="submitComment('${messageId}', '${traceId}')">Gửi nhận xét</button>
</div>
`;
botMsgDiv1.appendChild(formDiv);
}
container1.appendChild(botMsgDiv1);
messagesArea.appendChild(container1);
chatBox.scrollTop = chatBox.scrollHeight;
// ============================================
// MESSAGE 2: User Insight (Polling - real backend delay)
// ============================================
const renderUserInsightMessage = (insightObj) => {
const container2 = document.createElement('div');
container2.className = 'message-container bot';
const sender2 = document.createElement('div');
sender2.className = 'sender-name';
sender2.innerText = 'Canifa AI - Analytics';
container2.appendChild(sender2);
const botMsgDiv2 = document.createElement('div');
botMsgDiv2.className = 'message bot';
const insightDiv = document.createElement('div');
insightDiv.className = 'user-insight';
insightDiv.innerHTML = '<strong>🧠 User Insight:</strong><br/>';
Object.entries(insightObj).forEach(([key, value]) => {
const line = document.createElement('div');
line.style.fontSize = '0.9em';
line.style.marginTop = '2px';
line.innerHTML = `<strong>${key}:</strong> ${value}`;
insightDiv.appendChild(line);
});
botMsgDiv2.appendChild(insightDiv);
// Real timing for insight render
const insightTime = ((Date.now() - startTime) / 1000).toFixed(2);
const insightTimeDiv = document.createElement('div');
insightTimeDiv.className = 'response-time';
insightTimeDiv.innerText = `⏱️ Insight ${insightTime}s`;
botMsgDiv2.appendChild(insightTimeDiv);
container2.appendChild(botMsgDiv2);
messagesArea.appendChild(container2);
chatBox.scrollTop = chatBox.scrollHeight;
};
const pollUserInsight = async () => {
const maxAttempts = 60; // ~12s at 200ms
const intervalMs = 200;
let attempts = 0;
const headers = {
'Content-Type': 'application/json',
'device_id': deviceId
};
if (accessToken) {
headers['Authorization'] = 'Bearer ' + accessToken;
}
const tick = async () => {
attempts += 1;
try {
const res = await fetch('/api/agent/user-insight', { headers });
if (!res.ok) throw new Error('Failed to fetch user_insight');
const payload = await res.json();
if (payload.status === 'success' && payload.user_insight) {
const insightObj = typeof payload.user_insight === 'string'
? JSON.parse(payload.user_insight)
: payload.user_insight;
renderUserInsightMessage(insightObj);
return;
}
} catch (e) {
console.warn('Polling user_insight failed:', e);
}
if (attempts < maxAttempts) {
setTimeout(tick, intervalMs);
}
};
setTimeout(tick, 50);
};
if (data.insight_status === 'pending') {
pollUserInsight();
}
} else {
// ERROR CASE: Limit exceeded or other errors
const container = document.createElement('div');
container.className = 'message-container bot';
const sender = document.createElement('div');
sender.className = 'sender-name';
sender.innerText = 'Canifa AI';
container.appendChild(sender);
const botMsgDiv = document.createElement('div');
botMsgDiv.className = 'message bot';
// FILTERED CONTENT (error message - default visible)
const filteredDiv = document.createElement('div');
filteredDiv.id = 'filtered-' + messageId;
filteredDiv.className = 'filtered-content';
filteredDiv.style.color = '#ff6b6b';
filteredDiv.innerHTML = `
<div style="font-weight: bold; margin-bottom: 8px;">⚠️ ${data.error_code || 'ERROR'}</div>
<div>${data.message || 'Unknown error'}</div>
${data.require_login ? '<div style="margin-top: 10px; padding: 8px; background: #3d2d2d; border-radius: 6px;">👉 Vui lòng đăng nhập để tiếp tục sử dụng!</div>' : ''}
`;
botMsgDiv.appendChild(filteredDiv);
// RAW CONTENT (hidden by default)
const rawDiv = document.createElement('div');
rawDiv.id = 'raw-' + messageId;
rawDiv.className = 'raw-content';
rawDiv.style.display = 'none';
const rawJsonDiv = document.createElement('div');
rawJsonDiv.className = 'raw-json-view';
const pre = document.createElement('pre');
pre.textContent = JSON.stringify({
status: data.status,
error_code: data.error_code,
message: data.message,
require_login: data.require_login,
limit_info: data.limit_info || null
}, null, 2);
rawJsonDiv.appendChild(pre);
rawDiv.appendChild(rawJsonDiv);
botMsgDiv.appendChild(rawDiv);
// Toggle Buttons
const toggleDiv = document.createElement('div');
toggleDiv.className = 'message-view-toggle';
const filteredBtn = document.createElement('button');
filteredBtn.id = 'filtered-btn-' + messageId;
filteredBtn.className = 'active';
filteredBtn.innerText = '🎨 Widget';
filteredBtn.onclick = () => toggleMessageView(messageId);
const rawBtn = document.createElement('button');
rawBtn.id = 'raw-btn-' + messageId;
rawBtn.innerText = '👁️ Raw JSON';
rawBtn.onclick = () => toggleMessageView(messageId);
toggleDiv.appendChild(filteredBtn);
toggleDiv.appendChild(rawBtn);
botMsgDiv.appendChild(toggleDiv);
container.appendChild(botMsgDiv);
messagesArea.appendChild(container);
}
chatBox.scrollTop = chatBox.scrollHeight;
} catch (error) {
console.error('Error sending message:', error);
appendMessage({
message: `Error: ${error.message}`,
is_human: false,
timestamp: new Date().toISOString(),
id: 'error'
});
} finally {
input.disabled = false;
sendBtn.disabled = false;
typingIndicator.style.display = 'none';
input.focus();
chatBox.scrollTop = chatBox.scrollHeight;
}
}
function handleKeyPress(event) {
if (event.key === 'Enter') {
sendMessage();
}
}
// ── Feedback Functions ──────────────────────
function showFeedbackToast(message, type = 'success') {
const existing = document.querySelector('.feedback-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = 'feedback-toast ' + type;
toast.innerText = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
// ── New Feedback Logic: Like/Dislike + Comment ──
function handleFeedbackClick(messageId, traceId, rating, btn) {
const bar = btn.closest('.feedback-bar');
const buttons = bar.querySelectorAll('.fb-btn:not(.fb-comment-btn)');
// Toggle: click lại nút đã chọn → bỏ chọn
if (btn.classList.contains(rating === 1 ? 'active-like' : 'active-dislike')) {
btn.classList.remove('active-like', 'active-dislike');
bar.setAttribute('data-rating', '');
return;
}
// Set active state
buttons.forEach(b => b.classList.remove('active-like', 'active-dislike'));
btn.classList.add(rating === 1 ? 'active-like' : 'active-dislike');
bar.setAttribute('data-rating', rating);
// Update comment form: show/hide category chips
const catSection = document.getElementById('cat-section-' + messageId);
if (catSection) {
catSection.style.display = (rating === 0) ? 'block' : 'none';
}
// Gửi feedback ngay (không cần comment)
sendFeedbackToServer(traceId, rating);
}
async function sendFeedbackToServer(traceId, rating, comment = '', category = '') {
try {
const res = await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trace_id: traceId, rating: rating, comment: comment, category: category })
});
const data = await res.json();
if (data.status === 'success') {
const msg = rating === 1 ? '👍 Cảm ơn bạn!' : '👎 Cảm ơn phản hồi!';
showFeedbackToast(comment ? '✅ Nhận xét đã gửi thành công!' : msg);
} else {
throw new Error(data.message);
}
} catch (e) {
showFeedbackToast('❌ Lỗi gửi phản hồi: ' + e.message, 'error');
}
}
function toggleCommentForm(messageId) {
const form = document.getElementById('comment-form-' + messageId);
if (!form) return;
const isOpen = form.style.display === 'block';
form.style.display = isOpen ? 'none' : 'block';
// Show/hide category chips based on current rating
if (!isOpen) {
const bar = document.getElementById('fb-bar-' + messageId);
const currentRating = bar ? bar.getAttribute('data-rating') : '';
const catSection = document.getElementById('cat-section-' + messageId);
if (catSection) {
catSection.style.display = (currentRating === '0') ? 'block' : 'none';
}
// Auto-focus textarea
const textarea = document.getElementById('comment-text-' + messageId);
if (textarea) setTimeout(() => textarea.focus(), 100);
}
}
function hideCommentForm(messageId) {
const form = document.getElementById('comment-form-' + messageId);
if (form) form.style.display = 'none';
}
function selectCategory(chip) {
const siblings = chip.parentElement.querySelectorAll('.cat-chip');
siblings.forEach(s => s.classList.remove('selected'));
chip.classList.add('selected');
}
async function submitComment(messageId, traceId) {
const form = document.getElementById('comment-form-' + messageId);
const bar = document.getElementById('fb-bar-' + messageId);
const selectedChip = form.querySelector('.cat-chip.selected');
const textarea = document.getElementById('comment-text-' + messageId);
const comment = textarea ? textarea.value.trim() : '';
const category = selectedChip ? selectedChip.getAttribute('data-cat') : '';
if (!comment) {
showFeedbackToast('Vui lòng viết nhận xét trước khi gửi', 'error');
return;
}
// Lấy rating đã chọn (mặc định 0 nếu chưa chọn)
const currentRating = bar ? parseInt(bar.getAttribute('data-rating')) : 0;
const rating = isNaN(currentRating) ? 0 : currentRating;
const submitBtn = form.querySelector('.btn-submit');
submitBtn.disabled = true;
submitBtn.innerText = '⏳ Đang gửi...';
await sendFeedbackToServer(traceId, rating, comment, category);
// Close form + disable buttons
form.style.display = 'none';
if (bar) {
bar.querySelectorAll('.fb-btn').forEach(b => b.disabled = true);
}
submitBtn.disabled = false;
submitBtn.innerText = 'Gửi nhận xét';
}
async function forceRefreshPrompts() {
const btn = document.getElementById('refreshPromptBtn');
const originalText = btn.innerHTML;
btn.innerHTML = '🔄 Refreshing...';
btn.disabled = true;
btn.style.opacity = '0.7';
try {
const response = await fetch('/api/prompt/refresh', { method: 'POST' });
const data = await response.json();
if (data.status === 'success') {
btn.innerHTML = '✅ Done!';
btn.style.background = '#4caf50';
setTimeout(() => {
btn.innerHTML = originalText;
btn.style.background = '#00bcd4';
}, 2000);
} else {
throw new Error(data.detail || 'Refresh failed');
}
} catch (error) {
btn.innerHTML = '❌ Error';
btn.style.background = '#d32f2f';
console.error('Prompt refresh error:', error);
alert('❌ Không thể refresh prompts: ' + error.message);
setTimeout(() => {
btn.innerHTML = originalText;
btn.style.background = '#00bcd4';
}, 2000);
} finally {
btn.disabled = false;
btn.style.opacity = '1';
}
}
function clearUI() {
document.getElementById('messagesArea').innerHTML = '';
}
// Apply token from login prompt in rate limit error
function applyLoginToken() {
const tokenInput = document.getElementById('loginTokenInput');
if (tokenInput && tokenInput.value.trim()) {
document.getElementById('accessToken').value = tokenInput.value.trim();
saveConfig();
alert('✅ Token đã được lưu! Bạn có thể tiếp tục chat.');
} else {
alert('Vui lòng nhập Access Token!');
}
}
// Save config to localStorage (called on input change/blur)
function saveConfig() {
const deviceId = document.getElementById('deviceId').value.trim();
const accessToken = document.getElementById('accessToken').value.trim();
if (deviceId) {
localStorage.setItem('canifa_device_id', deviceId);
}
if (accessToken) {
localStorage.setItem('canifa_access_token', accessToken);
} else {
localStorage.removeItem('canifa_access_token');
}
}
// Generate UUID for device_id
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
// Load config from localStorage on page load
window.onload = function () {
// Load or generate Device ID
let savedDeviceId = localStorage.getItem('canifa_device_id');
if (!savedDeviceId) {
savedDeviceId = 'device-' + generateUUID().substring(0, 8);
localStorage.setItem('canifa_device_id', savedDeviceId);
}
document.getElementById('deviceId').value = savedDeviceId;
// Load Access Token (optional)
const savedAccessToken = localStorage.getItem('canifa_access_token');
if (savedAccessToken) {
document.getElementById('accessToken').value = savedAccessToken;
}
// Auto-load history
setTimeout(() => loadHistory(true), 50);
};
</script>
</div> <!-- Close main-content -->
</body>
</html>
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<title>Quản lý Message Limit</title>
<style>
:root {
--bg: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--text: #0f172a;
--muted: #64748b;
--primary: #3b82f6;
--primary-hover: #2563eb;
--success: #10b981;
--error: #ef4444;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
margin: 0;
padding: 40px 20px;
display: flex;
justify-content: center;
}
.container {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 30px;
width: 100%;
max-width: 500px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
h2 {
margin-top: 0;
margin-bottom: 8px;
}
p {
color: var(--muted);
margin-top: 0;
margin-bottom: 24px;
font-size: 14px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
font-size: 14px;
}
input[type="text"] {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
box-sizing: border-box;
outline: none;
transition: border-color 0.2s;
}
input[type="text"]:focus {
border-color: var(--primary);
}
.btn {
display: inline-block;
width: 100%;
padding: 10px 16px;
background: var(--primary);
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
text-align: center;
transition: background 0.2s;
}
.btn:hover {
background: var(--primary-hover);
}
.btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.btn-secondary {
background: #e2e8f0;
color: #0f172a;
margin-top: 10px;
}
.btn-secondary:hover {
background: #cbd5e1;
}
#resultBox {
display: none;
margin-top: 24px;
padding: 16px;
border-radius: 8px;
background: #f1f5f9;
border: 1px solid #cbd5e1;
font-size: 14px;
}
#resultStatus {
font-weight: bold;
margin-bottom: 8px;
}
.success { color: var(--success); }
.error { color: var(--error); }
.info-row {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
padding-bottom: 6px;
border-bottom: 1px dashed #cbd5e1;
}
.info-row:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
</style>
</head>
<body>
<div class="container">
<h2>Thêm Limit / Reset Quota</h2>
<p>Nhập Identity Key (ví dụ: <code>user:123</code> hoặc <code>device:abc</code>) để xem thông tin hoặc reset/thêm limit gửi tin nhắn trong ngày.</p>
<div class="form-group">
<label for="identityKey">Identity Key</label>
<input type="text" id="identityKey" placeholder="user:..." autocomplete="off">
</div>
<button class="btn" id="btnInfo" onclick="checkInfo()">Kiểm tra thông tin</button>
<button class="btn btn-secondary" id="btnReset" onclick="resetLimit()" style="background:var(--success); color:white;">Thêm/Reset Limit</button>
<div id="resultBox">
<div id="resultStatus"></div>
<div id="resultDetails"></div>
</div>
</div>
<script>
const API_BASE = '/api/limit';
async function checkInfo() {
const key = document.getElementById('identityKey').value.trim();
if (!key) { alert('Vui lòng nhập Identity Key'); return; }
setLoading('btnInfo', true);
try {
const res = await fetch(`${API_BASE}/info?identity_key=${encodeURIComponent(key)}`);
const data = await res.json();
const box = document.getElementById('resultBox');
const statusLabel = document.getElementById('resultStatus');
const details = document.getElementById('resultDetails');
box.style.display = 'block';
if (data.status === 'success') {
statusLabel.className = 'success';
statusLabel.textContent = 'Thông tin Limit';
const info = data.info || {};
details.innerHTML = `
<div class="info-row"><span>Đã dùng:</span> <strong>${info.used || 0} / ${info.limit || 0}</strong></div>
<div class="info-row"><span>Còn lại:</span> <strong>${info.remaining || 0}</strong></div>
<div class="info-row"><span>Loại:</span> <strong>${info.is_authenticated ? 'User' : 'Guest'}</strong></div>
<div class="info-row"><span>Đã Block:</span> <strong>${info.remaining <= 0 ? 'Có' : 'Không'}</strong></div>
`;
} else {
statusLabel.className = 'error';
statusLabel.textContent = 'Lỗi';
details.innerHTML = `<div>${data.message || 'Không thể lấy thông tin'}</div>`;
}
} catch (e) {
alert('Lỗi: ' + e.message);
} finally {
setLoading('btnInfo', false);
}
}
async function resetLimit() {
const key = document.getElementById('identityKey').value.trim();
if (!key) { alert('Vui lòng nhập Identity Key'); return; }
if (!confirm(`Bạn có chắc chắn muốn reset (thêm) limit cho ${key}? Dữ liệu số tin nhắn đã gửi hôm nay sẽ về 0.`)) return;
setLoading('btnReset', true);
try {
const res = await fetch(`${API_BASE}/reset`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ identity_key: key })
});
const data = await res.json();
const box = document.getElementById('resultBox');
const statusLabel = document.getElementById('resultStatus');
const details = document.getElementById('resultDetails');
box.style.display = 'block';
details.innerHTML = '';
if (data.status === 'success') {
statusLabel.className = 'success';
statusLabel.textContent = data.message;
// Tự động tải lại thông tin
setTimeout(checkInfo, 500);
} else {
statusLabel.className = 'error';
statusLabel.textContent = 'Lỗi';
details.innerHTML = `<div>${data.message || 'Không thể reset'}</div>`;
}
} catch (e) {
alert('Lỗi: ' + e.message);
} finally {
setLoading('btnReset', false);
}
}
function setLoading(btnId, isLoading) {
const btn = document.getElementById(btnId);
if (isLoading) {
btn.dataset.og = btn.innerHTML;
btn.innerHTML = 'Đang xử lý...';
btn.disabled = true;
} else {
btn.innerHTML = btn.dataset.og || btn.innerHTML;
btn.disabled = false;
}
}
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canifa AI Platform</title>
<link rel="stylesheet" href="/static/css/theme.css">
<link rel="stylesheet" href="/static/css/components.css">
<link rel="stylesheet" href="/static/css/lab.css">
<link rel="stylesheet" href="/static/css/dashboard.css?v=4">
<style>
/* ═══ MAIN LAYOUT OVERRIDES ═══ */
body{margin:0;display:flex;min-height:100vh}
.main{margin-left:240px;flex:1;display:flex;flex-direction:column;min-height:100vh;position:relative}
.content-frame{flex:1;border:none;width:100%;height:100%}
/* hide topbar since each page has its own */
.page-topbar{padding:0;margin:0;display:none}
/* Professional sidebar tone: keep subtle markers instead of emoji icons */
#mainSidebar .brand-icon{
font-size:.78em;
font-weight:700;
letter-spacing:.08em;
}
#mainSidebar .nav-item{gap:10px}
#mainSidebar .nav-icon{
position:relative;
width:8px;
min-width:8px;
font-size:0;
line-height:0;
color:transparent;
}
#mainSidebar .nav-icon::before{
content:'';
display:block;
width:6px;
height:6px;
border-radius:999px;
background:#CFC6B9;
transition:background .2s ease, transform .2s ease;
}
#mainSidebar .nav-item:hover .nav-icon::before{background:#A99E91}
#mainSidebar .nav-item.active .nav-icon::before{background:var(--gold);transform:scale(1.12)}
@media(max-width:1024px){
.main{margin-left:64px}
}
.sidebar-scroll { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; padding-bottom: 20px; }
.sidebar-scroll::-webkit-scrollbar { width: 4px; background: transparent; }
.sidebar-scroll::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.1); border-radius: 4px; }
.sidebar-scroll:hover::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.2); }
/* Settings Modal (Memos-style) */
.settings-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.25); z-index:1000; display:none; justify-content:center; align-items:center; backdrop-filter:blur(2px); }
.settings-overlay.open { display:flex; }
.settings-dialog { background:var(--card,#fff); border:1px solid var(--border,#e7e5e2); border-radius:12px; box-shadow:var(--shadow-lg, 0 4px 12px rgba(0,0,0,0.08)); width:680px; max-width:92vw; height:520px; max-height:85vh; display:flex; flex-direction:column; overflow:hidden; }
.settings-body { display:flex; flex:1; min-height:0; }
.settings-sidebar { width:180px; border-right:1px solid var(--border,#e7e5e2); display:flex; flex-direction:column; padding:16px 12px; gap:2px; background:var(--card,#fff); }
.settings-sidebar .group-label { font-size:11px; font-weight:600; color:var(--muted-fg,#78716c); padding:4px 12px; text-transform:uppercase; letter-spacing:0.05em; }
.settings-sidebar .group-label.admin { margin-top:16px; }
.settings-content { flex:1; display:flex; flex-direction:column; min-width:0; }
.settings-scroll { flex:1; overflow-y:auto; padding:20px 24px; }
.settings-footer { padding:12px 24px; border-top:1px solid var(--border,#e7e5e2); display:flex; justify-content:flex-end; gap:8px; background:var(--card,#fff); }
.settings-user-block { border-top:1px solid var(--border,#e7e5e2); padding-top:12px; margin-top:auto; }
.settings-user-row { display:flex; align-items:center; gap:8px; padding:4px 8px; }
.settings-user-avatar { width:28px; height:28px; border-radius:50%; background:var(--muted,#f0efec); display:flex; align-items:center; justify-content:center; font-size:12px; font-weight:600; color:var(--muted-fg,#78716c); flex-shrink:0; }
.settings-version { display:flex; align-items:center; gap:6px; padding:6px 8px 0; font-size:11px; color:var(--muted-fg,#78716c); }
.settings-version-dot { width:6px; height:6px; border-radius:50%; background:var(--success,#16a34a); flex-shrink:0; }
</style>
</head>
<body>
<!-- ═══ SIDEBAR (single source of truth) ═══ -->
<aside class="sidebar" id="mainSidebar">
<div class="sidebar-brand">
<div class="brand-icon">CA</div>
<div class="brand-text">
<h2>Canifa AI</h2>
<span>Admin Console</span>
</div>
</div>
<div class="sidebar-scroll">
<div class="nav-group">
<div class="nav-group-label">Main</div>
<a data-page="roadmap.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">RM</span>
<span>Kế hoạch phát triển</span>
</a>
<a data-page="flow.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>Sơ đồ hoạt động</span>
</a>
<a data-page="experiment_detail.html?id=exp_chatbot_prod" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>Chatbot</span>
<span class="nav-badge badge-live">LIVE</span>
</a>
<a data-page="history.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>History</span>
</a>
<a data-page="product.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>Product Perf.</span>
</a>
<a data-page="ai-report.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>AI Data Analyst</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="ai-sql.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:11px;font-weight:800;letter-spacing:.04em">SQL</span>
<span>AI sinh SQL</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="live-monitor.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.08em">LIVE</span>
<span>Realtime Monitor</span>
<span class="nav-badge badge-live">LIVE</span>
</a>
<a data-page="prompt-optimizer.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">PO</span>
<span>Prompt Optimizer</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="user-simulator.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">US</span>
<span>User Simulator</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="user-insight.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">UI</span>
<span>User Insight</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="regression-test.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">RT</span>
<span>Regression Test</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="stress-test.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">ST</span>
<span>Stress Test</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="competitor-research.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">CR</span>
<span>Competitor Research</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group-label">Workspace</div>
<a data-page="resources.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>Resources</span>
</a>
<a data-page="notes.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>Team Notes</span>
</a>
<a data-page="changelog.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>Changelog</span>
</a>
<a data-page="guide.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>Hướng dẫn</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group-label">Thử nghiệm</div>
<a data-page="test_sql.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>Text-to-SQL</span>
<span class="nav-badge badge-beta">BETA</span>
</a>
<a data-page="test_db.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>DB Test</span>
<span class="nav-badge badge-beta">BETA</span>
</a>
<a data-page="feedback_demo.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>Feedback Demo</span>
<span class="nav-badge badge-new">NEW</span>
</a>
<a data-page="http://172.16.2.210:5006/static/index.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>Chatbot (Dev)</span>
<span class="nav-badge badge-beta">DEV</span>
</a>
<a data-page="cache.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>Cache Manager</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group-label">External</div>
<a href="/docs" target="_blank" class="nav-item">
<span class="nav-icon"></span>
<span>API Docs</span>
</a>
<a href="/redoc" target="_blank" class="nav-item">
<span class="nav-icon"></span>
<span>ReDoc</span>
</a>
</div>
</div>
<div class="sidebar-footer">
<div class="settings-user-block">
<div class="settings-user-row">
<div class="settings-user-avatar" id="userAvatar">A</div>
<span style="flex:1; font-size:13px; font-weight:500; color:var(--foreground,#1c1917);" id="userName">admin</span>
<button class="btn-icon" style="padding:4px;font-size:13px;" onclick="openSettingsModal()" title="Settings"></button>
<button class="btn-icon" style="padding:4px;font-size:13px;" onclick="handleLogout()" title="Đăng xuất"></button>
</div>
<div class="settings-version">
<span class="settings-version-dot"></span>
<strong>v2.5.0</strong> · Online
</div>
</div>
</div>
</aside>
<!-- ═══ SETTINGS MODAL (Memos-style: sidebar + content) ═══ -->
<div id="llmSettingsModal" class="settings-overlay" onclick="if(event.target===this)closeSettingsModal()">
<div class="settings-dialog">
<div class="settings-body">
<!-- LEFT SIDEBAR -->
<div class="settings-sidebar">
<span class="group-label">Basic</span>
<div class="section-menu-item active" onclick="switchMainTab('account')" data-tab="account">
<i class="fas fa-user icon" style="font-size:12px;width:16px;text-align:center;"></i> <span>My Account</span>
</div>
<div class="section-menu-item" onclick="switchMainTab('llm')" data-tab="llm">
<i class="fas fa-key icon" style="font-size:12px;width:16px;text-align:center;"></i> <span>LLM Keys</span>
</div>
<div class="section-menu-item" onclick="switchMainTab('connection')" data-tab="connection">
<i class="fas fa-database icon" style="font-size:12px;width:16px;text-align:center;"></i> <span>Kết nối</span>
</div>
<div class="section-menu-item" onclick="switchMainTab('display')" data-tab="display">
<i class="fas fa-palette icon" style="font-size:12px;width:16px;text-align:center;"></i> <span>Giao diện</span>
</div>
<span class="group-label admin">Admin</span>
<div class="section-menu-item" onclick="switchMainTab('advanced')" data-tab="advanced">
<i class="fas fa-cog icon" style="font-size:12px;width:16px;text-align:center;"></i> <span>Nâng cao</span>
</div>
<!-- Spacer -->
<div style="flex:1;"></div>
<!-- User row at bottom of settings sidebar -->
<div class="settings-user-block">
<div class="settings-user-row">
<div class="settings-user-avatar" id="settingsAvatar">A</div>
<span style="flex:1; font-size:13px; font-weight:500; color:var(--foreground,#1c1917);" id="settingsUserName">admin</span>
<button class="btn-icon" style="padding:4px;" title="Copy ID" onclick="navigator.clipboard.writeText(localStorage.getItem('canifa_user')||'')"><i style="font-size:11px;" class="fas fa-copy"></i></button>
</div>
<div class="settings-version">
<span class="settings-version-dot"></span>
<strong>v2.5.0</strong> · Online
</div>
</div>
</div>
<!-- RIGHT CONTENT -->
<div class="settings-content">
<div class="settings-scroll">
<!-- TAB: Account -->
<div id="mainTab_account" class="setting-tab-content">
<div class="setting-section">
<div class="setting-section-header">
<h3>My Account</h3>
<p class="desc">Thông tin tài khoản cá nhân</p>
</div>
<div class="setting-section-body">
<div class="setting-row">
<div class="setting-label">
<span class="setting-label-text">Username</span>
</div>
<div class="setting-control">
<span id="settingUsername" style="font-size:13px; font-weight:600; color:var(--foreground);"></span>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<span class="setting-label-text">Role</span>
</div>
<div class="setting-control">
<span class="badge badge-info" id="settingRole">user</span>
</div>
</div>
</div>
</div>
</div>
<!-- TAB: LLM Keys -->
<div id="mainTab_llm" class="setting-tab-content" style="display:none;">
<div class="setting-section">
<div class="setting-section-header">
<h3>LLM API Keys</h3>
<p class="desc">Cấu hình API keys cho các mô hình AI</p>
</div>
<div class="setting-section-body">
<div class="setting-row vertical">
<div class="setting-label">
<span class="setting-label-text">Codex Auth Token</span>
<p class="setting-label-desc">Token xác thực cho Codex API</p>
</div>
<div class="setting-control" style="width:100%;">
<input class="input" type="password" id="inputCodexToken" placeholder="eyJhbGciOiJIUzI1NiIs..." style="width:100%; font-family:var(--font-mono,monospace);">
</div>
</div>
<div class="setting-row vertical">
<div class="setting-label">
<span class="setting-label-text">OpenAI API Key</span>
<p class="setting-label-desc">Tuỳ chọn — dùng cho GPT-4o, GPT-5.4</p>
</div>
<div class="setting-control" style="width:100%;">
<input class="input" type="password" id="inputOpenAiKey" placeholder="sk-..." style="width:100%; font-family:var(--font-mono,monospace);">
</div>
</div>
</div>
</div>
</div>
<!-- TAB: Connection -->
<div id="mainTab_connection" class="setting-tab-content" style="display:none;">
<div class="setting-section">
<div class="setting-section-header">
<h3>Kết nối</h3>
<p class="desc">Cấu hình endpoint và database</p>
</div>
<div class="setting-section-body">
<div class="setting-row">
<div class="setting-label">
<span class="setting-label-text">Backend API</span>
<p class="setting-label-desc">URL backend server</p>
</div>
<div class="setting-control">
<span style="font-size:12px; font-family:var(--font-mono,monospace); color:var(--muted-fg);" id="settingBackendUrl"></span>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<span class="setting-label-text">Trạng thái</span>
<p class="setting-label-desc">Kết nối tới backend</p>
</div>
<div class="setting-control">
<span class="badge badge-success" id="settingConnStatus">Connected</span>
</div>
</div>
<hr class="separator">
<div class="setting-row">
<div class="setting-label">
<span class="setting-label-text">Langfuse</span>
<p class="setting-label-desc">Observability & tracing</p>
</div>
<div class="setting-control">
<a href="http://172.16.2.210:3100" target="_blank" class="btn btn-outline btn-sm"><i class="fas fa-external-link-alt" style="margin-right:4px;"></i> Open</a>
</div>
</div>
</div>
</div>
</div>
<!-- TAB: Display -->
<div id="mainTab_display" class="setting-tab-content" style="display:none;">
<div class="setting-section">
<div class="setting-section-header">
<h3>Giao diện</h3>
<p class="desc">Tùy chỉnh theme và hiển thị</p>
</div>
<div class="setting-section-body">
<div class="setting-row">
<div class="setting-label">
<span class="setting-label-text">Theme</span>
<p class="setting-label-desc">Giao diện sáng/tối</p>
</div>
<div class="setting-control">
<select class="select" disabled><option>Light (Memos)</option></select>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<span class="setting-label-text">Sidebar mặc định</span>
<p class="setting-label-desc">Mở rộng hoặc thu gọn</p>
</div>
<div class="setting-control">
<select class="select"><option>Expanded</option><option>Collapsed</option></select>
</div>
</div>
</div>
</div>
</div>
<!-- TAB: Advanced -->
<div id="mainTab_advanced" class="setting-tab-content" style="display:none;">
<div class="setting-section">
<div class="setting-section-header">
<h3>Nâng cao</h3>
<p class="desc">Cấu hình admin</p>
</div>
<div class="setting-section-body">
<div class="setting-row">
<div class="setting-label">
<span class="setting-label-text">Clear Cache</span>
<p class="setting-label-desc">Xóa cache sản phẩm & embedding</p>
</div>
<div class="setting-control">
<button class="btn btn-outline btn-sm" onclick="fetch('/api/cache/clear',{method:'POST',headers:{'Authorization':'Bearer '+localStorage.getItem('canifa_token')}}).then(()=>alert('Cache cleared!'))"><i class="fas fa-broom" style="margin-right:4px;"></i> Clear</button>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<span class="setting-label-text">Health Check</span>
<p class="setting-label-desc">Kiểm tra trạng thái hệ thống</p>
</div>
<div class="setting-control">
<button class="btn btn-outline btn-sm" onclick="fetch('/health').then(r=>r.json()).then(d=>alert(JSON.stringify(d,null,2)))"><i class="fas fa-heartbeat" style="margin-right:4px;"></i> Check</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="settings-footer">
<button class="btn btn-outline" onclick="closeSettingsModal()">Hủy</button>
<button class="btn btn-primary" onclick="saveSettingsModal()"><i class="fas fa-check" style="margin-right:4px;"></i> Lưu</button>
</div>
</div>
</div>
</div>
</div>
<!-- ═══ MAIN CONTENT ═══ -->
<div class="main">
<iframe id="contentFrame" class="content-frame" src="/static/product.html"></iframe>
</div>
<script>
// ═══ AUTH GUARD ═══
(function authGuard() {
const token = localStorage.getItem('canifa_token');
if (!token) {
window.location.replace('/static/login.html?redirect=' + encodeURIComponent(window.location.href));
return;
}
// Admin only sees admin.html
const _u = JSON.parse(localStorage.getItem('canifa_user') || '{}');
if (_u.role === 'admin') {
window.location.replace('/static/admin.html');
return;
}
// Show user info in sidebar
try {
const user = JSON.parse(localStorage.getItem('canifa_user') || '{}');
const nameEl = document.getElementById('userName');
const avatarEl = document.getElementById('userAvatar');
if (nameEl && user.username) nameEl.textContent = user.username;
if (avatarEl && user.username) avatarEl.textContent = user.username.charAt(0).toUpperCase();
// Show Admin Console link for admin users
if (user.role === 'admin') {
const extGroup = document.querySelector('#mainSidebar .nav-group:last-of-type .nav-group-label');
if (extGroup) {
const adminLink = document.createElement('a');
adminLink.href = '/static/admin.html';
adminLink.className = 'nav-item';
adminLink.innerHTML = '<span class="nav-icon" style="font-size:10px;font-weight:800">⚙</span><span>Admin Console</span><span class="nav-badge badge-live">ADMIN</span>';
extGroup.after(adminLink);
}
}
} catch(e) {}
})();
function handleLogout() {
localStorage.removeItem('canifa_token');
localStorage.removeItem('canifa_user');
window.location.replace('/static/login.html');
}
// ═══ SETTINGS MODAL LOGIC ═══
function switchMainTab(tabName) {
document.querySelectorAll('#llmSettingsModal .setting-tab-content').forEach(el => el.style.display = 'none');
const tab = document.getElementById('mainTab_' + tabName);
if (tab) tab.style.display = '';
document.querySelectorAll('#llmSettingsModal .section-menu-item').forEach(el => {
el.classList.toggle('active', el.dataset.tab === tabName);
});
}
async function loadUserSettings() {
const token = localStorage.getItem('canifa_token');
if (!token) return;
try {
const res = await fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token }});
if (res.ok) {
const data = await res.json();
const user = data.user || {};
const settings = user.settings || {};
document.getElementById('inputCodexToken').value = settings.codex_token || '';
document.getElementById('inputOpenAiKey').value = settings.openai_key || '';
localStorage.setItem('canifa_user', JSON.stringify(user));
}
} catch(e) { console.error('Failed to load settings', e); }
}
function openSettingsModal() {
const modal = document.getElementById('llmSettingsModal');
// Populate user info
try {
const user = JSON.parse(localStorage.getItem('canifa_user') || '{}');
const name = user.username || 'user';
document.getElementById('settingsUserName').textContent = name;
document.getElementById('settingsAvatar').textContent = name.charAt(0).toUpperCase();
document.getElementById('settingUsername').textContent = name;
document.getElementById('settingRole').textContent = user.role || 'user';
document.getElementById('settingBackendUrl').textContent = window.location.origin;
} catch(e) {}
// Reset to first tab
switchMainTab('account');
modal.classList.add('open');
loadUserSettings();
}
function closeSettingsModal() {
document.getElementById('llmSettingsModal').classList.remove('open');
}
async function saveSettingsModal() {
const btn = document.querySelector('.settings-footer .btn-primary');
const ogHTML = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-spinner fa-spin" style="margin-right:4px;"></i> Đang lưu...';
btn.disabled = true;
const codex = document.getElementById('inputCodexToken').value.trim();
const oai = document.getElementById('inputOpenAiKey').value.trim();
const settings = {};
if (codex) settings.codex_token = codex;
if (oai) settings.openai_key = oai;
try {
const token = localStorage.getItem('canifa_token');
const res = await fetch('/api/auth/me/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ settings: settings })
});
if (res.ok) {
closeSettingsModal();
} else {
alert('Lỗi lưu cài đặt');
}
} catch(e) {
alert('Lỗi hệ thống');
} finally {
btn.innerHTML = ogHTML;
btn.disabled = false;
}
}
// ═══ NAVIGATION ═══
function navigateTo(el) {
const page = el.getAttribute('data-page');
if (!page) return;
const src = page.startsWith('http') ? page : '/static/' + page + (page.includes('?') ? '&' : '?') + 't=' + Date.now();
document.getElementById('contentFrame').src = src;
// Update active state
document.querySelectorAll('#mainSidebar .nav-item').forEach(n => n.classList.remove('active'));
el.classList.add('active');
// Update URL without reload
const pageName = page.split('?')[0].replace('.html','');
history.pushState({page}, '', '/static/main.html?page=' + page);
// Update title
document.title = (el.querySelector('span:nth-child(2)')?.textContent || 'Canifa AI') + ' — Canifa AI';
}
// ═══ INIT: Load page from URL param ═══
(function() {
const params = new URLSearchParams(window.location.search);
const page = params.get('page');
if (page) {
const src = page.startsWith('http') ? page : '/static/' + page + (page.includes('?') ? '&' : '?') + 't=' + Date.now();
document.getElementById('contentFrame').src = src;
// Highlight active nav
document.querySelectorAll('#mainSidebar .nav-item[data-page]').forEach(el => {
if (el.getAttribute('data-page') === page) {
el.classList.add('active');
} else {
el.classList.remove('active');
}
});
} else {
// Default: dashboard active
const dashLink = document.querySelector('[data-page="product.html"]');
if (dashLink) dashLink.classList.add('active');
}
})();
// ═══ HANDLE BACK/FORWARD ═══
window.addEventListener('popstate', function(e) {
if (e.state && e.state.page) {
const src = e.state.page.startsWith('http') ? e.state.page : '/static/' + e.state.page + (e.state.page.includes('?') ? '&' : '?') + 't=' + Date.now();
document.getElementById('contentFrame').src = src;
document.querySelectorAll('#mainSidebar .nav-item[data-page]').forEach(el => {
el.classList.toggle('active', el.getAttribute('data-page') === e.state.page);
});
}
});
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canifa AI Platform</title>
<link rel="stylesheet" href="/static/css/theme.css">
<link rel="stylesheet" href="/static/css/components.css">
<link rel="stylesheet" href="/static/css/lab.css">
<link rel="stylesheet" href="/static/css/dashboard.css?v=4">
<style>
/* ═══ MAIN LAYOUT OVERRIDES ═══ */
body{margin:0;display:flex;min-height:100vh}
.main{margin-left:240px;flex:1;display:flex;flex-direction:column;min-height:100vh;position:relative}
.content-frame{flex:1;border:none;width:100%;height:100%}
/* hide topbar since each page has its own */
.page-topbar{padding:0;margin:0;display:none}
/* Professional sidebar tone: keep subtle markers instead of emoji icons */
#mainSidebar .brand-icon{
font-size:.78em;
font-weight:700;
letter-spacing:.08em;
}
#mainSidebar .nav-item{gap:10px}
#mainSidebar .nav-icon{
position:relative;
width:8px;
min-width:8px;
font-size:0;
line-height:0;
color:transparent;
}
#mainSidebar .nav-icon::before{
content:'';
display:block;
width:6px;
height:6px;
border-radius:999px;
background:#CFC6B9;
transition:background .2s ease, transform .2s ease;
}
#mainSidebar .nav-item:hover .nav-icon::before{background:#A99E91}
#mainSidebar .nav-item.active .nav-icon::before{background:var(--gold);transform:scale(1.12)}
@media(max-width:1024px){
.main{margin-left:64px}
}
.sidebar-scroll { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; padding-bottom: 20px; }
.sidebar-scroll::-webkit-scrollbar { width: 4px; background: transparent; }
.sidebar-scroll::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.1); border-radius: 4px; }
.sidebar-scroll:hover::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.2); }
/* Settings Modal (Memos-style) */
.settings-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.25); z-index:1000; display:none; justify-content:center; align-items:center; backdrop-filter:blur(2px); }
.settings-overlay.open { display:flex; }
.settings-dialog { background:var(--card,#fff); border:1px solid var(--border,#e7e5e2); border-radius:12px; box-shadow:var(--shadow-lg, 0 4px 12px rgba(0,0,0,0.08)); width:680px; max-width:92vw; height:520px; max-height:85vh; display:flex; flex-direction:column; overflow:hidden; }
.settings-body { display:flex; flex:1; min-height:0; }
.settings-sidebar { width:180px; border-right:1px solid var(--border,#e7e5e2); display:flex; flex-direction:column; padding:16px 12px; gap:2px; background:var(--card,#fff); }
.settings-sidebar .group-label { font-size:11px; font-weight:600; color:var(--muted-fg,#78716c); padding:4px 12px; text-transform:uppercase; letter-spacing:0.05em; }
.settings-sidebar .group-label.admin { margin-top:16px; }
.settings-content { flex:1; display:flex; flex-direction:column; min-width:0; }
.settings-scroll { flex:1; overflow-y:auto; padding:20px 24px; }
.settings-footer { padding:12px 24px; border-top:1px solid var(--border,#e7e5e2); display:flex; justify-content:flex-end; gap:8px; background:var(--card,#fff); }
.settings-user-block { border-top:1px solid var(--border,#e7e5e2); padding-top:12px; margin-top:auto; }
.settings-user-row { display:flex; align-items:center; gap:8px; padding:4px 8px; }
.settings-user-avatar { width:28px; height:28px; border-radius:50%; background:var(--muted,#f0efec); display:flex; align-items:center; justify-content:center; font-size:12px; font-weight:600; color:var(--muted-fg,#78716c); flex-shrink:0; }
.settings-version { display:flex; align-items:center; gap:6px; padding:6px 8px 0; font-size:11px; color:var(--muted-fg,#78716c); }
.settings-version-dot { width:6px; height:6px; border-radius:50%; background:var(--success,#16a34a); flex-shrink:0; }
</style>
</head>
<body>
<!-- ═══ SIDEBAR (single source of truth) ═══ -->
<aside class="sidebar" id="mainSidebar">
<div class="sidebar-brand">
<div class="brand-icon">CA</div>
<div class="brand-text">
<h2>Canifa AI</h2>
<span>Admin Console</span>
</div>
</div>
<div class="sidebar-scroll">
<div class="nav-group">
<div class="nav-group-label">Main</div>
<a data-page="roadmap.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">RM</span>
<span>Kế hoạch phát triển</span>
</a>
<a data-page="flow.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>Sơ đồ hoạt động</span>
</a>
<a data-page="experiment_detail.html?id=exp_chatbot_prod" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>Chatbot</span>
<span class="nav-badge badge-live">LIVE</span>
</a>
<a data-page="history.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>History</span>
</a>
<a data-page="product.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>Product Perf.</span>
</a>
<a data-page="product-desc.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">UD</span>
<span>Ultra Description</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="ai-report.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>AI Data Analyst</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="ai-sql.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:11px;font-weight:800;letter-spacing:.04em">SQL</span>
<span>AI sinh SQL</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="live-monitor.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.08em">LIVE</span>
<span>Realtime Monitor</span>
<span class="nav-badge badge-live">LIVE</span>
</a>
<a data-page="prompt-optimizer.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">PO</span>
<span>Prompt Optimizer</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="user-simulator.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">US</span>
<span>User Simulator</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="user-insight.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">UI</span>
<span>User Insight</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="regression-test.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">RT</span>
<span>Regression Test</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="stress-test.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">ST</span>
<span>Stress Test</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="competitor-research.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">CR</span>
<span>Competitor Research</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group-label">Workspace</div>
<a data-page="resources.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>Resources</span>
</a>
<a data-page="notes.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>Team Notes</span>
</a>
<a data-page="changelog.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>Changelog</span>
</a>
<a data-page="guide.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>Hướng dẫn</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group-label">Thử nghiệm</div>
<a data-page="test_sql.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>Text-to-SQL</span>
<span class="nav-badge badge-beta">BETA</span>
</a>
<a data-page="test_db.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>DB Test</span>
<span class="nav-badge badge-beta">BETA</span>
</a>
<a data-page="feedback_demo.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>Feedback Demo</span>
<span class="nav-badge badge-new">NEW</span>
</a>
<a data-page="http://172.16.2.210:5006/static/index.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>Chatbot (Dev)</span>
<span class="nav-badge badge-beta">DEV</span>
</a>
<a data-page="cache.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<span>Cache Manager</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group-label">External</div>
<a href="/docs" target="_blank" class="nav-item">
<span class="nav-icon"></span>
<span>API Docs</span>
</a>
<a href="/redoc" target="_blank" class="nav-item">
<span class="nav-icon"></span>
<span>ReDoc</span>
</a>
</div>
</div>
<div class="sidebar-footer">
<div class="settings-user-block">
<div class="settings-user-row">
<div class="settings-user-avatar" id="userAvatar">A</div>
<span style="flex:1; font-size:13px; font-weight:500; color:var(--foreground,#1c1917);" id="userName">admin</span>
<button class="btn-icon" style="padding:4px;font-size:13px;" onclick="openSettingsModal()" title="Settings"></button>
<button class="btn-icon" style="padding:4px;font-size:13px;" onclick="handleLogout()" title="Đăng xuất"></button>
</div>
<div class="settings-version">
<span class="settings-version-dot"></span>
<strong>v2.5.0</strong> · Online
</div>
</div>
</div>
</aside>
<!-- ═══ SETTINGS MODAL (Memos-style: sidebar + content) ═══ -->
<div id="llmSettingsModal" class="settings-overlay" onclick="if(event.target===this)closeSettingsModal()">
<div class="settings-dialog">
<div class="settings-body">
<!-- LEFT SIDEBAR -->
<div class="settings-sidebar">
<span class="group-label">Basic</span>
<div class="section-menu-item active" onclick="switchMainTab('account')" data-tab="account">
<i class="fas fa-user icon" style="font-size:12px;width:16px;text-align:center;"></i> <span>My Account</span>
</div>
<div class="section-menu-item" onclick="switchMainTab('llm')" data-tab="llm">
<i class="fas fa-key icon" style="font-size:12px;width:16px;text-align:center;"></i> <span>LLM Keys</span>
</div>
<div class="section-menu-item" onclick="switchMainTab('connection')" data-tab="connection">
<i class="fas fa-database icon" style="font-size:12px;width:16px;text-align:center;"></i> <span>Kết nối</span>
</div>
<div class="section-menu-item" onclick="switchMainTab('display')" data-tab="display">
<i class="fas fa-palette icon" style="font-size:12px;width:16px;text-align:center;"></i> <span>Giao diện</span>
</div>
<span class="group-label admin">Admin</span>
<div class="section-menu-item" onclick="switchMainTab('advanced')" data-tab="advanced">
<i class="fas fa-cog icon" style="font-size:12px;width:16px;text-align:center;"></i> <span>Nâng cao</span>
</div>
<!-- Spacer -->
<div style="flex:1;"></div>
<!-- User row at bottom of settings sidebar -->
<div class="settings-user-block">
<div class="settings-user-row">
<div class="settings-user-avatar" id="settingsAvatar">A</div>
<span style="flex:1; font-size:13px; font-weight:500; color:var(--foreground,#1c1917);" id="settingsUserName">admin</span>
<button class="btn-icon" style="padding:4px;" title="Copy ID" onclick="navigator.clipboard.writeText(localStorage.getItem('canifa_user')||'')"><i style="font-size:11px;" class="fas fa-copy"></i></button>
</div>
<div class="settings-version">
<span class="settings-version-dot"></span>
<strong>v2.5.0</strong> · Online
</div>
</div>
</div>
<!-- RIGHT CONTENT -->
<div class="settings-content">
<div class="settings-scroll">
<!-- TAB: Account -->
<div id="mainTab_account" class="setting-tab-content">
<div class="setting-section">
<div class="setting-section-header">
<h3>My Account</h3>
<p class="desc">Thông tin tài khoản cá nhân</p>
</div>
<div class="setting-section-body">
<div class="setting-row">
<div class="setting-label">
<span class="setting-label-text">Username</span>
</div>
<div class="setting-control">
<span id="settingUsername" style="font-size:13px; font-weight:600; color:var(--foreground);"></span>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<span class="setting-label-text">Role</span>
</div>
<div class="setting-control">
<span class="badge badge-info" id="settingRole">user</span>
</div>
</div>
</div>
</div>
</div>
<!-- TAB: LLM Keys -->
<div id="mainTab_llm" class="setting-tab-content" style="display:none;">
<div class="setting-section">
<div class="setting-section-header">
<h3>LLM API Keys</h3>
<p class="desc">Cấu hình API keys cho các mô hình AI</p>
</div>
<div class="setting-section-body">
<div class="setting-row vertical">
<div class="setting-label">
<span class="setting-label-text">Codex Auth Token</span>
<p class="setting-label-desc">Token xác thực cho Codex API</p>
</div>
<div class="setting-control" style="width:100%;">
<input class="input" type="password" id="inputCodexToken" placeholder="eyJhbGciOiJIUzI1NiIs..." style="width:100%; font-family:var(--font-mono,monospace);">
</div>
</div>
<div class="setting-row vertical">
<div class="setting-label">
<span class="setting-label-text">OpenAI API Key</span>
<p class="setting-label-desc">Tuỳ chọn — dùng cho GPT-4o, GPT-5.4</p>
</div>
<div class="setting-control" style="width:100%;">
<input class="input" type="password" id="inputOpenAiKey" placeholder="sk-..." style="width:100%; font-family:var(--font-mono,monospace);">
</div>
</div>
</div>
</div>
</div>
<!-- TAB: Connection -->
<div id="mainTab_connection" class="setting-tab-content" style="display:none;">
<div class="setting-section">
<div class="setting-section-header">
<h3>Kết nối</h3>
<p class="desc">Cấu hình endpoint và database</p>
</div>
<div class="setting-section-body">
<div class="setting-row">
<div class="setting-label">
<span class="setting-label-text">Backend API</span>
<p class="setting-label-desc">URL backend server</p>
</div>
<div class="setting-control">
<span style="font-size:12px; font-family:var(--font-mono,monospace); color:var(--muted-fg);" id="settingBackendUrl"></span>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<span class="setting-label-text">Trạng thái</span>
<p class="setting-label-desc">Kết nối tới backend</p>
</div>
<div class="setting-control">
<span class="badge badge-success" id="settingConnStatus">Connected</span>
</div>
</div>
<hr class="separator">
<div class="setting-row">
<div class="setting-label">
<span class="setting-label-text">Langfuse</span>
<p class="setting-label-desc">Observability & tracing</p>
</div>
<div class="setting-control">
<a href="http://172.16.2.210:3100" target="_blank" class="btn btn-outline btn-sm"><i class="fas fa-external-link-alt" style="margin-right:4px;"></i> Open</a>
</div>
</div>
</div>
</div>
</div>
<!-- TAB: Display -->
<div id="mainTab_display" class="setting-tab-content" style="display:none;">
<div class="setting-section">
<div class="setting-section-header">
<h3>Giao diện</h3>
<p class="desc">Tùy chỉnh theme và hiển thị</p>
</div>
<div class="setting-section-body">
<div class="setting-row">
<div class="setting-label">
<span class="setting-label-text">Theme</span>
<p class="setting-label-desc">Giao diện sáng/tối</p>
</div>
<div class="setting-control">
<select class="select" disabled><option>Light (Memos)</option></select>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<span class="setting-label-text">Sidebar mặc định</span>
<p class="setting-label-desc">Mở rộng hoặc thu gọn</p>
</div>
<div class="setting-control">
<select class="select"><option>Expanded</option><option>Collapsed</option></select>
</div>
</div>
</div>
</div>
</div>
<!-- TAB: Advanced -->
<div id="mainTab_advanced" class="setting-tab-content" style="display:none;">
<div class="setting-section">
<div class="setting-section-header">
<h3>Nâng cao</h3>
<p class="desc">Cấu hình admin</p>
</div>
<div class="setting-section-body">
<div class="setting-row">
<div class="setting-label">
<span class="setting-label-text">Clear Cache</span>
<p class="setting-label-desc">Xóa cache sản phẩm & embedding</p>
</div>
<div class="setting-control">
<button class="btn btn-outline btn-sm" onclick="fetch('/api/cache/clear',{method:'POST',headers:{'Authorization':'Bearer '+localStorage.getItem('canifa_token')}}).then(()=>alert('Cache cleared!'))"><i class="fas fa-broom" style="margin-right:4px;"></i> Clear</button>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<span class="setting-label-text">Health Check</span>
<p class="setting-label-desc">Kiểm tra trạng thái hệ thống</p>
</div>
<div class="setting-control">
<button class="btn btn-outline btn-sm" onclick="fetch('/health').then(r=>r.json()).then(d=>alert(JSON.stringify(d,null,2)))"><i class="fas fa-heartbeat" style="margin-right:4px;"></i> Check</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="settings-footer">
<button class="btn btn-outline" onclick="closeSettingsModal()">Hủy</button>
<button class="btn btn-primary" onclick="saveSettingsModal()"><i class="fas fa-check" style="margin-right:4px;"></i> Lưu</button>
</div>
</div>
</div>
</div>
</div>
<!-- ═══ MAIN CONTENT ═══ -->
<div class="main">
<iframe id="contentFrame" class="content-frame" src="/static/product.html"></iframe>
</div>
<script>
// ═══ AUTH GUARD ═══
(function authGuard() {
const token = localStorage.getItem('canifa_token');
if (!token) {
window.location.replace('/static/login.html?redirect=' + encodeURIComponent(window.location.href));
return;
}
// Admin only sees admin.html
const _u = JSON.parse(localStorage.getItem('canifa_user') || '{}');
if (_u.role === 'admin') {
window.location.replace('/static/admin.html');
return;
}
// Show user info in sidebar
try {
const user = JSON.parse(localStorage.getItem('canifa_user') || '{}');
const nameEl = document.getElementById('userName');
const avatarEl = document.getElementById('userAvatar');
if (nameEl && user.username) nameEl.textContent = user.username;
if (avatarEl && user.username) avatarEl.textContent = user.username.charAt(0).toUpperCase();
// Show Admin Console link for admin users
if (user.role === 'admin') {
const extGroup = document.querySelector('#mainSidebar .nav-group:last-of-type .nav-group-label');
if (extGroup) {
const adminLink = document.createElement('a');
adminLink.href = '/static/admin.html';
adminLink.className = 'nav-item';
adminLink.innerHTML = '<span class="nav-icon" style="font-size:10px;font-weight:800">⚙</span><span>Admin Console</span><span class="nav-badge badge-live">ADMIN</span>';
extGroup.after(adminLink);
}
}
} catch(e) {}
})();
function handleLogout() {
localStorage.removeItem('canifa_token');
localStorage.removeItem('canifa_user');
window.location.replace('/static/login.html');
}
// ═══ SETTINGS MODAL LOGIC ═══
function switchMainTab(tabName) {
document.querySelectorAll('#llmSettingsModal .setting-tab-content').forEach(el => el.style.display = 'none');
const tab = document.getElementById('mainTab_' + tabName);
if (tab) tab.style.display = '';
document.querySelectorAll('#llmSettingsModal .section-menu-item').forEach(el => {
el.classList.toggle('active', el.dataset.tab === tabName);
});
}
async function loadUserSettings() {
const token = localStorage.getItem('canifa_token');
if (!token) return;
try {
const res = await fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token }});
if (res.ok) {
const data = await res.json();
const user = data.user || {};
const settings = user.settings || {};
document.getElementById('inputCodexToken').value = settings.codex_token || '';
document.getElementById('inputOpenAiKey').value = settings.openai_key || '';
localStorage.setItem('canifa_user', JSON.stringify(user));
}
} catch(e) { console.error('Failed to load settings', e); }
}
function openSettingsModal() {
const modal = document.getElementById('llmSettingsModal');
// Populate user info
try {
const user = JSON.parse(localStorage.getItem('canifa_user') || '{}');
const name = user.username || 'user';
document.getElementById('settingsUserName').textContent = name;
document.getElementById('settingsAvatar').textContent = name.charAt(0).toUpperCase();
document.getElementById('settingUsername').textContent = name;
document.getElementById('settingRole').textContent = user.role || 'user';
document.getElementById('settingBackendUrl').textContent = window.location.origin;
} catch(e) {}
// Reset to first tab
switchMainTab('account');
modal.classList.add('open');
loadUserSettings();
}
function closeSettingsModal() {
document.getElementById('llmSettingsModal').classList.remove('open');
}
async function saveSettingsModal() {
const btn = document.querySelector('.settings-footer .btn-primary');
const ogHTML = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-spinner fa-spin" style="margin-right:4px;"></i> Đang lưu...';
btn.disabled = true;
const codex = document.getElementById('inputCodexToken').value.trim();
const oai = document.getElementById('inputOpenAiKey').value.trim();
const settings = {};
if (codex) settings.codex_token = codex;
if (oai) settings.openai_key = oai;
try {
const token = localStorage.getItem('canifa_token');
const res = await fetch('/api/auth/me/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ settings: settings })
});
if (res.ok) {
closeSettingsModal();
} else {
alert('Lỗi lưu cài đặt');
}
} catch(e) {
alert('Lỗi hệ thống');
} finally {
btn.innerHTML = ogHTML;
btn.disabled = false;
}
}
// ═══ NAVIGATION ═══
function navigateTo(el) {
const page = el.getAttribute('data-page');
if (!page) return;
const src = page.startsWith('http') ? page : '/static/' + page + (page.includes('?') ? '&' : '?') + 't=' + Date.now();
document.getElementById('contentFrame').src = src;
// Update active state
document.querySelectorAll('#mainSidebar .nav-item').forEach(n => n.classList.remove('active'));
el.classList.add('active');
// Update URL without reload
const pageName = page.split('?')[0].replace('.html','');
history.pushState({page}, '', '/static/main.html?page=' + page);
// Update title
document.title = (el.querySelector('span:nth-child(2)')?.textContent || 'Canifa AI') + ' — Canifa AI';
}
// ═══ INIT: Load page from URL param ═══
(function() {
const params = new URLSearchParams(window.location.search);
const page = params.get('page');
if (page) {
const src = page.startsWith('http') ? page : '/static/' + page + (page.includes('?') ? '&' : '?') + 't=' + Date.now();
document.getElementById('contentFrame').src = src;
// Highlight active nav
document.querySelectorAll('#mainSidebar .nav-item[data-page]').forEach(el => {
if (el.getAttribute('data-page') === page) {
el.classList.add('active');
} else {
el.classList.remove('active');
}
});
} else {
// Default: dashboard active
const dashLink = document.querySelector('[data-page="product.html"]');
if (dashLink) dashLink.classList.add('active');
}
})();
// ═══ HANDLE BACK/FORWARD ═══
window.addEventListener('popstate', function(e) {
if (e.state && e.state.page) {
const src = e.state.page.startsWith('http') ? e.state.page : '/static/' + e.state.page + (e.state.page.includes('?') ? '&' : '?') + 't=' + Date.now();
document.getElementById('contentFrame').src = src;
document.querySelectorAll('#mainSidebar .nav-item[data-page]').forEach(el => {
el.classList.toggle('active', el.getAttribute('data-page') === e.state.page);
});
}
});
</script>
</body>
</html>
......
......@@ -126,59 +126,59 @@
<body>
<aside class="sidebar">
<div class="sidebar-brand">
<div class="brand-icon">??</div>
<div class="brand-icon">🤖</div>
<div class="brand-text"><h2>Canifa AI</h2><span>Admin Console</span></div>
</div>
<div class="nav-group">
<div class="nav-group-label">Main</div>
<a href="/static/flow.html" class="nav-item"><span class="nav-icon">??</span><span>So d? ho?t d?ng</span></a>
<a href="/static/experiment_detail.html?id=exp_chatbot_prod" class="nav-item"><span class="nav-icon">??</span><span>Chatbot</span><span class="nav-badge badge-live">LIVE</span></a>
<a href="/static/history.html" class="nav-item"><span class="nav-icon">??</span><span>History</span></a>
<a href="/static/flow.html" class="nav-item"><span class="nav-icon">🔄</span><span>Sơ đồ hoạt động</span></a>
<a href="/static/experiment_detail.html?id=exp_chatbot_prod" class="nav-item"><span class="nav-icon">🤖</span><span>Chatbot</span><span class="nav-badge badge-live">LIVE</span></a>
<a href="/static/history.html" class="nav-item"><span class="nav-icon">💬</span><span>History</span></a>
</div>
<div class="nav-group">
<div class="nav-group-label">Workspace</div>
<a href="/static/resources.html" class="nav-item"><span class="nav-icon">??</span><span>Resources</span></a>
<a href="/static/notes.html" class="nav-item active"><span class="nav-icon">??</span><span>Team Notes</span></a>
<a href="/static/changelog.html" class="nav-item"><span class="nav-icon">??</span><span>Changelog</span></a>
<a href="/static/guide.html" class="nav-item"><span class="nav-icon">??</span><span>Hu?ng d?n</span></a>
<a href="/static/resources.html" class="nav-item"><span class="nav-icon">📦</span><span>Resources</span></a>
<a href="/static/notes.html" class="nav-item active"><span class="nav-icon">📝</span><span>Team Notes</span></a>
<a href="/static/changelog.html" class="nav-item"><span class="nav-icon">📋</span><span>Changelog</span></a>
<a href="/static/guide.html" class="nav-item"><span class="nav-icon">📖</span><span>Hướng dẫn</span></a>
</div>
<div class="nav-group">
<div class="nav-group-label">Th? nghi?m</div>
<a href="/static/test_sql.html" class="nav-item"><span class="nav-icon">???</span><span>Text-to-SQL</span><span class="nav-badge badge-beta">BETA</span></a>
<a href="/static/test_db.html" class="nav-item"><span class="nav-icon">??</span><span>DB Test</span><span class="nav-badge badge-beta">BETA</span></a>
<a href="/static/feedback_demo.html" class="nav-item"><span class="nav-icon">??</span><span>Feedback Demo</span><span class="nav-badge badge-new">NEW</span></a>
<a href="/static/test_sql.html" class="nav-item"><span class="nav-icon">🔍</span><span>Text-to-SQL</span><span class="nav-badge badge-beta">BETA</span></a>
<a href="/static/test_db.html" class="nav-item"><span class="nav-icon">🗄</span><span>DB Test</span><span class="nav-badge badge-beta">BETA</span></a>
<a href="/static/feedback_demo.html" class="nav-item"><span class="nav-icon">💬</span><span>Feedback Demo</span><span class="nav-badge badge-new">NEW</span></a>
</div>
<div class="nav-group">
<div class="nav-group-label">External</div>
<a href="/docs" target="_blank" class="nav-item"><span class="nav-icon">??</span><span>API Docs</span></a>
<a href="/redoc" target="_blank" class="nav-item"><span class="nav-icon">??</span><span>ReDoc</span></a>
<a href="/docs" target="_blank" class="nav-item"><span class="nav-icon">📚</span><span>API Docs</span></a>
<a href="/redoc" target="_blank" class="nav-item"><span class="nav-icon">📄</span><span>ReDoc</span></a>
</div>
<div class="sidebar-footer">
<div class="version-info"><span class="version-dot"></span><span class="version-text"><strong>v2.5.0</strong> Online</span></div>
<div class="version-info"><span class="version-dot"></span><span class="version-text"><strong>v2.5.0</strong> Online</span></div>
</div>
</aside>
<div class="main">
<div class="topbar">
<div><h1>?? Team Notes</h1><p>Notes, pinned docs & team workspace</p></div>
<div><h1>📝 Team Notes</h1><p>Notes, pinned docs & team workspace</p></div>
</div>
<div class="content">
<div class="header-row">
<h2>?? Team Notes & Pinned Docs</h2>
<h2>📝 Team Notes & Pinned Docs</h2>
<button class="btn btn-primary" onclick="openModal()">+ New Note</button>
</div>
<div class="filter-bar">
<button class="filter-chip active" onclick="filter('all', this)">All</button>
<button class="filter-chip" onclick="filter('pinned', this)">?? Pinned</button>
<button class="filter-chip" onclick="filter('note', this)">?? Notes</button>
<button class="filter-chip" onclick="filter('doc', this)">?? Docs</button>
<button class="filter-chip" onclick="filter('todo', this)">? To-do</button>
<button class="filter-chip" onclick="filter('announcement', this)">?? Announcements</button>
<button class="filter-chip" onclick="filter('pinned', this)">📌 Pinned</button>
<button class="filter-chip" onclick="filter('note', this)">📝 Notes</button>
<button class="filter-chip" onclick="filter('doc', this)">📄 Docs</button>
<button class="filter-chip" onclick="filter('todo', this)"> To-do</button>
<button class="filter-chip" onclick="filter('announcement', this)">📢 Announcements</button>
</div>
<div class="notes-grid" id="notesGrid">
<div class="empty-state"><div class="empty-icon">??</div><p>No notes yet. Create your first one!</p></div>
<div class="empty-state"><div class="empty-icon">📝</div><p>No notes yet. Create your first one!</p></div>
</div>
......@@ -188,15 +188,15 @@
<!-- MODAL -->
<div class="modal-overlay" id="modal">
<div class="modal">
<div class="modal-head"><h3 id="modalTitle">?? New Note</h3><button class="modal-close" onclick="closeModal()"></button></div>
<div class="modal-head"><h3 id="modalTitle">📝 New Note</h3><button class="modal-close" onclick="closeModal()"></button></div>
<div class="modal-body">
<input type="hidden" id="editId">
<div class="form-group"><label class="form-label">Title</label><input type="text" class="form-input" id="fTitle" placeholder="Note title..."></div>
<div class="form-group"><label class="form-label">Content</label><textarea class="form-textarea" id="fContent" placeholder="Write your note..."></textarea></div>
<div class="form-group"><label class="form-label">Category</label>
<select class="form-select" id="fCat">
<option value="note">?? Note</option><option value="doc">?? Document</option>
<option value="todo">? To-do</option><option value="announcement">?? Announcement</option>
<option value="note">📝 Note</option><option value="doc">📄 Document</option>
<option value="todo">✅ To-do</option><option value="announcement">📢 Announcement</option>
</select>
</div>
<div class="form-group"><label class="form-label">Color</label>
......@@ -209,17 +209,17 @@
<div class="color-dot" style="background:#f778ba" onclick="pickColor('#f778ba',this)"></div>
</div>
</div>
<div class="form-group"><label style="display:flex;align-items:center;gap:8px;cursor:pointer;"><input type="checkbox" id="fPin"> <span class="form-label" style="margin:0">?? Pin this note</span></label></div>
<div class="form-group"><label style="display:flex;align-items:center;gap:8px;cursor:pointer;"><input type="checkbox" id="fPin"> <span class="form-label" style="margin:0">📌 Pin this note</span></label></div>
</div>
<div class="modal-foot"><button class="btn" onclick="closeModal()">Cancel</button><button class="btn btn-primary" onclick="save()">?? Save Note</button></div>
<div class="modal-foot"><button class="btn" onclick="closeModal()">Cancel</button><button class="btn btn-primary" onclick="save()">💾 Save Note</button></div>
</div>
</div>
<script>
let data = [], activeFilter = 'all', selectedColor = 'var(--gold, #B8860B)';
function esc(s) { return s ? s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') : ''; }
function fmtTime(iso) { if(!iso)return''; const d=new Date(iso),now=new Date(),diff=(now-d)/1000; if(diff<60)return'v?a xong'; if(diff<3600)return Math.floor(diff/60)+' pht tru?c'; if(diff<86400)return Math.floor(diff/3600)+' gi? tru?c'; if(diff<604800)return Math.floor(diff/86400)+' ngy tru?c'; return d.toLocaleDateString('vi-VN',{day:'2-digit',month:'2-digit',year:'numeric'}); }
const catIcons = {note:'??',doc:'??',todo:'?',announcement:'??'};
function fmtTime(iso) { if(!iso)return''; const d=new Date(iso),now=new Date(),diff=(now-d)/1000; if(diff<60)return'vừa xong'; if(diff<3600)return Math.floor(diff/60)+' phút trước'; if(diff<86400)return Math.floor(diff/3600)+' giờ trước'; if(diff<604800)return Math.floor(diff/86400)+' ngày trước'; return d.toLocaleDateString('vi-VN',{day:'2-digit',month:'2-digit',year:'numeric'}); }
const catIcons = {note:'📝',doc:'📄',todo:'✅',announcement:'📢'};
async function load() {
try { const r=await fetch('/api/dashboard/notes'); const j=await r.json(); data=j.notes||[]; render(); }
......@@ -233,22 +233,22 @@
else if (activeFilter !== 'all') f = data.filter(n=>n.category===activeFilter);
f.sort((a,b)=>(a.pinned&&!b.pinned?-1:!a.pinned&&b.pinned?1:new Date(b.updated_at||b.created_at)-new Date(a.updated_at||a.created_at)));
if (!f.length) { grid.innerHTML = `<div class="empty-state"><div class="empty-icon">??</div><p>No notes found.</p></div>`; return; }
if (!f.length) { grid.innerHTML = `<div class="empty-state"><div class="empty-icon">📝</div><p>No notes found.</p></div>`; return; }
grid.innerHTML = f.map(n=>`
<div class="note-card ${n.pinned?'pinned':''}" style="--nc:${n.color||'var(--gold, #B8860B)'}">
<div class="note-header">
<span class="note-cat">${catIcons[n.category]||'??'} ${n.category}</span>
${n.pinned?'<span style="font-size:0.8em">??</span>':''}
<span class="note-cat">${catIcons[n.category]||'📝'} ${n.category}</span>
${n.pinned?'<span style="font-size:0.8em">📌</span>':''}
</div>
<div class="note-title">${esc(n.title)}</div>
<div class="note-body">${esc(n.content)}</div>
<div class="note-footer">
<span class="note-time">${fmtTime(n.updated_at||n.created_at)}</span>
<div class="note-actions">
<button class="note-act-btn ${n.pinned?'pin-active':''}" title="Pin" onclick="togglePin('${n.id}')">??</button>
<button class="note-act-btn" title="Edit" onclick="edit('${n.id}')">??</button>
<button class="note-act-btn" title="Delete" onclick="del('${n.id}')">???</button>
<button class="note-act-btn ${n.pinned?'pin-active':''}" title="Pin" onclick="togglePin('${n.id}')">✏️</button>
<button class="note-act-btn" title="Edit" onclick="edit('${n.id}')">✏️</button>
<button class="note-act-btn" title="Delete" onclick="del('${n.id}')">🗑️</button>
</div>
</div>
</div>`).join('');
......@@ -257,10 +257,10 @@
function filter(f,el) { activeFilter=f; document.querySelectorAll('.filter-chip').forEach(c=>c.classList.remove('active')); el.classList.add('active'); render(); }
function pickColor(c,el) { selectedColor=c; document.querySelectorAll('.color-dot').forEach(d=>d.classList.remove('active')); el.classList.add('active'); }
function openModal() { document.getElementById('modal').classList.add('show'); document.getElementById('editId').value=''; document.getElementById('fTitle').value=''; document.getElementById('fContent').value=''; document.getElementById('fCat').value='note'; document.getElementById('fPin').checked=false; selectedColor='var(--gold, #B8860B)'; document.querySelectorAll('.color-dot').forEach(d=>{d.classList.remove('active');if(d.style.background==='rgb(88, 166, 255)')d.classList.add('active');}); document.getElementById('modalTitle').textContent='?? New Note'; setTimeout(()=>document.getElementById('fTitle').focus(),100); }
function openModal() { document.getElementById('modal').classList.add('show'); document.getElementById('editId').value=''; document.getElementById('fTitle').value=''; document.getElementById('fContent').value=''; document.getElementById('fCat').value='note'; document.getElementById('fPin').checked=false; selectedColor='var(--gold, #B8860B)'; document.querySelectorAll('.color-dot').forEach(d=>{d.classList.remove('active');if(d.style.background==='rgb(88, 166, 255)')d.classList.add('active');}); document.getElementById('modalTitle').textContent='📝 New Note'; setTimeout(()=>document.getElementById('fTitle').focus(),100); }
function closeModal() { document.getElementById('modal').classList.remove('show'); }
function edit(id) { const n=data.find(x=>x.id===id); if(!n)return; document.getElementById('modal').classList.add('show'); document.getElementById('editId').value=id; document.getElementById('fTitle').value=n.title; document.getElementById('fContent').value=n.content||''; document.getElementById('fCat').value=n.category; document.getElementById('fPin').checked=n.pinned; selectedColor=n.color||'var(--gold, #B8860B)'; document.querySelectorAll('.color-dot').forEach(d=>{d.classList.remove('active');if(d.style.background===selectedColor||`rgb(${parseInt(selectedColor.slice(1,3),16)}, ${parseInt(selectedColor.slice(3,5),16)}, ${parseInt(selectedColor.slice(5,7),16)})`===d.style.background)d.classList.add('active');}); document.getElementById('modalTitle').textContent='?? Edit Note'; }
function edit(id) { const n=data.find(x=>x.id===id); if(!n)return; document.getElementById('modal').classList.add('show'); document.getElementById('editId').value=id; document.getElementById('fTitle').value=n.title; document.getElementById('fContent').value=n.content||''; document.getElementById('fCat').value=n.category; document.getElementById('fPin').checked=n.pinned; selectedColor=n.color||'var(--gold, #B8860B)'; document.querySelectorAll('.color-dot').forEach(d=>{d.classList.remove('active');if(d.style.background===selectedColor||`rgb(${parseInt(selectedColor.slice(1,3),16)}, ${parseInt(selectedColor.slice(3,5),16)}, ${parseInt(selectedColor.slice(5,7),16)})`===d.style.background)d.classList.add('active');}); document.getElementById('modalTitle').textContent='✏️ Edit Note'; }
async function save() {
const id=document.getElementById('editId').value,title=document.getElementById('fTitle').value.trim(),content=document.getElementById('fContent').value.trim();
......
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ultra Description — Canifa AI</title>
<link rel="stylesheet" href="/static/css/theme.css">
<link rel="stylesheet" href="/static/css/components.css">
<script src="/static/js/frame-detect.js"></script>
<style>
body { margin:0; min-height:100vh; background:var(--background); font-family:var(--font-sans); color:var(--foreground); }
.page { max-width:1100px; margin:0 auto; padding:20px 20px 60px; }
/* Header */
.page-hdr { display:flex; align-items:center; justify-content:space-between; margin-bottom:20px; flex-wrap:wrap; gap:12px; }
.page-hdr h1 { font-size:20px; font-weight:700; margin:0; }
.page-hdr p { font-size:13px; color:var(--muted-fg); margin:2px 0 0; }
/* Stats Cards */
.stats-row { display:grid; grid-template-columns:repeat(3,1fr) repeat(3,1fr); gap:12px; margin-bottom:20px; }
.stat-card { background:var(--card); border:1px solid var(--border); border-radius:10px; padding:16px 18px; }
.stat-card .label { font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.04em; color:var(--muted-fg); margin-bottom:6px; }
.stat-card .value { font-size:26px; font-weight:700; line-height:1.1; }
.stat-card .sub { font-size:12px; color:var(--muted-fg); margin-top:4px; }
.stat-card .value.success { color:var(--success); }
.stat-card .value.warn { color:var(--warn); }
.stat-card .value.primary { color:var(--primary); }
/* Progress bar */
.progress-bar-wrap { background:var(--muted); border-radius:6px; height:8px; overflow:hidden; margin-top:8px; }
.progress-bar-fill { height:100%; border-radius:6px; background:var(--success); transition:width .5s ease; }
/* Filter Bar */
.filter-bar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; padding:12px 16px; background:var(--card); border:1px solid var(--border); border-radius:10px; margin-bottom:14px; }
.filter-bar select, .filter-bar input {
padding:8px 12px; border:1px solid var(--border); border-radius:8px; font:inherit; font-size:13px; background:var(--card); color:var(--foreground); outline:none;
}
.filter-bar select:focus, .filter-bar input:focus { border-color:var(--primary); box-shadow:0 0 0 3px rgba(59,89,152,0.08); }
.filter-bar .search-input { flex:1; min-width:180px; }
.filter-actions { display:flex; gap:6px; margin-left:auto; }
/* Product Table */
.product-table { width:100%; border-collapse:collapse; background:var(--card); border:1px solid var(--border); border-radius:10px; overflow:hidden; }
.product-table thead th { padding:12px 14px; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--muted-fg); text-align:left; border-bottom:1px solid var(--border); background:var(--muted); }
.product-table tbody td { padding:12px 14px; font-size:13px; border-bottom:1px solid var(--border); vertical-align:middle; }
.product-table tbody tr { transition:background .12s; cursor:pointer; }
.product-table tbody tr:hover { background:var(--accent); }
.product-table tbody tr:last-child td { border-bottom:none; }
/* Product cell */
.product-cell { display:flex; align-items:center; gap:10px; }
.product-cell img { width:44px; height:44px; border-radius:6px; object-fit:cover; flex-shrink:0; background:var(--muted); border:1px solid var(--border); }
.product-cell .info { min-width:0; }
.product-cell .name { font-weight:600; font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:250px; }
.product-cell .code { font-size:11px; color:var(--muted-fg); font-family:var(--font-mono); }
/* Status badge */
.status-badge { display:inline-flex; align-items:center; gap:4px; padding:3px 10px; border-radius:20px; font-size:11px; font-weight:600; }
.status-badge.done { background:var(--success-light); color:var(--success); }
.status-badge.pending { background:#fff3cd; color:#856404; }
.status-badge.missing { background:var(--warn-light); color:var(--warn); }
.status-badge.loading { background:var(--info-light); color:var(--info); }
/* Color dots */
.color-dots { display:flex; gap:3px; flex-wrap:wrap; }
.color-dot { width:14px; height:14px; border-radius:50%; border:1px solid var(--border); }
/* Detail Panel */
.detail-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.25); z-index:100; justify-content:center; align-items:flex-start; padding:40px 20px; overflow-y:auto; backdrop-filter:blur(2px); }
.detail-overlay.open { display:flex; }
.detail-panel { background:var(--card); border:1px solid var(--border); border-radius:14px; width:720px; max-width:100%; box-shadow:var(--shadow-lg); }
.detail-header { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid var(--border); }
.detail-header h2 { font-size:16px; font-weight:700; margin:0; }
.detail-body { padding:20px; max-height:70vh; overflow-y:auto; }
/* Description preview */
.desc-group { margin-bottom:16px; }
.desc-group-title { font-size:12px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--muted-fg); margin-bottom:8px; padding-bottom:6px; border-bottom:1px solid var(--border); }
.desc-row { display:flex; padding:8px 0; gap:12px; }
.desc-label { width:120px; min-width:120px; font-size:12px; font-weight:600; color:var(--muted-fg); text-transform:uppercase; letter-spacing:.03em; padding-top:2px; }
.desc-value { flex:1; font-size:13.5px; line-height:1.65; }
.desc-tagline { font-style:italic; color:var(--primary); }
/* FAQ */
.faq-item { padding:10px 0; border-bottom:1px solid var(--border); }
.faq-item:last-child { border-bottom:none; }
.faq-q { font-size:13px; font-weight:600; color:var(--primary); margin-bottom:4px; }
.faq-a { font-size:13px; color:var(--secondary-fg); line-height:1.6; padding-left:14px; border-left:2px solid var(--border); }
/* Pagination */
.pagination { display:flex; justify-content:center; gap:6px; margin-top:16px; }
.pagination button { padding:6px 14px; border:1px solid var(--border); border-radius:6px; background:var(--card); font:inherit; font-size:12px; cursor:pointer; color:var(--foreground); }
.pagination button:hover { border-color:var(--primary); color:var(--primary); }
.pagination button.active { background:var(--primary); color:var(--primary-fg); border-color:var(--primary); }
.pagination button:disabled { opacity:.4; cursor:not-allowed; }
/* Loading */
.table-loading { text-align:center; padding:40px; color:var(--muted-fg); }
.spinner-sm { display:inline-block; width:18px; height:18px; border:2px solid var(--border); border-top-color:var(--primary); border-radius:50%; animation:spin .6s linear infinite; }
@keyframes spin { to { transform:rotate(360deg); } }
/* Responsive */
@media (max-width:768px) {
.stats-row { grid-template-columns:repeat(2,1fr); }
.desc-row { flex-direction:column; gap:2px; }
.desc-label { width:auto; min-width:auto; }
}
</style>
</head>
<body>
<div class="page">
<!-- Header -->
<div class="page-hdr" style="margin-bottom:10px;">
<div>
<h1>✦ Ultra Description Manager</h1>
<p>Sinh mô tả sản phẩm AI — kết hợp Vision + Stock Data</p>
</div>
<div style="display:flex;gap:8px;">
<button class="btn btn-outline btn-sm" onclick="loadOverview();if(currentTab==='products') loadProducts(); else loadFields();">↻ Refresh</button>
</div>
</div>
<!-- Tabs -->
<div style="display:flex; gap:20px; border-bottom:1px solid var(--border); margin-bottom:20px;">
<div class="tab-btn active" id="tabProducts" onclick="switchTab('products')" style="padding:10px 0; cursor:pointer; font-weight:600; font-size:14px; border-bottom:2px solid var(--primary); color:var(--primary);">Sản phẩm</div>
<div class="tab-btn" id="tabFields" onclick="switchTab('fields')" style="padding:10px 0; cursor:pointer; font-weight:600; font-size:14px; border-bottom:2px solid transparent; color:var(--muted-fg);">Quản lý Trường</div>
<div class="tab-btn" id="tabCleanData" onclick="switchTab('cleanData')" style="padding:10px 0; cursor:pointer; font-weight:600; font-size:14px; border-bottom:2px solid transparent; color:var(--muted-fg);">Clean Data</div>
</div>
<div id="sectionProducts">
<!-- Stats -->
<div class="stats-row" id="statsRow">
<div class="stat-card">
<div class="label">Tổng sản phẩm</div>
<div class="value primary" id="statTotal"></div>
</div>
<div class="stat-card">
<div class="label">Đã generate</div>
<div class="value" id="statDone"></div>
</div>
<div class="stat-card">
<div class="label">✅ Đã duyệt</div>
<div class="value success" id="statApproved"></div>
</div>
<div class="stat-card">
<div class="label">⏳ Chờ duyệt</div>
<div class="value" style="color:#856404" id="statPending"></div>
</div>
<div class="stat-card">
<div class="label">Chưa có mô tả</div>
<div class="value warn" id="statMissing"></div>
</div>
<div class="stat-card">
<div class="label">Tiến độ duyệt</div>
<div class="value" id="statProgress"></div>
<div class="progress-bar-wrap"><div class="progress-bar-fill" id="progressFill" style="width:0%"></div></div>
</div>
</div>
<!-- Filters -->
<div class="filter-bar">
<input type="text" class="search-input" id="searchInput" placeholder="Tìm theo tên hoặc mã sản phẩm...">
<select id="statusFilter">
<option value="">Tất cả</option>
<option value="missing">✕ Chưa có mô tả</option>
<option value="pending">⏳ Chờ duyệt</option>
<option value="approved">✅ Đã duyệt</option>
<option value="has_desc">Đã generate (tất cả)</option>
</select>
<select id="lineFilter">
<option value="">Tất cả dòng SP</option>
</select>
<div class="filter-actions">
<button class="btn btn-primary btn-sm" onclick="loadProducts()">Lọc</button>
<button class="btn btn-outline btn-sm" onclick="resetFilters()">Reset</button>
<button class="btn btn-sm" id="btnApprovePage" style="background:#28a745;color:#fff;border:none;" onclick="batchApprovePage()">✅ Duyệt (Trang này)</button>
<button class="btn btn-sm" id="btnApproveAll" style="background:#0d6efd;color:#fff;border:none;font-weight:600;" onclick="approveAll()">✅ Duyệt TOÀN BỘ</button>
<button class="btn btn-sm" id="btnBatch" style="background:#6a0dad;color:#fff;border:none;" onclick="batchGeneratePage()">🤖 Sinh AI (Trang này)</button>
<button class="btn btn-sm" id="btnBatchAll" style="background:#d63384;color:#fff;border:none;" onclick="batchGenerateAll()">🚀 Sinh AI (Toàn bộ thiếu)</button>
</div>
</div><!-- Batch Tracking Bar -->
<div id="batchTrackingBox" style="display:none; background:#e0f2fe; border:1px solid #7dd3fc; border-radius:8px; padding:12px 20px; margin-bottom:20px; display:flex; gap:20px; align-items:center; box-shadow:0 2px 4px rgba(0,0,0,0.05);">
<div style="font-size:24px;">🤖</div>
<div style="flex:1;">
<div style="display:flex; justify-content:space-between; margin-bottom:6px;">
<strong id="batchTrackTitle" style="color:#0369a1;">Hệ thống đang chạy AI hàng loạt...</strong>
<span id="batchTrackText" style="color:#0284c7; font-weight:600;">0 / 0</span>
</div>
<div style="background:rgba(255,255,255,0.7); height:8px; border-radius:4px; overflow:hidden;">
<div id="batchTrackFill" style="background:#0ea5e9; height:100%; width:0%; transition:width 0.3s;"></div>
</div>
<div style="margin-top:6px; font-size:12px; color:#0369a1;">Mã đang xử lý: <span id="batchTrackCode" style="font-weight:600;"></span> | Lỗi: <span id="batchTrackErrors" style="color:#ef4444; font-weight:bold;">0</span></div>
</div>
</div>
<!-- Table -->
<div style="border-radius:10px; overflow:hidden; border:1px solid var(--border);">
<table class="product-table">
<thead>
<tr>
<th style="width:35%">Sản phẩm</th>
<th>Dòng SP</th>
<th>Giá</th>
<th>Lượt bán</th>
<th>Trạng thái</th>
<th>Duyệt</th>
<th style="width:100px">Thao tác</th>
</tr>
</thead>
<tbody id="tableBody">
<tr><td colspan="7" class="table-loading">Đang tải...</td></tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<!-- Pagination -->
<div class="pagination" id="pagination"></div>
</div> <!-- end sectionProducts -->
<!-- Fields Section -->
<div id="sectionFields" style="display:none;">
<div style="display:flex; justify-content:space-between; margin-bottom:12px;">
<div>
<h2 style="margin:0; font-size:18px;">Cấu hình Prompt Fields</h2>
<p style="margin:4px 0 0; font-size:13px; color:var(--muted-fg);">Các trường này sẽ được sinh động trong quá trình AI phân tích sản phẩm (Phase 1 & Phase 2).</p>
</div>
<button class="btn btn-primary" onclick="showFieldForm()"><span style="margin-right:6px"></span> Thêm Trường</button>
</div>
<div style="border-radius:10px; overflow:hidden; border:1px solid var(--border); background:var(--card);">
<table class="product-table" id="fieldsTable">
<thead style="background:var(--muted);">
<tr>
<th style="width:15%">Key</th>
<th style="width:20%">Label</th>
<th style="width:40%">Instruction (Prompt)</th>
<th>Trạng thái</th>
<th style="width:120px">Thao tác</th>
</tr>
</thead>
<tbody id="fieldsTableBody">
<tr><td colspan="5" class="table-loading">Đang tải...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Detail Overlay -->
<div class="detail-overlay" id="detailOverlay" onclick="if(event.target===this)closeDetail()">
<div class="detail-panel" style="width:800px;">
<div class="detail-header" style="flex-direction:column; align-items:stretch; gap:12px; padding-bottom:0;">
<div style="display:flex; align-items:center; justify-content:space-between;">
<h2 id="detailTitle">Chi tiết mô tả</h2>
<div style="display:flex;gap:6px;">
<button class="btn btn-sm" id="btnApprove" style="background:#28a745;color:#fff;border:none;display:none;font-weight:500" onclick="approveDetail(1)">Duyệt</button>
<button class="btn btn-sm" id="btnReject" style="background:#dc3545;color:#fff;border:none;display:none;font-weight:500" onclick="approveDetail(0)">Hủy duyệt</button>
<button class="btn btn-outline btn-sm" id="btnCopyJson" style="font-weight:500" onclick="copyPopupText()">Copy Nội Dung</button>
<button class="btn btn-sm" id="btnSavePopupText" style="background:#10b981;color:#fff;border:none;font-weight:500;" onclick="savePopupText()">Lưu Update</button>
<button class="btn btn-sm" style="background:#1e40af;color:#fff;border:none;font-weight:500" onclick="goToCleanData()">Sửa Dữ Liệu (Clean Data)</button>
<button class="btn btn-outline btn-sm" style="font-weight:500" onclick="closeDetail()">Đóng</button>
</div>
</div>
<div style="display:flex; gap:24px; border-bottom:1px solid var(--border);">
<div class="tab-btn active" id="dTabClean" onclick="switchDetailTab('clean')" style="padding:10px 0; cursor:pointer; font-weight:600; font-size:14px; border-bottom:2px solid var(--primary); color:var(--primary);">Dàn trang (Chuẩn)</div>
<div class="tab-btn" id="dTabFields" onclick="switchDetailTab('fields')" style="padding:10px 0; cursor:pointer; font-weight:600; font-size:14px; border-bottom:2px solid transparent; color:var(--muted-fg);">Xem Từng Trường</div>
<div class="tab-btn" id="dTabEmbed" onclick="switchDetailTab('embed')" style="padding:10px 0; cursor:pointer; font-weight:600; font-size:14px; border-bottom:2px solid transparent; color:var(--muted-fg);">Text Sync (Embedding)</div>
<div class="tab-btn" id="dTabRaw" onclick="switchDetailTab('raw')" style="padding:10px 0; cursor:pointer; font-weight:600; font-size:14px; border-bottom:2px solid transparent; color:var(--muted-fg);">Raw JSON</div>
</div>
</div>
<!-- Clean Tab -->
<div class="detail-body" id="detailBodyClean" style="max-height: 60vh; overflow-y: auto;">
<div class="table-loading"><div class="spinner-sm"></div> Đang tải...</div>
</div>
<!-- Fields Tab -->
<div class="detail-body" id="detailBodyFields" style="display:none; max-height: 60vh; overflow-y: auto; background: var(--card);">
</div>
<!-- Embed Tab -->
<div class="detail-body" id="detailBodyEmbed" style="display:none; max-height: 60vh; overflow-y: auto; background: var(--card); padding: 24px;">
<div style="background: #fdfae6; border: 1px solid #fef08a; padding: 12px; border-radius: 8px; margin-bottom: 20px;">
<strong style="color: #ca8a04; font-size: 14px;">💡 Thông tin Dành Cho Hệ Thống Chatbot</strong>
<p style="color: #a16207; font-size: 13px; margin-top: 4px; margin-bottom: 0;">Đoạn văn bản dày đặc dưới đây là văn bản gộp từ các thuộc tính trên. Nó không dành để hiển thị ra web cho khách hàng đọc, mà được Dùng làm "nhân" đồng bộ sang StarRocks cho công cụ Vector Emdedding của Chatbot hiểu được sản phẩm.</p>
</div>
<div id="detailPreEmbed" style="font-size:14px; color:#374151; white-space:pre-wrap; line-height: 1.6; padding: 16px; background: #fefefe; border: 1px dashed #d1d5db; border-radius: 8px;"></div>
</div>
<!-- Raw Tab -->
<div class="detail-body" id="detailBodyRaw" style="display:none; background:#1e1e1e; color:#cfcbcc; max-height: 60vh; overflow-y: auto; padding: 20px;">
<pre id="detailPreRaw" style="font-size:12px; font-family:var(--font-mono); margin:0; white-space:pre-wrap;"></pre>
</div>
</div>
</div>
<!-- Clean Data Section -->
<div id="sectionCleanData" style="display:none; padding: 20px; background:var(--card); border:1px solid var(--border); border-radius:10px; min-height: 400px;">
<div style="display:flex; justify-content:space-between; margin-bottom:12px;">
<div>
<h2 style="margin:0; font-size:18px;">Chỉnh sửa Dữ liệu Sản phẩm (Clean Data)</h2>
<p style="margin:4px 0 0; font-size:13px; color:var(--muted-fg);">Khu vực chỉnh sửa, bổ sung, thêm/xóa trường dữ liệu chuyên sâu cho 1 sản phẩm.</p>
</div>
<div>
<div style="display:inline-flex; border:1px solid var(--border); border-radius:6px; overflow:hidden;">
<input type="text" id="cleanDataSearch" placeholder="Nhập mã sản phẩm..." style="border:none; padding:8px 12px; font-size:13px; outline:none; width:200px;">
<button class="btn btn-primary" style="border-radius:0; border:none;" onclick="loadCleanDataForInput()">Tải dữ liệu</button>
</div>
<button class="btn btn-sm" id="btnSaveCleanData" style="background:#0d6efd;color:#fff;border:none;display:none;margin-left:10px;padding:8px 16px;" onclick="saveDetailEdit()">💾 Lưu Dữ liệu JSON</button>
</div>
</div>
<div id="cleanDataTitle" style="font-weight:bold; font-size:16px; margin:20px 0 10px; color:var(--primary); display:none;"></div>
<!-- Edit Tab that was previously in popup -->
<div id="cleanDataEditor" style="background:#fafafa; padding:20px; border:1px solid var(--border); border-radius:8px; display:none;">
<!-- Rendered dynamically -->
</div>
<div id="cleanDataEmpty" style="padding:40px; text-align:center; color:var(--muted-fg); background:#f9fafb; border-radius:8px; border:2px dashed var(--border);">
Bạn chưa chọn sản phẩm nào để chỉnh sửa. Vui lòng nhập mã phía trên hoặc qua từ danh sách Sản phẩm.
</div>
</div>
<script>
const API = '/api/product-desc';
let currentProducts = [];
let currentPage = 0;
let currentDetail = null;
let currentDetailCode = null;
const PAGE_SIZE = 30;
// ═══ Init ═══
window.addEventListener('DOMContentLoaded', () => {
try {
const parentParams = new URLSearchParams(window.parent.location.search);
if (parentParams.has('p')) {
currentPage = Math.max(0, parseInt(parentParams.get('p')) - 1) || 0;
}
} catch(e) {}
loadOverview();
loadFilters();
loadProducts();
// Enter to search
document.getElementById('searchInput').addEventListener('keydown', e => {
if (e.key === 'Enter') loadProducts();
});
});
// ═══ Overview Stats ═══
async function loadOverview() {
try {
const res = await fetch(`${API}/overview`);
const data = await res.json();
if (data.status !== 'success') return;
document.getElementById('statTotal').textContent = data.total.toLocaleString();
document.getElementById('statDone').textContent = data.has_desc.toLocaleString();
document.getElementById('statApproved').textContent = (data.approved || 0).toLocaleString();
document.getElementById('statPending').textContent = (data.pending || 0).toLocaleString();
document.getElementById('statMissing').textContent = data.missing.toLocaleString();
const approveProgress = data.total > 0 ? ((data.approved || 0) / data.total * 100).toFixed(1) : 0;
document.getElementById('statProgress').textContent = approveProgress + '%';
document.getElementById('progressFill').style.width = approveProgress + '%';
} catch (e) { console.error('Overview error:', e); }
}
// ═══ Filters ═══
async function loadFilters() {
try {
const res = await fetch(`${API}/filters`);
const data = await res.json();
if (data.status !== 'success') return;
const sel = document.getElementById('lineFilter');
(data.product_lines || []).forEach(l => {
const opt = document.createElement('option');
opt.value = l.product_line_vn;
opt.textContent = `${l.product_line_vn} (${l.cnt})`;
sel.appendChild(opt);
});
} catch (e) {}
}
function resetFilters() {
document.getElementById('searchInput').value = '';
document.getElementById('statusFilter').value = '';
document.getElementById('lineFilter').value = '';
currentPage = 0;
loadProducts();
}
// ═══ Products List ═══
async function loadProducts() {
const body = document.getElementById('tableBody');
body.innerHTML = '<tr><td colspan="6" class="table-loading"><div class="spinner-sm"></div> Đang tải...</td></tr>';
const search = document.getElementById('searchInput').value.trim();
const status = document.getElementById('statusFilter').value;
const line = document.getElementById('lineFilter').value;
const params = new URLSearchParams({ limit: PAGE_SIZE, page: currentPage + 1 });
if (search) params.set('search', search);
if (status) params.set('status', status);
if (line) params.set('product_line', line);
try {
const parentUrl = new URL(window.parent.location);
parentUrl.searchParams.set('p', currentPage + 1);
window.parent.history.replaceState(null, '', parentUrl.toString());
} catch(e) {}
try {
const res = await fetch(`${API}/list?${params}`);
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
currentProducts = data.products || [];
renderTable(currentProducts);
renderPagination(data.total);
} catch (e) {
body.innerHTML = `<tr><td colspan="6" class="table-loading" style="color:var(--error)">⚠️ ${e.message}</td></tr>`;
}
}
function renderTable(products) {
const body = document.getElementById('tableBody');
if (!products.length) {
body.innerHTML = '<tr><td colspan="7" class="table-loading">Không tìm thấy sản phẩm nào</td></tr>';
return;
}
body.innerHTML = products.map(p => {
const descStatus = p.desc_status;
let statusHtml;
if (descStatus === 1) {
statusHtml = '<span class="status-badge done">✅ Đã duyệt</span>';
} else if (descStatus === 0) {
statusHtml = '<span class="status-badge pending">⏳ Chờ duyệt</span>';
} else {
statusHtml = '<span class="status-badge missing">✕ Chưa có</span>';
}
const price = p.sale_price ? Number(p.sale_price).toLocaleString('vi-VN') + '₫' : '—';
const sold = p.quantity_sold ? Number(p.quantity_sold).toLocaleString() : '0';
const imgSrc = p.product_image_url_thumbnail || '';
const imgTag = imgSrc
? `<img src="${esc(imgSrc)}" alt="" onerror="this.style.display='none'">`
: `<div style="width:44px;height:44px;border-radius:6px;background:var(--muted);display:flex;align-items:center;justify-content:center;font-size:16px;">📦</div>`;
return `<tr onclick="openDetail('${esc(p.internal_ref_code)}', '${esc(p.product_name || '')}')">
<td>
<div class="product-cell">
${imgTag}
<div class="info">
<div class="name" title="${esc(p.product_name || '')}">${esc(p.product_name || '—')}</div>
<div class="code">${esc(p.internal_ref_code || '')}</div>
</div>
</div>
</td>
<td>${esc(p.product_line_vn || '—')}</td>
<td>${price}</td>
<td>${sold}</td>
<td>${statusHtml}</td>
<td>${descStatus >= 0
? (descStatus === 1
? `<button class="btn btn-sm" style="background:#dc3545;color:#fff;border:none;font-size:11px;padding:4px 10px" onclick="event.stopPropagation();approveRow('${esc(p.internal_ref_code)}',0,this)">✕ Hủy</button>`
: `<button class="btn btn-sm" style="background:#28a745;color:#fff;border:none;font-size:11px;padding:4px 10px" onclick="event.stopPropagation();approveRow('${esc(p.internal_ref_code)}',1,this)">✓ Duyệt</button>`)
: '<span style="color:var(--muted-fg);font-size:11px">—</span>'
}</td>
<td><button class="btn btn-outline btn-sm" onclick="event.stopPropagation();generateSingle('${esc(p.internal_ref_code)}','${esc(p.product_name || '')}',this)">✦ Generate</button></td>
</tr>`;
}).join('');
}
function renderPagination(total) {
const pages = Math.ceil(total / PAGE_SIZE);
const el = document.getElementById('pagination');
if (pages <= 1) { el.innerHTML = ''; return; }
let html = `<button ${currentPage===0?'disabled':''} onclick="currentPage--;loadProducts()">← Trước</button>`;
const start = Math.max(0, currentPage - 3);
const end = Math.min(pages, start + 7);
for (let i = start; i < end; i++) {
html += `<button class="${i===currentPage?'active':''}" onclick="currentPage=${i};loadProducts()">${i+1}</button>`;
}
html += `<button ${currentPage>=pages-1?'disabled':''} onclick="currentPage++;loadProducts()">Sau →</button>`;
el.innerHTML = html;
}
// ═══ Generate Single ═══
async function generateSingle(code, name, btn) {
const ogText = btn.textContent;
btn.innerHTML = '<span class="spinner-sm"></span>';
btn.disabled = true;
try {
const res = await fetch(`${API}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ internal_ref_code: code }),
});
const data = await res.json();
if (data.status === 'error') throw new Error(data.message);
currentDetail = data;
currentDetailCode = code;
showDetailPanel(data, name);
// Update local state and re-render table row
const p = currentProducts.find(x => x.internal_ref_code === code);
if (p) {
p.desc_status = 0; // Chờ duyệt
renderTable(currentProducts);
}
loadOverview(); // Cập nhật lại board thống kê ở trên cùng
} catch (e) {
alert('Lỗi: ' + e.message);
} finally {
btn.textContent = ogText;
btn.disabled = false;
}
}
// ═══ Batch Approve Page ═══
async function batchApprovePage() {
const pendingCodes = currentProducts.filter(p => p.desc_status === 0).map(p => p.internal_ref_code);
if (pendingCodes.length === 0) {
alert("Trang hiện tại không có sản phẩm nào 'Chờ duyệt' để duyệt hàng loạt.");
return;
}
if (!confirm(`Xác nhận duyệt hàng loạt ${pendingCodes.length} sản phẩm đang chờ duyệt ở trang này?`)) return;
const btn = document.getElementById('btnApprovePage');
const ogText = btn.innerHTML;
btn.innerHTML = '<div class="spinner-sm"></div> Đang tải...';
btn.disabled = true;
try {
const promises = pendingCodes.map(code =>
fetch(`${API}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ internal_ref_code: code, status: 1 }),
})
);
await Promise.all(promises);
alert(`✅ Đã duyệt thành công ${pendingCodes.length} sản phẩm.`);
loadProducts();
loadOverview();
} catch (e) {
alert('Lỗi duyệt hàng loạt: ' + e.message);
} finally {
btn.innerHTML = ogText;
btn.disabled = false;
}
}
// ═══ Approve ALL (toàn bộ chờ duyệt) ═══
async function approveAll() {
if (!confirm('⚠️ Xác nhận DUYỆT TOÀN BỘ tất cả mô tả đang ở trạng thái "Chờ duyệt"?')) return;
const btn = document.getElementById('btnApproveAll');
const ogText = btn.innerHTML;
btn.innerHTML = '<div class="spinner-sm"></div> Đang duyệt...';
btn.disabled = true;
try {
const resp = await fetch(`${API}/approve-all`, { method: 'POST' });
const data = await resp.json();
if (data.status === 'success') {
alert(`✅ ${data.message}`);
loadProducts();
loadOverview();
} else {
alert('❌ Lỗi: ' + (data.message || 'Unknown error'));
}
} catch (e) {
alert('❌ Lỗi: ' + e.message);
} finally {
btn.innerHTML = ogText;
btn.disabled = false;
}
}
async function batchGeneratePage() {
const missingCodes = currentProducts.filter(p => p.desc_status === -1).map(p => p.internal_ref_code);
if (missingCodes.length === 0) {
alert("Trang hiện tại không có sản phẩm nào 'Chưa có mô tả'. Vui lòng chuyển trang hoặc đổi bộ lọc.");
return;
}
if (!confirm(`Phát hiện ${missingCodes.length} sản phẩm thiếu mô tả ở trang này.\\nĐưa vào hàng đợi tự động sinh AI (Chạy ngầm)?`)) return;
const btn = document.getElementById('btnBatch');
const ogText = btn.innerHTML;
btn.innerHTML = '<div class="spinner-sm"></div> Đang gửi...';
btn.disabled = true;
try {
const res = await fetch(`${API}/batch-generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ internal_ref_codes: missingCodes })
});
const data = await res.json();
if (data.status === 'error') throw new Error(data.message);
alert(data.message);
startBatchPolling();
// Refresh table immediately to not click again
loadProducts();
} catch (e) {
alert('Lỗi chạy hàng loạt: ' + e.message);
} finally {
btn.innerHTML = ogText;
btn.disabled = false;
}
}
// ═══ Batch Generate All ═══
async function batchGenerateAll() {
if (!confirm(`Hệ thống sẽ tự động quét TẤT CẢ sản phẩm thiếu mô tả trong database (tối đa 500 SP/lần) và đưa vào hàng đợi sinh AI chạy ngầm.\\n\\nBạn có chắc chắn muốn chạy không?`)) return;
const btn = document.getElementById('btnBatchAll');
const ogText = btn.innerHTML;
btn.innerHTML = '<div class="spinner-sm"></div> Đang gửi...';
btn.disabled = true;
try {
const res = await fetch(`${API}/batch-generate-all`, {
method: 'POST'
});
const data = await res.json();
if (data.status === 'error') throw new Error(data.message);
alert(data.message);
startBatchPolling();
loadProducts();
loadOverview();
} catch (e) {
alert('Lỗi chạy toàn bộ: ' + e.message);
} finally {
btn.innerHTML = ogText;
btn.disabled = false;
}
}
// ═══ Batch Tracking Polling ═══
let batchPollInterval = null;
function startBatchPolling() {
if (batchPollInterval) return; // Already polling
document.getElementById('batchTrackingBox').style.display = 'flex';
batchPollInterval = setInterval(async () => {
try {
const res = await fetch(`${API}/batch-status`);
const data = await res.json();
if (!data.progress || !data.progress.is_running) {
clearInterval(batchPollInterval);
batchPollInterval = null;
document.getElementById('batchTrackTitle').innerText = '✅ Hoàn tất sinh AI hàng loạt!';
document.getElementById('batchTrackFill').style.background = '#10b981';
setTimeout(() => {
document.getElementById('batchTrackingBox').style.display = 'none';
loadOverview();
loadProducts();
}, 5000);
return;
}
const prog = data.progress;
const pct = Math.round((prog.done / prog.total) * 100) || 0;
document.getElementById('batchTrackTitle').innerText = 'Hệ thống đang chạy AI hàng loạt...';
document.getElementById('batchTrackFill').style.background = '#0ea5e9';
document.getElementById('batchTrackText').innerText = `${prog.done} / ${prog.total} (${pct}%)`;
document.getElementById('batchTrackFill').style.width = pct + '%';
document.getElementById('batchTrackCode').innerText = prog.current_code || '—';
document.getElementById('batchTrackErrors').innerText = prog.errors || 0;
} catch(e) {
console.error("Lỗi poll status:", e);
}
}, 2000); // poll every 2 seconds
}
// Check status on mount just in case a batch is already running
setTimeout(() => {
fetch(`${API}/batch-status`)
.then(r => r.json())
.then(s => {
if (s.is_running) startBatchPolling();
})
.catch(()=>{});
}, 1000);
// ═══ Detail Panel ═══
async function openDetail(code, name) {
// Show loading state
document.getElementById('detailTitle').textContent = name || 'Đang tải...';
document.getElementById('detailBodyClean').innerHTML = '<div class="table-loading"><div class="spinner-sm"></div> Đang tải...</div>';
document.getElementById('detailOverlay').classList.add('open');
// Try to load saved description first
try {
const res = await fetch(`${API}/saved/${encodeURIComponent(code)}`);
if (res.ok) {
const saved = await res.json();
if (saved.status === 'success' && saved.description) {
const desc = saved.description;
currentDetail = {
phase: desc.phase,
data: desc.description_data,
saved: true,
savedAt: desc.updated_at,
descStatus: desc.status,
description: desc
};
currentDetailCode = code;
showDetailPanel(currentDetail, desc.product_name || name);
return;
}
}
} catch (e) { /* not found, will generate */ }
// No saved description → generate new
closeDetail();
generateSingle(code, name, { textContent: '', innerHTML: '', disabled: false, closest: () => null });
}
function closeDetail() {
document.getElementById('detailOverlay').classList.remove('open');
}
function showDetailPanel(data, name) {
const d = data.description?.description_data || data.data || {};
const c = data.description || {};
const title = name ? `${name} <span style="font-weight:400;color:var(--muted-fg);font-size:13px">(${c.internal_ref_code || currentDetailCode})</span>` : (c.internal_ref_code || currentDetailCode || 'Chi tiết');
document.getElementById('detailTitle').innerHTML = title;
// 1. Clean Tab - Render Format Đẹp Đẹp
const cleanBody = document.getElementById('detailBodyClean');
if (c.rendered_text) {
let html = c.rendered_text;
// Only run regex if it does not look like html
if (!html.includes('<h2') && !html.includes('<div') && !html.includes('<li')) {
let text = esc(html);
text = text.replace(/={50}\n\s+(.*?)\n={50}/g, '<h1 style="color: var(--foreground); font-size: 20px; font-weight: 700; text-align: center; margin: 0 0 24px 0; letter-spacing: 0.05em;">$1</h1>');
text = text.replace(/^──\s+(.*?)\s+──$/gm, '<h2 style="margin: 32px 0 16px; color: var(--primary); font-size: 16px; border-bottom: 2px solid #f0f0f0; padding-bottom: 8px; font-weight: 700; text-transform: uppercase;">$1</h2>');
text = text.replace(/^\s*💬\s+(.*)$/gm, '<div style="font-weight: 600; margin-top: 16px; display: flex; gap: 8px;"><div style="color:#ef4444">Q:</div><div>$1</div></div>');
text = text.replace(/^\s*→\s+(.*)$/gm, '<div style="color: #4b5563; margin-top: 6px; margin-bottom: 16px; padding-left: 24px; border-left: 3px solid #e5e7eb; display: flex; gap: 8px;"><div style="color:#10b981">A:</div><div>$1</div></div>');
text = text.replace(/^\s*•\s+(.*)$/gm, '<li style="margin-bottom: 8px;">$1</li>');
text = text.replace(/(<li.*<\/li>(\n|(?=<br>)|$))+/g, '<ul style="margin: 8px 0 16px; padding-left: 24px;">$&</ul>');
text = text.replace(/^\s*([^<:\n]+):\s*(.*)$/gm, '<div style="margin-bottom: 8px;"><strong style="min-width: 140px; display: inline-block;">$1:</strong> <span style="color: #4b5563;">$2</span></div>');
text = text.replace(/^\s*([^<:\n]+):$/gm, '<strong style="display: block; margin-top: 12px; margin-bottom: 8px;">$1:</strong>');
text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
text = text.replace(/\n(?!<)/g, '<br>');
text = text.replace(/<br>\s*<br>/g, '<br>');
html = text;
}
cleanBody.innerHTML = `<div id="cleanTextEditor" contenteditable="true" style="padding: 24px; font-family: 'Inter', system-ui, -apple-system, sans-serif; font-size: 14px; line-height: 1.6; color: #374151; outline: none; border: 1px solid transparent; border-radius: 8px; transition: all 0.2s; min-height: 200px;" onfocus="this.style.border='1px solid var(--primary)'; this.style.boxShadow='0 0 0 3px rgba(11,28,68,0.1)';" onblur="this.style.border='1px solid transparent'; this.style.boxShadow='none';">${html}</div>`;
} else {
cleanBody.innerHTML = `<div id="cleanTextEditor" contenteditable="true" style="padding:24px; color:var(--muted-fg)">Chưa có nội dung bài viết hoàn chỉnh từ AI.</div>`;
}
// 2. We no longer render editBody here! Instead, we set up variables
// so that if the user clicks "Chỉnh sửa ở Tab Clean Data", it knows what to load.
window.currentDetailDataForCleanTab = data;
window.currentDetailNameForCleanTab = name;
// 3. Fields Tab
const fieldsBody = document.getElementById('detailBodyFields');
let fHtml = '<div style="padding: 24px;">';
for (const [k, v] of Object.entries(d)) {
fHtml += `
<div style="margin-bottom: 16px; padding: 12px; background: #f9fafb; border-radius: 8px; border: 1px solid #e5e7eb;">
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
<strong style="color:var(--primary); font-size:14px;">${esc(k)}</strong>
<button class="btn btn-sm" style="background:#e0e7ff; color:#4f46e5; border:none; border-radius:4px; font-size:12px; font-weight:600; cursor:pointer;" onclick="rewriteField('${c.internal_ref_code || currentDetailCode}', '${esc(k)}', this)">✨ AI Viết Lại</button>
</div>
<div id="field-val-${k}" style="font-size: 14px; color: #374151; white-space: pre-wrap;">${esc(v)}</div>
</div>
`;
}
fHtml += '</div>';
fieldsBody.innerHTML = fHtml;
// 3.5 Embed Tab
const embedBody = document.getElementById('detailPreEmbed');
let embedVals = [];
for (const [k, v] of Object.entries(d)) {
if (v && v.trim) {
embedVals.push(`${k}: ${v.trim().replace(/\n/g, ' ')}`);
}
}
embedBody.textContent = embedVals.join('. ');
// 4. Raw Tab
const rawBody = document.getElementById('detailPreRaw');
rawBody.textContent = JSON.stringify(d, null, 2);
document.getElementById('detailOverlay').classList.add('open');
switchDetailTab('clean');
// Show/hide approve buttons
const btnApprove = document.getElementById('btnApprove');
const btnReject = document.getElementById('btnReject');
if (c.saved_at || data.saved) {
const isApproved = c.status === 1 || data.descStatus === 1;
btnApprove.style.display = isApproved ? 'none' : '';
btnReject.style.display = isApproved ? '' : 'none';
} else {
// Just generated -> waiting for save
btnApprove.style.display = '';
btnReject.style.display = 'none';
}
}
let activeDetailTab = 'clean';
function switchDetailTab(tabId) {
activeDetailTab = tabId;
document.getElementById('dTabClean').classList.remove('active');
document.getElementById('dTabFields').classList.remove('active');
document.getElementById('dTabEmbed').classList.remove('active');
document.getElementById('dTabRaw').classList.remove('active');
document.getElementById('dTabClean').style.color = 'var(--secondary-fg)';
document.getElementById('dTabClean').style.borderColor = 'transparent';
document.getElementById('dTabFields').style.color = 'var(--secondary-fg)';
document.getElementById('dTabFields').style.borderColor = 'transparent';
document.getElementById('dTabEmbed').style.color = 'var(--secondary-fg)';
document.getElementById('dTabEmbed').style.borderColor = 'transparent';
document.getElementById('dTabRaw').style.color = 'var(--secondary-fg)';
document.getElementById('dTabRaw').style.borderColor = 'transparent';
document.getElementById('detailBodyClean').style.display = 'none';
document.getElementById('detailBodyFields').style.display = 'none';
document.getElementById('detailBodyEmbed').style.display = 'none';
document.getElementById('detailBodyRaw').style.display = 'none';
const bgMap = { 'clean': 'var(--card)', 'fields': 'var(--card)', 'embed': 'var(--card)', 'raw': '#1e1e1e' };
document.querySelector('.detail-panel').style.background = bgMap[tabId];
const activeTab = document.getElementById(
tabId === 'clean' ? 'dTabClean' : (tabId === 'fields' ? 'dTabFields' : (tabId === 'embed' ? 'dTabEmbed' : 'dTabRaw'))
);
activeTab.classList.add('active');
activeTab.style.color = 'var(--primary)';
activeTab.style.borderColor = 'var(--primary)';
if (tabId === 'clean') {
document.getElementById('detailBodyClean').style.display = 'block';
document.getElementById('btnSavePopupText').style.display = 'inline-block';
} else if (tabId === 'fields') {
document.getElementById('detailBodyFields').style.display = 'block';
document.getElementById('btnSavePopupText').style.display = 'none';
} else if (tabId === 'embed') {
document.getElementById('detailBodyEmbed').style.display = 'block';
document.getElementById('btnSavePopupText').style.display = 'none';
} else if (tabId === 'raw') {
document.getElementById('detailBodyRaw').style.display = 'block';
document.getElementById('btnSavePopupText').style.display = 'none';
}
}
async function rewriteField(code, key, btn) {
if(!code || !key) return;
const vDiv = document.getElementById(`field-val-${key}`);
btn.innerHTML = '<span class="spinner-sm" style="border-width:2px; width:12px; height:12px; border-color:#4f46e5 transparent transparent transparent;"></span> ...';
btn.disabled = true;
vDiv.style.opacity = '0.5';
try {
const res = await fetch(`${API}/saved/${encodeURIComponent(code)}/rewrite-field`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ field_key: key })
});
const data = await res.json();
if (data.status === 'success') {
vDiv.innerText = data.new_value;
// Update local cache
if(window.currentDetailDataForCleanTab && window.currentDetailDataForCleanTab.data) {
window.currentDetailDataForCleanTab.data[key] = data.new_value;
}
if(window.currentDetailDataForCleanTab && window.currentDetailDataForCleanTab.description) {
window.currentDetailDataForCleanTab.description.rendered_text = data.clean_description;
}
btn.innerHTML = '✅ OK';
setTimeout(() => { btn.innerHTML = '✨ AI Viết Lại'; btn.disabled = false; }, 2000);
// Attempt to re-render clean html implicitly
const cleanEditor = document.getElementById('cleanTextEditor');
if (cleanEditor && cleanEditor.innerHTML && !cleanEditor.innerHTML.trim().includes('<h2')) {
// If pure text, let's just do a tiny logic to refresh the popup if they switch tabs
// No big deal if not, they can refresh page.
}
} else {
throw new Error(data.message || 'Lỗi');
}
} catch(e) {
alert("Lỗi: " + e.message);
btn.innerHTML = '✨ AI Viết Lại';
btn.disabled = false;
}
vDiv.style.opacity = '1';
}
// Helper for formatting fake strings temporarily
function StrReplaceFake(str) {
return str; // If need markdown replacement later
}
// --- Clean Data Tab Logic ---
function goToCleanData() {
closeDetail();
switchTab('cleanData');
document.getElementById('cleanDataSearch').value = currentDetailCode || '';
renderCleanDataForm();
}
async function loadCleanDataForInput() {
const code = document.getElementById('cleanDataSearch').value.trim();
if(!code) return alert("Nhập mã sản phẩm!");
document.getElementById('cleanDataEditor').innerHTML = '<div class="table-loading"><div class="spinner-sm"></div> Đang tải...</div>';
document.getElementById('cleanDataEditor').style.display = 'block';
document.getElementById('cleanDataEmpty').style.display = 'none';
try {
const res = await fetch(`${API}/saved/${encodeURIComponent(code)}`);
if (res.ok) {
const saved = await res.json();
if (saved.status === 'success' && saved.description) {
window.currentDetailDataForCleanTab = {
data: saved.description.description_data,
description: saved.description
};
window.currentDetailNameForCleanTab = saved.description.product_name || `Sản phẩm ${code}`;
currentDetailCode = code;
renderCleanDataForm();
return;
}
}
alert("Không tìm thấy dữ liệu cho mã này. Hãy generate trước ở tab Sản phẩm.");
document.getElementById('cleanDataEditor').style.display = 'none';
document.getElementById('cleanDataEmpty').style.display = 'block';
} catch (e) { alert("Lỗi khi tải dữ liệu"); }
}
function renderCleanDataForm() {
const data = window.currentDetailDataForCleanTab;
const name = window.currentDetailNameForCleanTab;
if(!data) return;
document.getElementById('cleanDataEmpty').style.display = 'none';
document.getElementById('cleanDataEditor').style.display = 'block';
document.getElementById('cleanDataTitle').style.display = 'block';
document.getElementById('btnSaveCleanData').style.display = 'inline-block';
const c = data.description || {};
const d = data.description?.description_data || data.data || {};
document.getElementById('cleanDataTitle').innerHTML = `Đang chỉnh sửa: <span style="color:#000;">${name || c.internal_ref_code || currentDetailCode}</span> (${currentDetailCode})`;
const editBody = document.getElementById('cleanDataEditor');
let editHtml = `<div style="display:flex; flex-direction:column; gap:16px;">`;
const orderedKeys = ['ten_san_pham', 'tagline', 'mo_ta_chinh', 'phong_cach', 'chat_lieu', 'tinh_nang_vai', 'huong_dan_bao_quan'];
const allKeys = Object.keys(d);
const remainingKeys = allKeys.filter(k => !orderedKeys.includes(k)).sort();
const sortedKeys = [...orderedKeys.filter(k => allKeys.includes(k)), ...remainingKeys];
sortedKeys.forEach(k => {
editHtml += `
<div style="background:#fff; border:1px solid #e5e7eb; border-radius:8px; padding:16px;">
<label style="display:block; font-size:13px; font-weight:700; color:var(--primary); margin-bottom:8px; text-transform:uppercase;">${esc(k)} <button onclick="deleteField('${k}')" style="float:right; border:none; background:none; color:var(--warn); cursor:pointer; font-size:11px; font-weight:bold;">✕ Xóa trường này</button></label>
<textarea id="editField_${k}" data-key="${k}" style="width:100%; box-sizing:border-box; border:1px solid #d1d5db; border-radius:6px; padding:10px; font-size:14px; font-family:inherit; min-height:60px; line-height:1.5; resize:vertical;">${esc(d[k] || '')}</textarea>
</div>`;
});
editHtml += `
<div style="background:#f0f9ff; border:2px dashed #bae6fd; border-radius:8px; padding:16px; margin-top:12px;">
<label style="display:block; font-size:13px; font-weight:700; color:#0369a1; margin-bottom:10px;">➕ BỔ SUNG TRƯỜNG MỚI</label>
<div style="display:flex; gap:10px;">
<input type="text" id="newFieldKey" placeholder="Tên key (vd: dac_diem_noi_bat)" style="flex:1; border:1px solid #bae6fd; border-radius:6px; padding:10px; font-size:14px; outline:none;">
<input type="text" id="newFieldValue" placeholder="Nội dung" style="flex:2; border:1px solid #bae6fd; border-radius:6px; padding:10px; font-size:14px; outline:none;">
<button class="btn btn-sm" style="background:#0284c7;color:#fff;border:none;font-weight:bold;padding:0 20px;" onclick="addNewField()">Thêm</button>
</div>
</div>
</div>`;
editBody.innerHTML = editHtml;
}
function getSafeDetailData() {
if (window.currentDetailDataForCleanTab) {
if (window.currentDetailDataForCleanTab.description && window.currentDetailDataForCleanTab.description.description_data) return window.currentDetailDataForCleanTab.description.description_data;
if (window.currentDetailDataForCleanTab.data) return window.currentDetailDataForCleanTab.data;
}
return {};
}
function gatherInputsIntoData() {
const inputs = document.querySelectorAll('#cleanDataEditor textarea[data-key]');
const d = getSafeDetailData();
inputs.forEach(el => { d[el.getAttribute('data-key')] = el.value; });
return d;
}
function deleteField(key) {
if (!confirm(`Xóa trường [${key}]?`)) return;
const d = gatherInputsIntoData();
delete d[key];
renderCleanDataForm();
}
function addNewField() {
const k = document.getElementById('newFieldKey').value.trim();
const v = document.getElementById('newFieldValue').value.trim();
if (!k) return alert('Phải nhập Tên key!');
const safe_k = k.toLowerCase().replace(/[^a-z0-9_]/g, '_');
const d = gatherInputsIntoData();
d[safe_k] = v;
renderCleanDataForm();
}
async function saveDetailEdit() {
if (!currentDetailCode) return;
const btn = document.getElementById('btnSaveCleanData');
const orgText = btn.textContent;
btn.textContent = 'Đang lưu...';
btn.disabled = true;
try {
const newData = gatherInputsIntoData();
// Send update request
const res = await fetch(`${API}/saved/${currentDetailCode}/update`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description_data: newData })
});
const data = await res.json();
if (data.status === 'error') throw new Error(data.message);
// update memory
const targetData = getSafeDetailData();
Object.keys(targetData).forEach(k => delete targetData[k]);
Object.assign(targetData, newData);
btn.textContent = '✓ Lưu thành công!';
// Optional: Refresh the main products table to keep data in sync
loadProducts();
setTimeout(() => { btn.textContent = orgText; btn.disabled = false; }, 2000);
setTimeout(() => { btn.textContent = '💾 Lưu'; btn.disabled = false; }, 2000);
// Refresh modal to show updated Clean Text & Raw JSON
showDetailPanel(currentDetail, document.getElementById('detailTitle').textContent.split(' <span')[0]);
switchDetailTab('clean');
} catch (e) {
alert('Lỗi lưu thay đổi: ' + e.message);
btn.textContent = '💾 Lưu';
btn.disabled = false;
}
}
function copyDetailJson() {
if (!currentDetail) return;
const text = currentDetail.description?.rendered_text || "";
if (!text) {
alert("Chưa có Nội dung chuẩn để copy. Hãy lưu lại trước.");
return;
}
navigator.clipboard.writeText(text);
const btn = document.getElementById('btnCopyJson');
const og = btn.textContent;
btn.textContent = '✓ Đã copy!';
btn.style.borderColor = 'var(--success)';
btn.style.color = 'var(--success)';
setTimeout(() => { btn.textContent = og; btn.style.borderColor = ''; btn.style.color = ''; }, 2000);
}
// ═══ Approve / Reject ═══
async function approveDetail(newStatus) {
if (!currentDetailCode) return;
try {
const res = await fetch(`${API}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ internal_ref_code: currentDetailCode, status: newStatus }),
});
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
// Update buttons
document.getElementById('btnApprove').style.display = newStatus === 1 ? 'none' : '';
document.getElementById('btnReject').style.display = newStatus === 1 ? '' : 'none';
// Flash feedback
const label = newStatus === 1 ? '✅ Đã duyệt!' : '✕ Đã hủy duyệt';
const body = document.getElementById('detailBody');
const toast = document.createElement('div');
toast.style.cssText = 'padding:10px 14px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:12px;' +
(newStatus === 1 ? 'background:var(--success-light);color:var(--success)' : 'background:#fff3cd;color:#856404');
toast.textContent = label;
body.prepend(toast);
setTimeout(() => toast.remove(), 3000);
// Refresh stats
loadOverview();
} catch (e) {
alert('Lỗi: ' + e.message);
}
}
// ═══ Approve from Table Row ═══
async function approveRow(code, newStatus, btn) {
const ogHtml = btn.innerHTML;
btn.innerHTML = '<span class="spinner-sm"></span>';
btn.disabled = true;
try {
const res = await fetch(`${API}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ internal_ref_code: code, status: newStatus }),
});
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
// Update the button itself
if (newStatus === 1) {
btn.style.background = '#dc3545';
btn.innerHTML = '✕ Hủy';
btn.setAttribute('onclick', `event.stopPropagation();approveRow('${code}',0,this)`);
} else {
btn.style.background = '#28a745';
btn.innerHTML = '✓ Duyệt';
btn.setAttribute('onclick', `event.stopPropagation();approveRow('${code}',1,this)`);
}
// Update status badge in same row
const row = btn.closest('tr');
if (row) {
const badge = row.querySelector('.status-badge');
if (badge) {
if (newStatus === 1) {
badge.className = 'status-badge done';
badge.textContent = '✅ Đã duyệt';
} else {
badge.className = 'status-badge pending';
badge.textContent = '⏳ Chờ duyệt';
}
}
}
// Refresh stats
loadOverview();
} catch (e) {
alert('Lỗi: ' + e.message);
btn.innerHTML = ogHtml;
} finally {
btn.disabled = false;
}
}
// ═══ Tabs & Fields ═══
let currentTab = 'products';
function switchTab(tab) {
currentTab = tab;
document.getElementById('tabProducts').className = tab === 'products' ? 'tab-btn active' : 'tab-btn';
document.getElementById('tabProducts').style.borderBottomColor = tab === 'products' ? 'var(--primary)' : 'transparent';
document.getElementById('tabProducts').style.color = tab === 'products' ? 'var(--primary)' : 'var(--muted-fg)';
document.getElementById('tabFields').className = tab === 'fields' ? 'tab-btn active' : 'tab-btn';
document.getElementById('tabFields').style.borderBottomColor = tab === 'fields' ? 'var(--primary)' : 'transparent';
document.getElementById('tabFields').style.color = tab === 'fields' ? 'var(--primary)' : 'var(--muted-fg)';
const tabCleanData = document.getElementById('tabCleanData');
if (tabCleanData) {
tabCleanData.className = tab === 'cleanData' ? 'tab-btn active' : 'tab-btn';
tabCleanData.style.borderBottomColor = tab === 'cleanData' ? 'var(--primary)' : 'transparent';
tabCleanData.style.color = tab === 'cleanData' ? 'var(--primary)' : 'var(--muted-fg)';
}
document.getElementById('sectionProducts').style.display = tab === 'products' ? 'block' : 'none';
document.getElementById('sectionFields').style.display = tab === 'fields' ? 'block' : 'none';
document.getElementById('sectionCleanData').style.display = tab === 'cleanData' ? 'block' : 'none';
if (tab === 'fields') {
loadFields();
} else {
loadProducts();
}
}
async function loadFields() {
const tbody = document.getElementById('fieldsTableBody');
tbody.innerHTML = '<tr><td colspan="5" class="table-loading"><div class="spinner-sm"></div> Đang tải...</td></tr>';
try {
const res = await fetch(`${API}/fields`);
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
if (data.data.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="table-loading">Chưa có cấu hình trường nào.</td></tr>';
return;
}
tbody.innerHTML = data.data.map((f, i) => `
<tr style="opacity: ${f.is_active ? '1' : '0.5'}">
<td style="font-family:var(--font-mono); font-size:12px; font-weight:600;">${esc(f.field_key)}</td>
<td style="font-weight:600;">${esc(f.field_label)}</td>
<td style="font-size:12px; color:var(--secondary-fg);">${esc(f.field_instruction)}</td>
<td>
<button class="btn btn-sm" style="background:${f.is_active ? '#28a745' : '#6c757d'}; color:#fff; border:none;" onclick="toggleField('${f.field_key}', ${!f.is_active})">${f.is_active ? 'Bật' : 'Tắt'}</button>
</td>
<td>
<button class="btn btn-outline btn-sm" onclick="deleteField('${f.field_key}')" style="color:var(--warn); border-color:var(--warn);">Xóa</button>
</td>
</tr>
`).join('');
} catch (e) {
tbody.innerHTML = `<tr><td colspan="5" class="table-loading" style="color:var(--warn)">Lỗi: ${esc(e.message)}</td></tr>`;
}
}
async function toggleField(key, newStatus) {
try {
const res = await fetch(`${API}/fields/${key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: newStatus }),
});
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
loadFields();
} catch (e) {
alert('Lỗi: ' + e.message);
}
}
async function deleteField(key) {
if (!confirm(`Bạn có chắc chắn muốn xóa trường ${key}?`)) return;
try {
const res = await fetch(`${API}/fields/${key}`, { method: 'DELETE' });
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
loadFields();
} catch (e) {
alert('Lỗi: ' + e.message);
}
}
async function showFieldForm() {
const key = prompt('Nhập Key (viết liền không dấu, VD: chat_lieu_moi):');
if (!key) return;
const label = prompt('Nhập Label hiển thị:');
if (!label) return;
const instruction = prompt('Nhập Prompt (hướng dẫn AI sinh dữ liệu cho trường này):');
try {
const res = await fetch(`${API}/fields`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ field_key: key, field_label: label, field_instruction: instruction || '' }),
});
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
loadFields();
} catch (e) {
alert('Lỗi: ' + e.message);
}
}
// ═══ Utils ═══
function esc(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = String(str);
return d.innerHTML;
}
// ═══ Edit Text in Popup ═══
async function savePopupText() {
if (!currentDetailCode) return;
const editor = document.getElementById('cleanTextEditor');
if (!editor) return;
const originalHtml = editor.innerHTML;
const btn = document.getElementById('btnSavePopupText');
const oldText = btn.innerText;
btn.innerText = 'Đang lưu...';
btn.disabled = true;
try {
const res = await fetch(`${API}/saved/${encodeURIComponent(currentDetailCode)}/update-text`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clean_description: editor.innerHTML })
});
const data = await res.json();
if (data.status === 'success') {
btn.innerText = '✅ Đã lưu';
setTimeout(() => { btn.innerText = oldText; btn.disabled = false; }, 2000);
// Update the local cache so if they close and reopen it's preserved
if(window.currentDetailDataForCleanTab && window.currentDetailDataForCleanTab.description) {
window.currentDetailDataForCleanTab.description.rendered_text = editor.innerHTML;
}
} else {
throw new Error(data.message || 'Lỗi không xác định');
}
} catch (e) {
alert('Lỗi lưu text: ' + e.message);
btn.innerText = oldText;
btn.disabled = false;
}
}
function copyPopupText() {
const editor = document.getElementById('cleanTextEditor');
if (!editor) return;
navigator.clipboard.writeText(editor.innerText).then(() => {
const btn = document.getElementById('btnCopyJson');
const oldText = btn.innerText;
btn.innerText = '✅ Đã Copy';
setTimeout(() => { btn.innerText = oldText; }, 2000);
});
}
</script>
</body>
</html>
......@@ -217,7 +217,7 @@ button{cursor:pointer;font-family:inherit}
<!-- Detail view -->
<div class="card-body" id="detail-area">
<div class="empty-state">
<p>Click test case ben trai<br>de xem ket qua chi tiet</p>
<p>Click test case bên trái<br>để xem kết quả chi tiết</p>
</div>
</div>
</div>
......@@ -249,21 +249,21 @@ button{cursor:pointer;font-family:inherit}
<script>
// ═══ INITIAL PROMPT ═══
const INITIAL_PROMPT = `Ban la tro ly tu van thoi trang cua Canifa. Hay tra loi cau hoi cua khach hang mot cach than thien va chuyen nghiep.
const INITIAL_PROMPT = `Bạn là trợ lý tư vấn thời trang của Canifa. Hãy trả lời câu hỏi của khách hàng một cách thân thiện và chuyên nghiệp.
Huong dan:
- Luon chao hoi than thien, xung "ban"
- Khi khach hoi ve san pham, goi tool data_retrieval_tool de tim kiem
- KHONG duoc bia san pham, gia, khuyen mai khong co that
- Neu khach chua ro nhu cau, hoi them de tu van chinh xac
- Tra loi bang tieng Viet, ngon ngu tu nhien`;
Hướng dẫn:
- Luôn chào hỏi thân thiện, xưng "bạn"
- Khi khách hỏi về sản phẩm, gọi tool data_retrieval_tool để tìm kiếm
- KHÔNG được bịa sản phẩm, giá, khuyến mãi không có thật
- Nếu khách chưa rõ nhu cầu, hỏi thêm để tư vấn chính xác
- Trả lời bằng tiếng Việt, ngôn ngữ tự nhiên`;
const DEFAULT_TESTS = [
{id:1, input:"Toi muon mua ao di lam, khong biet chon mau gi?", expect_keywords:["mau","cong so","phong cach"], expect_question:true},
{id:2, input:"Shop co vay du tiec khong?", expect_keywords:["da","co","mau"], expect_question:true},
{id:3, input:"Ngan sach 500k mua duoc gi?", expect_keywords:["500","san pham","goi y"], expect_question:false},
{id:4, input:"Ao khoac nao hop mua dong?", expect_keywords:["am","chat lieu","kieu"], expect_question:true},
{id:5, input:"Toi cao 1m55 nang 50kg mac size gi?", expect_keywords:["size","S","M"], expect_question:false},
{id:1, input:"Tôi muốn mua áo đi làm, không biết chọn màu gì?", expect_keywords:["màu","công sở","phong cách"], expect_question:true},
{id:2, input:"Shop có váy dự tiệc không?", expect_keywords:["dạ","có","màu"], expect_question:true},
{id:3, input:"Ngân sách 500k mua được gì?", expect_keywords:["500","sản phẩm","gợi ý"], expect_question:false},
{id:4, input:"Áo khoác nào hợp mùa đông?", expect_keywords:["ấm","chất liệu","kiểu"], expect_question:true},
{id:5, input:"Tôi cao 1m55 nặng 50kg mặc size gì?", expect_keywords:["size","S","M"], expect_question:false},
];
// ═══ STATE ═══
......@@ -371,7 +371,7 @@ function renderLog() {
function renderDetail() {
const el = document.getElementById('detail-area');
if (!selectedId || !results[selectedId]) {
el.innerHTML = '<div class="empty-state"><p>Click test case ben trai<br>de xem ket qua chi tiet</p></div>';
el.innerHTML = '<div class="empty-state"><p>Click test case bên trái<br>để xem kết quả chi tiết</p></div>';
return;
}
const r = results[selectedId];
......@@ -524,7 +524,7 @@ function startEditPrompt() {
function applyPromptEdit() {
currPrompt = document.getElementById('prompt-editor').value;
cancelPromptEdit();
addLog('Prompt da duoc chinh sua thu cong', 'info');
addLog('Prompt đã được chỉnh sửa thủ công', 'info');
}
function cancelPromptEdit() {
document.getElementById('prompt-display').style.display = '';
......@@ -545,18 +545,18 @@ function selectTest(id) {
function deleteTC(id) {
testCases = testCases.filter(t => t.id !== id);
delete results[id];
addLog(`Test #${id} da xoa`, 'info');
addLog(`Test #${id} đã xoá`, 'info');
renderTests();
renderScoreGrid();
}
function addNewTC() {
const input = prompt('Nhap cau hoi test:');
const input = prompt('Nhập câu hỏi test:');
if (!input) return;
const kw = prompt('Keywords (cach boi dau phay):') || '';
const q = confirm('Mong bot hoi lai khach?');
const kw = prompt('Keywords (cách bởi dấu phẩy):') || '';
const q = confirm('Mong bot hỏi lại khách?');
const id = Date.now();
testCases.push({id, input, expect_keywords: kw.split(',').map(s=>s.trim()).filter(Boolean), expect_question: q});
addLog('Test case moi them', 'info');
addLog('Test case mới thêm', 'info');
renderTests();
renderScoreGrid();
}
......@@ -649,7 +649,7 @@ async function runRound() {
if (selectedId === tc.id) renderDetail();
// Step 2: Judge
addLog(`Judge dang danh gia #${tc.id}...`, 'optimize');
addLog(`Judge đang đánh giá #${tc.id}...`, 'optimize');
let judgeResult;
try {
judgeResult = await apiJudge(tc.input, chatResult.output, chatResult.product_ids || [], tc.expect_keywords, tc.expect_question);
......@@ -713,7 +713,7 @@ async function runRound() {
async function optimize(roundResults, avg) {
document.getElementById('optimizing-indicator').style.display = 'inline-flex';
addLog('Score < 75% — Optimizer dang phan tich...', 'optimize');
addLog('Score < 75% — Optimizer đang phân tích...', 'optimize');
const testResults = Object.values(roundResults);
try {
......@@ -752,7 +752,7 @@ async function startLoop() {
document.getElementById('proposal-box').style.display = 'none';
updateTopbar();
addLog('Optimizer khoi dong', 'system');
addLog('Optimizer khởi động', 'system');
addLog(`${testCases.length} test cases · Target >= 75%`, 'info');
switchTab('log');
renderTests(); renderScoreGrid(); renderSparkline(); renderMetrics();
......@@ -763,7 +763,7 @@ async function startLoop() {
const {roundResults, avg} = await runRound();
if (!running) break;
if (avg >= 0.75) {
addLog(`Target dat! ${(avg*100).toFixed(1)}% >= 75%`, 'pass');
addLog(`Target đạt! ${(avg*100).toFixed(1)}% >= 75%`, 'pass');
break;
}
if (round < 6) {
......
......@@ -3,92 +3,124 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kế hoạch phát triển — Canifa AI</title>
<title>Kế hoạch phát triển</title>
<link rel="stylesheet" href="/static/css/theme.css">
<link rel="stylesheet" href="/static/css/components.css">
<script src="/static/js/frame-detect.js"></script>
<style>
body { margin:0; min-height:100vh; background:var(--bg); font-family:var(--font-sans); color:var(--foreground); }
.page { max-width:800px; margin:0 auto; padding:28px 24px 60px; }
.page-header { margin-bottom:32px; }
.page-header h1 { font-size:24px; font-weight:700; margin:0 0 6px; }
.page-header p { font-size:14px; color:var(--muted-fg); margin:0; }
.version-block { margin-bottom:32px; }
.version-title { display:flex; align-items:center; gap:10px; margin-bottom:14px; }
.version-title h2 { font-size:16px; font-weight:700; margin:0; }
.version-badge { font-size:11px; font-weight:700; padding:2px 8px; border-radius:999px; text-transform:uppercase; letter-spacing:.04em; }
.badge-current { background:var(--success-bg,#dcfce7); color:var(--success,#16a34a); }
.badge-next { background:var(--primary-light,#e0f2fe); color:var(--primary,#3b5998); }
.badge-future { background:var(--muted,#f0efec); color:var(--muted-fg,#78716c); }
.version-date { font-size:12px; color:var(--muted-fg); }
.feature-list { list-style:none; padding:0; margin:0; display:flex; flex-direction:column; gap:8px; }
.feature-item { display:flex; align-items:flex-start; gap:10px; padding:10px 14px; background:var(--card); border:1px solid var(--border); border-radius:10px; }
.feature-check { width:18px; height:18px; border-radius:4px; border:2px solid var(--border); display:flex; align-items:center; justify-content:center; flex-shrink:0; margin-top:1px; font-size:11px; }
.feature-check.done { background:var(--success,#16a34a); border-color:var(--success,#16a34a); color:white; }
.feature-check.wip { background:var(--warning-bg,#fef3c7); border-color:var(--warning,#d97706); color:var(--warning); }
.feature-body { flex:1; }
.feature-name { font-size:13px; font-weight:600; }
.feature-desc { font-size:12px; color:var(--muted-fg); margin-top:2px; }
hr.section-sep { border:none; border-top:1px solid var(--border); margin:0; }
body{margin:0;min-height:100vh;background:var(--bg);font-family:var(--font-sans);color:var(--foreground)}
.page{max-width:640px;margin:0 auto;padding:24px 20px 60px}
.hdr h1{font-size:18px;font-weight:800;margin:0 0 4px}
.hdr p{font-size:12px;color:var(--muted-fg);margin:0 0 20px}
/* Composer */
.composer{border:1px solid var(--border);border-radius:10px;background:var(--card);margin-bottom:20px;overflow:hidden}
.composer-box{width:100%;min-height:100px;padding:14px 16px;border:none;outline:none;font:inherit;font-size:13px;color:var(--foreground);background:transparent;resize:none;line-height:1.6;box-sizing:border-box}
.composer-box::placeholder{color:var(--muted-fg);opacity:.5}
.composer-foot{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;border-top:1px solid var(--border)}
.composer-foot .hint{font-size:10px;color:var(--muted-fg)}
.post-btn{padding:6px 16px;border:none;border-radius:6px;background:var(--foreground);color:var(--bg);font:inherit;font-size:12px;font-weight:700;cursor:pointer}
.post-btn:hover{opacity:.85}
.post-btn:disabled{opacity:.3;cursor:default}
/* Feed */
.feed{display:flex;flex-direction:column;gap:8px}
.memo{border:1px solid var(--border);border-radius:10px;background:var(--card);padding:14px 16px;transition:box-shadow .1s}
.memo:hover{box-shadow:0 1px 6px rgba(0,0,0,.04)}
.memo:hover .memo-act{opacity:1}
.memo-head{display:flex;align-items:center;gap:6px;margin-bottom:6px}
.memo-time{font-size:10px;color:var(--muted-fg)}
.memo-pin{font-size:9px;font-weight:700;padding:1px 5px;border-radius:4px;background:#fef3c7;color:#d97706}
.memo-title{font-size:13px;font-weight:700;margin-bottom:4px}
.memo-text{font-size:12.5px;line-height:1.65;white-space:pre-wrap;word-break:break-word;color:var(--muted-fg)}
.memo-act{opacity:0;display:flex;gap:4px;margin-top:8px;transition:opacity .12s}
.memo-act button{padding:3px 8px;border:1px solid var(--border);border-radius:5px;background:transparent;font:inherit;font-size:10px;color:var(--muted-fg);cursor:pointer}
.memo-act button:hover{border-color:var(--foreground);color:var(--foreground)}
.memo-act .del:hover{border-color:#dc2626;color:#dc2626}
.empty{text-align:center;padding:40px 0;color:var(--muted-fg);font-size:13px}
</style>
</head>
<body>
<div class="page">
<div class="page-header">
<h1>📋 Kế hoạch phát triển</h1>
<p>Roadmap phát triển hệ thống Canifa AI Platform</p>
<div class="hdr">
<h1>Kế hoạch phát triển</h1>
<p>Ghi chép kế hoạch version, ý tưởng, thay đổi hệ thống</p>
</div>
<!-- v2.5.0 - Current -->
<div class="version-block">
<div class="version-title">
<h2>v2.5.0</h2>
<span class="version-badge badge-current">Current</span>
<span class="version-date">Mar 2026</span>
<div class="composer">
<textarea class="composer-box" id="input" placeholder="Viết kế hoạch, ghi chú, ý tưởng..."></textarea>
<div class="composer-foot">
<span class="hint">Nhấn Lưu để post</span>
<button class="post-btn" id="postBtn" onclick="post()">Lưu</button>
</div>
<ul class="feature-list">
<li class="feature-item"><div class="feature-check done"></div><div class="feature-body"><div class="feature-name">AI SQL Agent (Text-to-SQL)</div><div class="feature-desc">Truy vấn dữ liệu bằng ngôn ngữ tự nhiên với Human-in-the-Loop</div></div></li>
<li class="feature-item"><div class="feature-check done"></div><div class="feature-body"><div class="feature-name">Memos-inspired UI Redesign</div><div class="feature-desc">Theme mới, modular CSS, Settings modal giống Memos</div></div></li>
<li class="feature-item"><div class="feature-check done"></div><div class="feature-body"><div class="feature-name">Prompt Optimizer</div><div class="feature-desc">Tối ưu prompt tự động với AI feedback loop</div></div></li>
<li class="feature-item"><div class="feature-check done"></div><div class="feature-body"><div class="feature-name">User Simulator & Stress Test</div><div class="feature-desc">Mô phỏng user và stress test hệ thống</div></div></li>
<li class="feature-item"><div class="feature-check done"></div><div class="feature-body"><div class="feature-name">Realtime Monitor</div><div class="feature-desc">Dashboard theo dõi hệ thống real-time</div></div></li>
<li class="feature-item"><div class="feature-check wip"></div><div class="feature-body"><div class="feature-name">History Viewer v2</div><div class="feature-desc">Date picker, product cards, cải thiện UX xem lịch sử</div></div></li>
</ul>
</div>
<hr class="section-sep">
<!-- v2.6.0 - Next -->
<div class="version-block" style="margin-top:28px;">
<div class="version-title">
<h2>v2.6.0</h2>
<span class="version-badge badge-next">Planned</span>
<span class="version-date">Q2 2026</span>
</div>
<ul class="feature-list">
<li class="feature-item"><div class="feature-check"></div><div class="feature-body"><div class="feature-name">Multi-Agent Orchestration</div><div class="feature-desc">Nhiều agent phối hợp: SQL Agent, Report Agent, Analytics Agent</div></div></li>
<li class="feature-item"><div class="feature-check"></div><div class="feature-body"><div class="feature-name">AI Data Analyst v2</div><div class="feature-desc">Tự động phân tích dữ liệu và tạo báo cáo insights</div></div></li>
<li class="feature-item"><div class="feature-check"></div><div class="feature-body"><div class="feature-name">Compact Sidebar (Memos-style)</div><div class="feature-desc">Sidebar thu gọn icon-only, expand on hover</div></div></li>
<li class="feature-item"><div class="feature-check"></div><div class="feature-body"><div class="feature-name">Dark Mode</div><div class="feature-desc">Theme tối cho platform</div></div></li>
</ul>
<div class="feed" id="feed">
<div class="empty">Đang tải...</div>
</div>
</div>
<hr class="section-sep">
<script>
const API = '/api/dashboard/notes';
<!-- v3.0.0 - Future -->
<div class="version-block" style="margin-top:28px;">
<div class="version-title">
<h2>v3.0.0</h2>
<span class="version-badge badge-future">Future</span>
<span class="version-date">Q3-Q4 2026</span>
</div>
<ul class="feature-list">
<li class="feature-item"><div class="feature-check"></div><div class="feature-body"><div class="feature-name">Customer Analytics Dashboard</div><div class="feature-desc">Phân tích hành vi khách hàng từ chat data</div></div></li>
<li class="feature-item"><div class="feature-check"></div><div class="feature-body"><div class="feature-name">A/B Testing Engine</div><div class="feature-desc">So sánh prompt/model variants trên live traffic</div></div></li>
<li class="feature-item"><div class="feature-check"></div><div class="feature-body"><div class="feature-name">Self-Learning System</div><div class="feature-desc">Auto-tune prompts dựa trên feedback data</div></div></li>
<li class="feature-item"><div class="feature-check"></div><div class="feature-body"><div class="feature-name">Mobile Admin App</div><div class="feature-desc">Quản lý chatbot từ điện thoại</div></div></li>
</ul>
</div>
</div>
async function post(){
const inp = document.getElementById('input');
const text = inp.value.trim();
if(!text) return;
inp.value = '';
await fetch(API, {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({title:'', content:text, category:'note', pinned:false})
});
load();
}
async function load(){
const r = await fetch(API);
const data = await r.json();
const notes = data.notes || [];
if(!notes.length){
document.getElementById('feed').innerHTML = '<div class="empty">Chưa có ghi chú nào.</div>';
return;
}
document.getElementById('feed').innerHTML = notes.map(n => {
const d = new Date(n.created_at);
const time = d.toLocaleDateString('vi-VN') + ' ' + d.toLocaleTimeString('vi-VN',{hour:'2-digit',minute:'2-digit'});
const title = n.title ? `<div class="memo-title">${esc(n.title)}</div>` : '';
return `
<div class="memo">
<div class="memo-head">
<span class="memo-time">${time}</span>
${n.pinned ? '<span class="memo-pin">PIN</span>' : ''}
</div>
${title}
<div class="memo-text">${esc(n.content)}</div>
<div class="memo-act">
<button onclick="pin('${n.id}',${!n.pinned})">${n.pinned?'Bỏ pin':'Pin'}</button>
<button class="del" onclick="del('${n.id}')">Xóa</button>
</div>
</div>`;
}).join('');
}
function esc(s){return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
async function pin(id,val){
await fetch(API+'/'+id+'/pin',{method:'PATCH'});
load();
}
async function del(id){
if(!confirm('Xóa ghi chú này?')) return;
await fetch(API+'/'+id,{method:'DELETE'});
load();
}
load();
</script>
</body>
</html>
......@@ -10,154 +10,154 @@
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Inter',system-ui,sans-serif;background:#F8F9FC;min-height:100vh;color:#1a1a2e}
body{font-family:var(--font-sans,'Inter',system-ui,sans-serif);background:var(--background,#f8f7f4);min-height:100vh;color:var(--foreground,#1c1917)}
.app-wrap{display:flex;height:100vh;overflow:hidden}
.user-list-pane{width:320px;border-right:1px solid #E5E7EB;display:flex;flex-direction:column;background:#fff;flex-shrink:0}
.profile-pane{flex:1;overflow-y:auto;background:#F8F9FC}
.list-header{padding:20px 20px 16px;border-bottom:1px solid #E5E7EB}
.list-header h2{font-size:18px;font-weight:800;color:#1a1a2e;margin-bottom:4px}
.list-header p{font-size:12px;color:#6B7280}
.search-box{margin-top:12px;width:100%;padding:9px 14px;border:1px solid #E5E7EB;border-radius:10px;font-size:13px;outline:none;background:#F9FAFB}
.search-box:focus{border-color:#6366F1;box-shadow:0 0 0 3px rgba(99,102,241,.1)}
.user-list-pane{width:320px;border-right:1px solid var(--border,#e7e5e2);display:flex;flex-direction:column;background:var(--card,#ffffff);flex-shrink:0}
.profile-pane{flex:1;overflow-y:auto;background:var(--background,#f8f7f4)}
.list-header{padding:20px 20px 16px;border-bottom:1px solid var(--border,#e7e5e2)}
.list-header h2{font-size:18px;font-weight:800;color:var(--foreground,#1c1917);margin-bottom:4px}
.list-header p{font-size:12px;color:var(--muted-fg,#78716c)}
.search-box{margin-top:12px;width:100%;padding:9px 14px;border:1px solid var(--border,#e7e5e2);border-radius:10px;font-size:13px;outline:none;background:var(--muted,#f0efec)}
.search-box:focus{border-color:var(--primary,#3b5998);box-shadow:0 0 0 3px rgba(59,89,152,0.08)}
.user-items{flex:1;overflow-y:auto;padding:8px}
.user-item{display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:12px;cursor:pointer;transition:all .15s;border:1.5px solid transparent;margin-bottom:4px}
.user-item:hover{background:#F3F4F6}
.user-item.active{background:#EEF2FF;border-color:#6366F1}
.user-item:hover{background:var(--muted,#f0efec)}
.user-item.active{background:var(--primary-light,#eef1f8);border-color:var(--primary,#3b5998)}
.user-avatar{width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:800;color:#fff;flex-shrink:0}
.user-item-info{flex:1;min-width:0}
.user-item-name{font-size:14px;font-weight:700;color:#1a1a2e;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.user-item-meta{font-size:11px;color:#6B7280;margin-top:2px}
.user-item-name{font-size:14px;font-weight:700;color:var(--foreground,#1c1917);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.user-item-meta{font-size:11px;color:var(--muted-fg,#78716c);margin-top:2px}
.user-item-right{text-align:right;flex-shrink:0}
.user-item-right .val{font-size:14px;font-weight:800;color:#1a1a2e}
.user-item-right .lbl{font-size:10px;color:#6B7280}
.user-item-right .val{font-size:14px;font-weight:800;color:var(--foreground,#1c1917)}
.user-item-right .lbl{font-size:10px;color:var(--muted-fg,#78716c)}
.profile-content{max-width:980px;margin:0 auto;padding:24px 28px 40px}
.profile-empty{display:flex;align-items:center;justify-content:center;height:100%;text-align:center;color:#9CA3AF;font-size:15px}
.profile-empty{display:flex;align-items:center;justify-content:center;height:100%;text-align:center;color:var(--muted-fg,#78716c);font-size:15px}
/* Tabs */
.profile-tabs{display:flex;gap:32px;border-bottom:2px solid #E5E7EB;margin-bottom:24px}
.tab-btn{padding:0 8px 16px;background:none;border:none;font-size:15px;font-weight:700;color:#6B7280;cursor:pointer;position:relative;transition:color .2s}
.tab-btn:hover{color:#1a1a2e}
.tab-btn.active{color:#6366F1}
.tab-btn.active::after{content:'';position:absolute;bottom:-2px;left:0;right:0;height:3px;background:#6366F1;border-radius:3px 3px 0 0}
.profile-tabs{display:flex;gap:32px;border-bottom:2px solid var(--border,#e7e5e2);margin-bottom:24px}
.tab-btn{padding:0 8px 16px;background:none;border:none;font-size:15px;font-weight:700;color:var(--muted-fg,#78716c);cursor:pointer;position:relative;transition:color .2s}
.tab-btn:hover{color:var(--foreground,#1c1917)}
.tab-btn.active{color:var(--primary,#3b5998)}
.tab-btn.active::after{content:'';position:absolute;bottom:-2px;left:0;right:0;height:3px;background:var(--primary,#3b5998);border-radius:3px 3px 0 0}
.tab-pane{display:none;animation:fadeIn .3s ease}
.tab-pane.active{display:block}
@keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
.section{background:#fff;border:1px solid #E5E7EB;border-radius:14px;padding:20px 24px;margin-bottom:16px}
.section-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#6B7280;margin-bottom:14px}
.section{background:var(--card,#ffffff);border:1px solid var(--border,#e7e5e2);border-radius:14px;padding:20px 24px;margin-bottom:16px}
.section-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-fg,#78716c);margin-bottom:14px}
/* Overview */
.overview-grid{display:flex;align-items:center;gap:20px}
.overview-avatar{width:72px;height:72px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:28px;font-weight:800;color:#fff;flex-shrink:0}
.overview-details{flex:1;display:grid;grid-template-columns:1fr 1fr 1fr;gap:4px 24px}
.overview-field-label{font-size:10px;color:#9CA3AF;font-weight:600}
.overview-field-value{font-size:14px;font-weight:700;color:#1a1a2e;margin-bottom:8px}
.channel-tag{display:inline-block;font-size:10px;font-weight:700;padding:3px 8px;border-radius:6px;background:#EEF2FF;color:#4338CA;margin-right:4px;margin-top:4px}
.overview-field-label{font-size:10px;color:var(--muted-fg,#78716c);font-weight:600}
.overview-field-value{font-size:14px;font-weight:700;color:var(--foreground,#1c1917);margin-bottom:8px}
.channel-tag{display:inline-block;font-size:10px;font-weight:700;padding:3px 8px;border-radius:6px;background:var(--primary-light,#eef1f8);color:var(--primary,#3b5998);margin-right:4px;margin-top:4px}
/* Health Score */
.health-row{display:grid;grid-template-columns:180px 1fr;gap:20px;align-items:center}
.health-ring{width:140px;height:140px;position:relative;margin:0 auto}
.health-ring svg{width:100%;height:100%}
.health-ring .score-text{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center}
.health-ring .score-num{font-size:36px;font-weight:900;line-height:1}
.health-ring .score-lbl{font-size:10px;font-weight:700;color:#6B7280;text-transform:uppercase;letter-spacing:.06em}
.health-ring .score-lbl{font-size:10px;font-weight:700;color:var(--muted-fg,#78716c);text-transform:uppercase;letter-spacing:.06em}
.health-factors{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px}
.factor-card{padding:14px;border-radius:10px;border:1px solid #E5E7EB;background:#F9FAFB}
.factor-card{padding:14px;border-radius:10px;border:1px solid var(--border,#e7e5e2);background:var(--muted,#f0efec)}
.factor-card .f-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
.factor-card .f-name{font-size:11px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:.04em}
.factor-card .f-name{font-size:11px;font-weight:700;color:var(--foreground,#1c1917);text-transform:uppercase;letter-spacing:.04em}
.factor-card .f-score{font-size:18px;font-weight:900}
.factor-bar{width:100%;height:6px;background:#E5E7EB;border-radius:3px;overflow:hidden}
.factor-bar-fill{height:100%;border-radius:3px;transition:width .6s ease}
/* Metrics */
.metrics-row{display:grid;grid-template-columns:repeat(5,1fr);gap:0}
.metric-cell{text-align:center;padding:12px 8px;border-right:1px solid #F3F4F6}
.metric-cell{text-align:center;padding:12px 8px;border-right:1px solid var(--border,#e7e5e2)}
.metric-cell:last-child{border-right:none}
.metric-cell .m-label{font-size:10px;color:#6B7280;font-weight:600;text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px}
.metric-cell .m-value{font-size:28px;font-weight:800;color:#1a1a2e;line-height:1}
.metric-cell .m-unit{font-size:11px;color:#9CA3AF;font-weight:600}
.metric-cell .m-label{font-size:10px;color:var(--muted-fg,#78716c);font-weight:600;text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px}
.metric-cell .m-value{font-size:28px;font-weight:800;color:var(--foreground,#1c1917);line-height:1}
.metric-cell .m-unit{font-size:11px;color:var(--muted-fg,#78716c);font-weight:600}
/* Predictions */
.pred-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}
.pred-card{padding:16px;border-radius:12px;text-align:center}
.pred-card .pred-icon{font-size:24px;margin-bottom:8px}
.pred-card .pred-val{font-size:22px;font-weight:800;line-height:1;margin-bottom:4px}
.pred-card .pred-lbl{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6B7280}
.pred-card .pred-lbl{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-fg,#78716c)}
.pred-green{background:#F0FDF4;border:1px solid #BBF7D0}.pred-green .pred-val{color:#059669}
.pred-amber{background:#FFFBEB;border:1px solid #FDE68A}.pred-amber .pred-val{color:#D97706}
.pred-red{background:#FEF2F2;border:1px solid #FECACA}.pred-red .pred-val{color:#DC2626}
.pred-blue{background:#EFF6FF;border:1px solid #BFDBFE}.pred-blue .pred-val{color:#2563EB}
/* Three-col */
.three-col{display:grid;grid-template-columns:1fr 1.2fr 1fr;gap:16px}
.segment-row{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #F3F4F6}
.segment-row{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--border,#e7e5e2)}
.segment-row:last-child{border-bottom:none}
.seg-label{font-size:13px;color:#374151}
.seg-label{font-size:13px;color:var(--foreground,#1c1917)}
.seg-value{font-size:13px;font-weight:700}
.seg-high{color:#059669}.seg-medium{color:#D97706}.seg-low{color:#6B7280}.seg-vip{color:#7C3AED}.seg-active{color:#059669}
.seg-high{color:#059669}.seg-medium{color:#D97706}.seg-low{color:var(--muted-fg,#78716c)}.seg-vip{color:#7C3AED}.seg-active{color:#059669}
.donut-wrap{display:flex;flex-direction:column;align-items:center;gap:14px}
.donut-svg{width:140px;height:140px}
.donut-legend{display:flex;flex-direction:column;gap:5px;width:100%}
.legend-row{display:flex;align-items:center;gap:8px;font-size:12px;color:#374151}
.legend-row{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--foreground,#1c1917)}
.legend-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.legend-pct{margin-left:auto;font-weight:700;color:#1a1a2e}
.attr-row{display:flex;justify-content:space-between;padding:7px 0;border-bottom:1px solid #F3F4F6;font-size:13px}
.legend-pct{margin-left:auto;font-weight:700;color:var(--foreground,#1c1917)}
.attr-row{display:flex;justify-content:space-between;padding:7px 0;border-bottom:1px solid var(--border,#e7e5e2);font-size:13px}
.attr-row:last-child{border-bottom:none}
.attr-label{color:#6B7280}.attr-value{font-weight:700;color:#1a1a2e;text-align:right;max-width:55%}
.attr-label{color:var(--muted-fg,#78716c)}.attr-value{font-weight:700;color:var(--foreground,#1c1917);text-align:right;max-width:55%}
/* Product cards */
.product-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
.product-card{display:flex;align-items:center;gap:14px;padding:14px;background:#F9FAFB;border-radius:12px;border:1px solid #E5E7EB}
.product-card-icon{width:52px;height:52px;border-radius:10px;background:#EEF2FF;display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0}
.product-card{display:flex;align-items:center;gap:14px;padding:14px;background:var(--muted,#f0efec);border-radius:12px;border:1px solid var(--border,#e7e5e2)}
.product-card-icon{width:52px;height:52px;border-radius:10px;background:var(--primary-light,#eef1f8);display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0}
.product-card-info{flex:1}
.product-card-label{font-size:10px;color:#6B7280;font-weight:600;text-transform:uppercase;letter-spacing:.06em;margin-bottom:3px}
.product-card-name{font-size:13px;font-weight:700;color:#1a1a2e;margin-bottom:2px}
.product-card-price{font-size:18px;font-weight:800;color:#1a1a2e}
.product-card-label{font-size:10px;color:var(--muted-fg,#78716c);font-weight:600;text-transform:uppercase;letter-spacing:.06em;margin-bottom:3px}
.product-card-name{font-size:13px;font-weight:700;color:var(--foreground,#1c1917);margin-bottom:2px}
.product-card-price{font-size:18px;font-weight:800;color:var(--foreground,#1c1917)}
/* Purchase Timeline */
.timeline{position:relative;padding-left:28px}
.timeline::before{content:'';position:absolute;left:10px;top:4px;bottom:4px;width:2px;background:#E5E7EB;border-radius:1px}
.tl-item{position:relative;padding:10px 0 14px}
.tl-item::before{content:'';position:absolute;left:-22px;top:14px;width:10px;height:10px;border-radius:50%;background:#6366F1;border:2px solid #fff;box-shadow:0 0 0 2px #6366F1;z-index:1}
.tl-date{font-size:11px;font-weight:600;color:#9CA3AF;margin-bottom:4px}
.tl-item::before{content:'';position:absolute;left:-22px;top:14px;width:10px;height:10px;border-radius:50%;background:var(--primary,#3b5998);border:2px solid #fff;box-shadow:0 0 0 2px var(--primary,#3b5998);z-index:1}
.tl-date{font-size:11px;font-weight:600;color:var(--muted-fg,#78716c);margin-bottom:4px}
.tl-content{display:flex;align-items:center;gap:10px}
.tl-icon{font-size:18px}
.tl-detail{flex:1}
.tl-name{font-size:13px;font-weight:700;color:#1a1a2e}
.tl-sub{font-size:11px;color:#6B7280}
.tl-price{font-size:14px;font-weight:800;color:#1a1a2e;flex-shrink:0}
.tl-name{font-size:13px;font-weight:700;color:var(--foreground,#1c1917)}
.tl-sub{font-size:11px;color:var(--muted-fg,#78716c)}
.tl-price{font-size:14px;font-weight:800;color:var(--foreground,#1c1917);flex-shrink:0}
/* Recommendation Engine */
.reco-section{border-top:1px solid #E5E7EB;padding-top:16px;margin-top:6px}
.reco-strat-label{font-size:11px;font-weight:700;color:#6366F1;margin-bottom:10px;display:flex;align-items:center;gap:6px}
.reco-section{border-top:1px solid var(--border,#e7e5e2);padding-top:16px;margin-top:6px}
.reco-strat-label{font-size:11px;font-weight:700;color:var(--primary,#3b5998);margin-bottom:10px;display:flex;align-items:center;gap:6px}
.reco-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:18px}
.reco-item{display:flex;align-items:center;gap:10px;padding:10px 14px;background:#F9FAFB;border:1px solid #E5E7EB;border-radius:10px}
.reco-item{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--muted,#f0efec);border:1px solid var(--border,#e7e5e2);border-radius:10px}
.reco-item .reco-icon{font-size:18px;flex-shrink:0}
.reco-item .reco-name{font-size:13px;font-weight:700;color:#1a1a2e}
.reco-item .reco-reason{font-size:11px;color:#6B7280;margin-top:2px}
.reco-item .reco-name{font-size:13px;font-weight:700;color:var(--foreground,#1c1917)}
.reco-item .reco-reason{font-size:11px;color:var(--muted-fg,#78716c);margin-top:2px}
.reco-item .reco-conf{font-size:12px;font-weight:800;flex-shrink:0;margin-left:auto}
/* Next Best Action */
.nba-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
.nba-card{padding:16px;border-radius:12px;border:1px solid #E5E7EB;display:flex;flex-direction:column;gap:8px;cursor:pointer;transition:all .15s}
.nba-card{padding:16px;border-radius:12px;border:1px solid var(--border,#e7e5e2);display:flex;flex-direction:column;gap:8px;cursor:pointer;transition:all .15s}
.nba-card:hover{box-shadow:0 4px 16px rgba(0,0,0,.08);transform:translateY(-2px)}
.nba-card .nba-icon{font-size:28px}
.nba-card .nba-title{font-size:13px;font-weight:800;color:#1a1a2e}
.nba-card .nba-desc{font-size:12px;color:#6B7280;line-height:1.4}
.nba-card .nba-title{font-size:13px;font-weight:800;color:var(--foreground,#1c1917)}
.nba-card .nba-desc{font-size:12px;color:var(--muted-fg,#78716c);line-height:1.4}
.nba-card .nba-tag{display:inline-block;font-size:10px;font-weight:700;padding:3px 8px;border-radius:6px;margin-top:4px}
.nba-urgent .nba-tag{background:#FEF2F2;color:#DC2626}
.nba-medium .nba-tag{background:#FFFBEB;color:#D97706}
.nba-low .nba-tag{background:#F0FDF4;color:#059669}
/* Engagement Heatmap */
.heatmap-grid{display:grid;grid-template-columns:60px repeat(7,1fr);gap:3px;font-size:11px}
.heatmap-grid .h-label{font-weight:600;color:#6B7280;display:flex;align-items:center}
.heatmap-grid .h-day{text-align:center;font-weight:700;color:#6B7280;padding:4px}
.heatmap-grid .h-cell{border-radius:4px;height:28px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:10px;color:#374151}
.heatmap-grid .h-label{font-weight:600;color:var(--muted-fg,#78716c);display:flex;align-items:center}
.heatmap-grid .h-day{text-align:center;font-weight:700;color:var(--muted-fg,#78716c);padding:4px}
.heatmap-grid .h-cell{border-radius:4px;height:28px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:10px;color:var(--foreground,#1c1917)}
/* Chat history */
.chat-history{display:flex;flex-direction:column;gap:8px;max-height:280px;overflow-y:auto}
.chat-msg{padding:10px 14px;border-radius:12px;font-size:13px;line-height:1.5;max-width:85%}
.chat-msg.user{align-self:flex-end;background:#6366F1;color:#fff;border-bottom-right-radius:4px}
.chat-msg.bot{align-self:flex-start;background:#F3F4F6;color:#1a1a2e;border-bottom-left-radius:4px}
.chat-msg.user{align-self:flex-end;background:var(--primary,#3b5998);color:#fff;border-bottom-right-radius:4px}
.chat-msg.bot{align-self:flex-start;background:var(--muted,#f0efec);color:var(--foreground,#1c1917);border-bottom-left-radius:4px}
/* Two-col */
.two-col{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.mini-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:0}
.mini-metrics.cols-2{grid-template-columns:repeat(2,1fr)}
.mini-cell{text-align:center;padding:12px 8px;border-right:1px solid #F3F4F6}
.mini-cell{text-align:center;padding:12px 8px;border-right:1px solid var(--border,#e7e5e2)}
.mini-cell:last-child{border-right:none}
.mini-cell .m-label{font-size:10px;color:#6B7280;font-weight:600;text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px}
.mini-cell .m-value{font-size:24px;font-weight:800;color:#1a1a2e;line-height:1}
@media(max-width:900px){.app-wrap{flex-direction:column}.user-list-pane{width:100%;height:260px;border-right:none;border-bottom:1px solid #E5E7EB}.three-col,.product-cards,.pred-grid,.reco-grid,.nba-grid{grid-template-columns:1fr}.overview-details{grid-template-columns:1fr 1fr}.metrics-row{grid-template-columns:repeat(3,1fr)}.two-col{grid-template-columns:1fr}}
.mini-cell .m-label{font-size:10px;color:var(--muted-fg,#78716c);font-weight:600;text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px}
.mini-cell .m-value{font-size:24px;font-weight:800;color:var(--foreground,#1c1917);line-height:1}
@media(max-width:900px){.app-wrap{flex-direction:column}.user-list-pane{width:100%;height:260px;border-right:none;border-bottom:1px solid var(--border,#e7e5e2)}.three-col,.product-cards,.pred-grid,.reco-grid,.nba-grid{grid-template-columns:1fr}.overview-details{grid-template-columns:1fr 1fr}.metrics-row{grid-template-columns:repeat(3,1fr)}.two-col{grid-template-columns:1fr}}
</style>
</head>
<body>
......
......@@ -7,73 +7,73 @@
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Inter',system-ui,sans-serif;background:linear-gradient(135deg,#FFF8F0 0%,#FFF3E6 50%,#FFECD2 100%);min-height:100vh}
::-webkit-scrollbar{width:5px}::-webkit-scrollbar-thumb{background:rgba(139,90,43,.15);border-radius:4px}
body{font-family:var(--font-sans,'Inter',system-ui,sans-serif);background:var(--background,#f8f7f4);min-height:100vh}
::-webkit-scrollbar{width:5px}::-webkit-scrollbar-thumb{background:var(--border,#e7e5e2);border-radius:4px}
button,input,textarea,select{font-family:inherit}button{cursor:pointer}
/* Layout */
.sim-wrap{display:flex;flex-direction:column;height:100vh;overflow:hidden}
.sim-topbar{height:56px;background:rgba(255,255,255,.85);backdrop-filter:blur(12px);border-bottom:1px solid rgba(139,90,43,.12);display:flex;align-items:center;padding:0 20px;gap:14px;flex-shrink:0}
.sim-topbar{height:56px;background:var(--card,#ffffff);backdrop-filter:blur(12px);border-bottom:1px solid var(--border,#e7e5e2);display:flex;align-items:center;padding:0 20px;gap:14px;flex-shrink:0}
.sim-body{flex:1;overflow:hidden;display:flex}
/* Topbar */
.sim-logo{width:36px;height:36px;border-radius:10px;background:linear-gradient(135deg,#8B5A2B,#A0522D);display:flex;align-items:center;justify-content:center;font-size:17px;color:#fff;font-weight:800}
.sim-title{font-size:14px;font-weight:800;color:#3E2723;letter-spacing:-.01em}
.sim-subtitle{font-size:10px;color:#8D6E63}
.sim-logo{width:36px;height:36px;border-radius:10px;background:var(--primary,#3b5998);display:flex;align-items:center;justify-content:center;font-size:17px;color:#fff;font-weight:800}
.sim-title{font-size:14px;font-weight:800;color:var(--foreground,#1c1917);letter-spacing:-.01em}
.sim-subtitle{font-size:10px;color:var(--muted-fg,#78716c)}
/* Tabs */
.tab-group{display:flex;gap:2px;background:rgba(139,90,43,.06);border-radius:9px;padding:3px}
.tab-btn{padding:6px 14px;border-radius:7px;border:none;font-size:12px;font-weight:600;background:transparent;color:#8D6E63;transition:all .15s}
.tab-btn.active{background:#fff;color:#3E2723;box-shadow:0 1px 4px rgba(139,90,43,.12)}
.tab-badge{margin-left:4px;font-size:9px;background:rgba(139,90,43,.08);color:#8B5A2B;padding:1px 6px;border-radius:100px}
.tab-group{display:flex;gap:2px;background:rgba(59,89,152,0.04);border-radius:9px;padding:3px}
.tab-btn{padding:6px 14px;border-radius:7px;border:none;font-size:12px;font-weight:600;background:transparent;color:var(--muted-fg,#78716c);transition:all .15s}
.tab-btn.active{background:#fff;color:var(--foreground,#1c1917);box-shadow:0 1px 4px var(--border,#e7e5e2)}
.tab-badge{margin-left:4px;font-size:9px;background:rgba(59,89,152,0.06);color:var(--primary,#3b5998);padding:1px 6px;border-radius:100px}
/* Buttons */
.btn-primary{background:linear-gradient(135deg,#8B5A2B,#A0522D);color:#fff;border:none;border-radius:9px;padding:8px 18px;font-size:12.5px;font-weight:700;display:flex;align-items:center;gap:6px;transition:transform .1s}
.btn-primary{background:var(--primary,#3b5998);color:#fff;border:none;border-radius:9px;padding:8px 18px;font-size:12.5px;font-weight:700;display:flex;align-items:center;gap:6px;transition:transform .1s}
.btn-primary:hover{transform:translateY(-1px)}
.btn-primary:disabled{opacity:.4;cursor:not-allowed;transform:none}
.btn-secondary{background:rgba(139,90,43,.06);color:#8B5A2B;border:1px solid rgba(139,90,43,.15);border-radius:9px;padding:8px 16px;font-size:12.5px;font-weight:600}
.btn-secondary{background:rgba(59,89,152,0.04);color:var(--primary,#3b5998);border:1px solid var(--border,#e7e5e2);border-radius:9px;padding:8px 16px;font-size:12.5px;font-weight:600}
.btn-danger{background:#EF4444;color:#fff;border:none;border-radius:9px;padding:8px 16px;font-size:12.5px;font-weight:700}
.btn-success{background:linear-gradient(135deg,#059669,#047857);color:#fff;border:none;border-radius:9px;padding:8px 16px;font-size:12.5px;font-weight:700}
/* Cards */
.card{background:rgba(255,255,255,.75);border:1px solid rgba(139,90,43,.12);border-radius:14px;padding:18px 20px;backdrop-filter:blur(8px)}
.card-title{font-size:15px;font-weight:700;color:#3E2723;margin-bottom:2px}
.card-desc{font-size:11.5px;color:#8D6E63}
.card{background:var(--card,#ffffff);border:1px solid var(--border,#e7e5e2);border-radius:14px;padding:18px 20px;backdrop-filter:blur(8px)}
.card-title{font-size:15px;font-weight:700;color:var(--foreground,#1c1917);margin-bottom:2px}
.card-desc{font-size:11.5px;color:var(--muted-fg,#78716c)}
/* Persona Cards */
.persona-card{border:1.5px solid rgba(139,90,43,.15);border-radius:12px;padding:14px;background:rgba(255,255,255,.7);transition:all .2s;position:relative}
.persona-card{border:1.5px solid var(--border,#e7e5e2);border-radius:12px;padding:14px;background:var(--card,#ffffff);transition:all .2s;position:relative}
.persona-card.done{border-color:rgba(5,150,105,.3);background:rgba(240,253,244,.7)}
.persona-card.running{border-color:rgba(59,130,246,.3)}
.persona-avatar{width:42px;height:42px;border-radius:50%;background:#fff;border:1.5px solid rgba(139,90,43,.15);display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0}
.persona-name{font-size:13px;font-weight:700;color:#3E2723}
.persona-meta{font-size:10.5px;color:#8D6E63}
.persona-trait{font-size:11.5px;color:#5D4037;line-height:1.5;margin:8px 0}
.persona-avatar{width:42px;height:42px;border-radius:50%;background:#fff;border:1.5px solid var(--border,#e7e5e2);display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0}
.persona-name{font-size:13px;font-weight:700;color:var(--foreground,#1c1917)}
.persona-meta{font-size:10.5px;color:var(--muted-fg,#78716c)}
.persona-trait{font-size:11.5px;color:var(--foreground,#1c1917);line-height:1.5;margin:8px 0}
/* Score helpers */
.score-dot{width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700}
.minibar-track{height:4px;background:rgba(139,90,43,.08);border-radius:100px;overflow:hidden}
.minibar-track{height:4px;background:rgba(59,89,152,0.06);border-radius:100px;overflow:hidden}
.minibar-fill{height:100%;border-radius:100px;transition:width .6s ease}
/* Chat */
.chat-header{padding:12px 16px;border-bottom:1px solid rgba(139,90,43,.1);background:rgba(255,255,255,.5);display:flex;align-items:center;gap:10px;flex-shrink:0}
.chat-header{padding:12px 16px;border-bottom:1px solid var(--border,#e7e5e2);background:var(--card,#ffffff);display:flex;align-items:center;gap:10px;flex-shrink:0}
.chat-area{flex:1;overflow-y:auto;padding:14px 16px;display:flex;flex-direction:column;gap:10px}
.msg-label{font-size:9.5px;color:#8D6E63;font-weight:600;text-transform:uppercase;letter-spacing:.06em;margin-bottom:1px}
.msg-label{font-size:9.5px;color:var(--muted-fg,#78716c);font-weight:600;text-transform:uppercase;letter-spacing:.06em;margin-bottom:1px}
.msg-bubble{max-width:80%;padding:9px 13px;font-size:13px;line-height:1.55;word-break:break-word}
.msg-user{align-self:flex-end;border-radius:14px 14px 3px 14px;background:#8B5A2B;color:#fff}
.msg-bot{align-self:flex-start;border-radius:14px 14px 14px 3px;background:rgba(255,255,255,.85);border:1px solid rgba(139,90,43,.12);color:#3E2723}
.msg-user{align-self:flex-end;border-radius:14px 14px 3px 14px;background:var(--primary,#3b5998);color:#fff}
.msg-bot{align-self:flex-start;border-radius:14px 14px 14px 3px;background:var(--card,#ffffff);border:1px solid var(--border,#e7e5e2);color:var(--foreground,#1c1917)}
.msg-thinking{align-self:flex-end;border-radius:14px 14px 3px 14px;background:#FFFBEB;border:1px solid #FDE68A;color:#78350F;font-style:italic}
/* Persona sidebar (simulate tab) */
.sim-sidebar{width:200px;border-right:1px solid rgba(139,90,43,.1);overflow-y:auto;padding:10px;background:rgba(255,255,255,.5)}
.sim-sidebar{width:200px;border-right:1px solid var(--border,#e7e5e2);overflow-y:auto;padding:10px;background:var(--card,#ffffff)}
.sim-sidebar-item{padding:9px 10px;border-radius:9px;cursor:pointer;margin-bottom:4px;transition:all .12s;border:1.5px solid transparent}
.sim-sidebar-item.active{background:rgba(139,90,43,.06);border-color:rgba(139,90,43,.15)}
.sim-sidebar-item.active{background:rgba(59,89,152,0.04);border-color:var(--border,#e7e5e2)}
/* Table */
.score-table{width:100%;border-collapse:collapse;font-size:12px}
.score-table th{padding:10px;text-align:center;font-size:11px;font-weight:600;color:#8D6E63;text-transform:uppercase;letter-spacing:.06em;border-bottom:1px solid rgba(139,90,43,.1)}
.score-table th{padding:10px;text-align:center;font-size:11px;font-weight:600;color:var(--muted-fg,#78716c);text-transform:uppercase;letter-spacing:.06em;border-bottom:1px solid var(--border,#e7e5e2)}
.score-table th:first-child{text-align:left;padding-left:14px}
.score-table td{padding:8px 10px;text-align:center;border-bottom:1px solid rgba(139,90,43,.04)}
.score-table td:first-child{text-align:left;padding-left:14px;font-family:monospace;font-weight:500;color:#5D4037}
.score-table td:first-child{text-align:left;padding-left:14px;font-family:monospace;font-weight:500;color:var(--foreground,#1c1917)}
.score-table tr:nth-child(even){background:rgba(139,90,43,.02)}
/* Insights */
......@@ -233,25 +233,25 @@ button,input,textarea,select{font-family:inherit}button{cursor:pointer}
// STATE
// ══════════════════════════════════════════════════════
const PERSONAS_DEFAULT = [
{ id:1, name:"Chi Lan", age:40, archetype:"Non-tech user",
trait:"Khong quen dung app, can huong dan tung buoc. Hay bo cuoc neu khong hieu.",
system:"Ban la Chi Lan, 40 tuoi, khong quen dung app. Hoi chatbot nhu nguoi that, don gian va chan that. Neu cau tra loi kho hieu thi hoi lai 'cai do nghia la gi?'. Neu qua phuc tap, to ra boi roi hoac buc boi. Hoi toi da 4 luot. Sau moi luot chi hoi 1 cau ngan."},
{ id:1, name:"Ch Lan", age:40, archetype:"Non-tech user",
trait:"Không quen dùng app, cần hướng dẫn từng bước. Hay bỏ cuộc nếu không hiểu.",
system:"Bạn là Chị Lan, 40 tuổi, không quen dùng app. Hỏi chatbot như người thật, đơn giản và chân thật. Nếu câu trả lời khó hiểu thì hỏi lại 'cái đó nghĩa là gì?'. Nếu quá phức tạp, tỏ ra bối rối hoặc bực bội. Hỏi tối đa 4 lượt. Sau mỗi lượt chỉ hỏi 1 câu ngắn."},
{ id:2, name:"Dev Minh", age:28, archetype:"Technical power user",
trait:"Developer, hoi ky thuat chi tiet, hay test edge case, phan bac neu cau tra loi khong chinh xac.",
system:"Ban la Dev Minh, 28 tuoi, lap trinh vien. Hoi chatbot voi nhung cau hoi ky thuat, cu the, chi tiet. Neu cau tra loi mo ho thi phan bac hoac hoi lai cu the hon. Test edge case. Hoi toi da 4 luot."},
trait:"Developer, hỏi kỹ thuật chi tiết, hay test edge case, phản bác nếu câu trả lời không chính xác.",
system:"Bạn là Dev Minh, 28 tuổi, lập trình viên. Hỏi chatbot với những câu hỏi kỹ thuật, cụ thể, chi tiết. Nếu câu trả lời mơ hồ thì phản bác hoặc hỏi lại cụ thể hơn. Test edge case. Hỏi tối đa 4 lượt."},
{ id:3, name:"SV Nam", age:20, archetype:"Budget shopper",
trait:"Ngan sach han che, hay so sanh doi thu, nhay cam ve gia, thich mac ca va tim deal.",
system:"Ban la Nam, 20 tuoi, sinh vien, tiet kiem tien. Luon hoi ve gia, so sanh voi doi thu nhu Shein hay Lazada, hoi ve discount va khuyen mai. Neu bot ne tranh so sanh thi chi ra thang. Hoi toi da 4 luot."},
{ id:4, name:"Anh Tuan", age:45, archetype:"Busy executive",
trait:"Giam doc, rat ban, muon cau tra loi ngan gon va nhanh. Ghet phai doc nhieu.",
system:"Ban la Anh Tuan, 45 tuoi, giam doc rat ban. Chi hoi ngan gon, suc tich. Neu cau tra loi dai hon 2 cau thi phan nan. Muon thong tin thang, khong can hoi them. Hoi toi da 4 luot."},
{ id:5, name:"Ba Hoa", age:65, archetype:"Elderly user",
trait:"Lon tuoi, dung dien thoai kho, can giai thich cham tung buoc, khong hieu tu chuyen nganh.",
system:"Ban la Ba Hoa, 65 tuoi, it dung smartphone. Hoi nhung cau hoi co ban, don gian. Hay bi boi roi voi tu chuyen nganh. Neu khong hieu thi hoi lai theo cach nguoi lon tuoi noi. Hoi toi da 4 luot."},
trait:"Ngân sách hạn chế, hay so sánh đối thủ, nhạy cảm về giá, thích mặc cả và tìm deal.",
system:"Bạn là Nam, 20 tuổi, sinh viên, tiết kiệm tiền. Luôn hỏi về giá, so sánh với đối thủ như Shein hay Lazada, hỏi về discount và khuyến mãi. Nếu bot né tránh so sánh thì chỉ ra thẳng. Hỏi tối đa 4 lượt."},
{ id:4, name:"Anh Tun", age:45, archetype:"Busy executive",
trait:"Giám đốc, rất bận, muốn câu trả lời ngắn gọn và nhanh. Ghét phải đọc nhiều.",
system:"Bạn là Anh Tuấn, 45 tuổi, giám đốc rất bận. Chỉ hỏi ngắn gọn, súc tích. Nếu câu trả lời dài hơn 2 câu thì phàn nàn. Muốn thông tin thẳng, không cần hỏi thêm. Hỏi tối đa 4 lượt."},
{ id:5, name:"Bà Hoa", age:65, archetype:"Elderly user",
trait:"Lớn tuổi, dùng điện thoại khó, cần giải thích chậm từng bước, không hiểu từ chuyên ngành.",
system:"Bạn là Bà Hoa, 65 tuổi, ít dùng smartphone. Hỏi những câu hỏi cơ bản, đơn giản. Hay bị bối rối với từ chuyên ngành. Nếu không hiểu thì hỏi lại theo cách người lớn tuổi nói. Hỏi tối đa 4 lượt."},
]
let personas = [...PERSONAS_DEFAULT]
let chatbotPrompt = "Ban la tro ly tu van thoi trang Canifa. Hay tra loi cau hoi cua khach hang mot cach than thien va huu ich."
let chatbotPrompt = "Bạn là trợ lý tư vấn thời trang Canifa. Hãy trả lời câu hỏi của khách hàng một cách thân thiện và hữu ích."
let conversations = {} // {personaId: [{role,content,thinking}]}
let results = {} // {personaId: {scores,key_wins,key_fails,prompt_fix}}
let synthesis = null
......
{"mua": "Mùa xuân – Hè", "tags": "áo phông, trẻ em, unisex, stitch, hoạt hình, cotton", "layer": "Khi thời tiết se lạnh, có thể khoác nhẹ áo khoác gió hoặc áo khoác chần bông để giữ ấm mà không làm mất đi vẻ trẻ trung.", "dip_mac": "Đi học hàng ngày · Dạo phố cuối tuần · Kỳ nghỉ hè · Sinh nhật bạn bè", "do_tuoi": "5-12 tuổi", "faq_1_a": "Form regular fit và phần vai hơi rộng giúp che nhẹ bắp tay, tạo cảm giác thoải mái cho bé có vòng ngực trung bình đến hơi to.", "faq_1_q": "Áo này có phù hợp với bé có vòng ngực hơi to không?", "faq_2_a": "Theo bảng size, chiều cao 110 cm tương ứng size 110; nếu muốn áo hơi rộng hơn để di chuyển thoải mái, có thể chọn size 116.", "faq_2_q": "Bé 110 cm nên chọn size nào?", "faq_3_a": "Kết hợp với áo khoác gió nhẹ hoặc áo khoác chần bông màu trung tính (be, xám) sẽ giữ ấm và vẫn giữ được nét hoạt hình sinh động của áo.", "faq_3_q": "Có thể phối áo này với áo khoác nào để tạo phong cách mùa thu?", "phoi_do": "Quần jean + Giày sneaker trắng → Năng động chơi phố | Quần khaki + Dép xỏ ngón → Gọn gàng dạo công viên | Quần legging + Giày slip‑on đen → Thoải mái sau giờ học", "tagline": "Stitch đồng hành, bé yêu tỏa sáng mỗi ngày", "loi_song": "Thích khám phá, yêu thiên nhiên và những nhân vật hoạt hình", "chat_lieu": "Không xác định", "gioi_tinh": "Unisex", "ly_do_mua": "Thiết kế độc đáo, chất liệu thoáng mát, phù hợp cho mọi hoạt động trẻ em, dễ dàng mix‑match để tạo phong cách riêng.", "tinh_cach": "Hòa đồng, vui tươi, luôn tràn đầy năng lượng", "cross_sell": "Quần jean, Áo khoác gió, Bộ thể thao", "luu_y_size": "Hãy đo chiều cao bé và so sánh với bảng size; nếu bé ở mức trung gian giữa hai size, nên chọn size lớn hơn để áo không bị bó.", "phong_cach": "Casual, hoạt hình", "mo_ta_chinh": "Form dáng regular fit nhẹ nhàng, không gò bó, giúp bé tự tin di chuyển. Thiết kế in hình Stitch đáng yêu cùng chữ “SEA YOU LATER” tạo điểm nhấn hoạt hình sinh động. Chiếc áo này làm dịu mắt, khiến làn da bé luôn thoải mái và nổi bật trong mọi hoạt động.", "ten_san_pham": "Áo phông unisex trẻ em in hình Stitch", "tinh_nang_vai": "Co giãn nhẹ, thấm hút mồ hôi tốt, giữ form sau nhiều lần giặt", "hook_quang_cao": "Stitch cùng bé khám phá thế giới, mỗi bước đi là một câu chuyện vui nhộn!", "tranh_phoi_cung": "Không nên ghép áo phông hoạt hình với áo blazer trang trọng hoặc quần tây công sở vì sẽ phá vỡ tính năng vui tươi; tránh kết hợp cùng áo khoác nỉ dày vào mùa hè vì sẽ làm bé cảm thấy nóng bức.", "huong_dan_bao_quan": "Giặt máy ≤30°C, lộn trái, không dùng chất tẩy, phơi trong bóng râm để bảo màu sắc sáng bóng", "nguyen_tac_phoi_do": "Áo phông rộng vừa giúp cân bằng tỉ lệ với quần jean ôm sát, tạo dáng cân đối; màu sắc tươi sáng của áo sẽ nổi bật hơn khi kết hợp với quần khaki trung tính; quần legging co giãn giúp bé thoải mái vận động, đồng thời giữ cho áo không bị xê dịch, thích hợp cho các hoạt động năng động."}
# Init file for worker package
import asyncio
import json
import logging
import sys
from common.cache import redis_cache
from api.product_desc_route import generate_description, GenerateRequest
logging.basicConfig(level=logging.INFO, format="20%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%y-%m-%d %H:%M:%S")
logger = logging.getLogger("batch_worker")
BATCH_CONCURRENCY = 6 # 1 worker per Groq API key = max throughput
async def main():
await redis_cache.initialize()
redis = redis_cache.get_client()
if not redis:
logger.error("Redis client is not available. Please check REDIS_CACHE_TURN_ON in config.py.")
sys.exit(1)
# Initialize tables sequentially before starting concurrent tasks
from common.ultra_desc_db import UltraDescriptionDB, DescFieldConfig
UltraDescriptionDB.ensure_table()
DescFieldConfig.ensure_table()
logger.info("👷 Batch Worker started. Waiting for jobs on 'desc:queue'...")
while True:
try:
# BRPOP blocks until an item is available, use timeout=2 to allow Windows Ctrl+C propagation!
item = await redis.brpop("desc:queue", timeout=2)
if not item:
continue
_, payload = item
job_data = json.loads(payload)
job_id = job_data.get("job_id")
codes = job_data.get("codes", [])
if not job_id or not codes:
logger.warning(f"Invalid job payload: {job_data}")
continue
logger.info(f"🚀 Found Job {job_id} with {len(codes)} items.")
# Setup job progress in Redis
progress_key = f"desc:progress:{job_id}"
await redis.set("desc:progress:latest_job", job_id)
await redis.hset(progress_key, mapping={
"is_running": "true",
"total": len(codes),
"done": 0,
"errors": 0,
})
# Giữ trạng thái job trong 1 tiếng
await redis.expire(progress_key, 3600)
# Phân tách work và kiểm soát concurrency
sem = asyncio.Semaphore(BATCH_CONCURRENCY)
done_count = 0
err_count = 0
async def _worker(code: str):
nonlocal done_count, err_count
async with sem:
try:
logger.info(f"⚡ [Job {job_id}] [{done_count+1}/{len(codes)}] Generating {code}...")
req_data = GenerateRequest(internal_ref_code=code)
await generate_description(req_data)
except Exception as e:
logger.error(f"❌ Batch failed for {code}: {e}")
err_count += 1
finally:
done_count += 1
await redis.hset(progress_key, mapping={
"done": done_count,
"errors": err_count,
"current_code": code
})
await asyncio.gather(*[_worker(code) for code in codes])
await redis.hset(progress_key, "is_running", "false")
logger.info(f"🎉 Job {job_id} finished. Total: {len(codes)}, Errors: {err_count}")
except Exception as e:
logger.error(f"Worker loop error: {e}")
await asyncio.sleep(5) # Backoff nếu Redis rớt
if __name__ == "__main__":
# Workaround for Window's ProactorEventLoop not playing nice with some async teardowns
if sys.platform == 'win32':
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
asyncio.run(main())
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment