Spaces:
Paused
Paused
Fix: Context Amnesia & UI Cards
Browse files- src/core/memory.py +37 -23
- src/server.py +91 -141
src/core/memory.py
CHANGED
|
@@ -14,33 +14,29 @@ class MemoryManager:
|
|
| 14 |
|
| 15 |
def get_conn(self): return self.engine.connect()
|
| 16 |
|
| 17 |
-
def get_user_workspaces(self, user_id):
|
| 18 |
-
if not self.engine: return [{"id": 1, "name": "Offline Store"}]
|
| 19 |
-
try:
|
| 20 |
-
with self.get_conn() as conn:
|
| 21 |
-
rows = conn.execute(text("SELECT id, name, type FROM workspaces WHERE user_id = :uid"), {"uid": str(user_id)}).fetchall()
|
| 22 |
-
if not rows: return [{"id": 1, "name": "Default Store"}]
|
| 23 |
-
return [{"id": r[0], "name": r[1]} for r in rows]
|
| 24 |
-
except: return [{"id": 1, "name": "Default Store"}]
|
| 25 |
-
|
| 26 |
-
def _get_or_create_session(self, conn, user_id, workspace_id):
|
| 27 |
-
row = conn.execute(text("SELECT id FROM chat_sessions WHERE user_id = :uid ORDER BY last_active DESC LIMIT 1"), {"uid": str(user_id)}).fetchone()
|
| 28 |
-
if row: return row[0]
|
| 29 |
-
res = conn.execute(text("INSERT INTO chat_sessions (user_id, workspace_id, title) VALUES (:uid, :wid, 'New Chat') RETURNING id"), {"uid": str(user_id), "wid": str(workspace_id)}).fetchone()
|
| 30 |
-
return res[0]
|
| 31 |
-
|
| 32 |
def save_attachment(self, user_id, workspace_id, filename, filetype, analysis):
|
| 33 |
try:
|
| 34 |
with self.get_conn() as conn:
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
conn.execute(text("INSERT INTO chat_attachments (session_id, file_name, file_type, analysis_summary) VALUES (:sid, :f, :t, :a)"), {"sid": sid, "f": filename, "t": filetype, "a": analysis})
|
|
|
|
| 37 |
conn.commit()
|
| 38 |
-
except:
|
| 39 |
|
| 40 |
def add_message(self, user_id, workspace_id, role, content):
|
| 41 |
try:
|
| 42 |
with self.get_conn() as conn:
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
| 44 |
conn.execute(text("INSERT INTO chat_messages (session_id, role, content) VALUES (:sid, :role, :content)"), {"sid": sid, "role": role, "content": str(content)})
|
| 45 |
conn.commit()
|
| 46 |
except: pass
|
|
@@ -48,19 +44,37 @@ class MemoryManager:
|
|
| 48 |
def get_context_string(self, user_id, limit=6):
|
| 49 |
try:
|
| 50 |
with self.get_conn() as conn:
|
|
|
|
| 51 |
rows = conn.execute(text("SELECT m.role, m.content FROM chat_messages m JOIN chat_sessions s ON m.session_id = s.id WHERE s.user_id = :uid ORDER BY m.created_at DESC LIMIT :lim"), {"uid": str(user_id), "lim": limit}).fetchall()
|
| 52 |
history = "\n".join([f"{r[0]}: {r[1]}" for r in reversed(rows)])
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
| 56 |
if att_rows:
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
def save_workflow(self, workspace_id, name, json_data):
|
|
|
|
| 62 |
with self.get_conn() as conn:
|
| 63 |
conn.execute(text("INSERT INTO scenarios (workspace_id, name, description, steps, status, created_at) VALUES (:wid, :name, 'AI Generated', :steps, 'active', :time)"),
|
| 64 |
{"wid": workspace_id, "name": name, "steps": json.dumps(json_data), "time": datetime.now().isoformat()})
|
| 65 |
conn.commit()
|
| 66 |
return 1
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
def get_conn(self): return self.engine.connect()
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
def save_attachment(self, user_id, workspace_id, filename, filetype, analysis):
|
| 18 |
try:
|
| 19 |
with self.get_conn() as conn:
|
| 20 |
+
# Get or Create Session
|
| 21 |
+
row = conn.execute(text("SELECT id FROM chat_sessions WHERE user_id = :uid ORDER BY last_active DESC LIMIT 1"), {"uid": str(user_id)}).fetchone()
|
| 22 |
+
if row:
|
| 23 |
+
sid = row[0]
|
| 24 |
+
else:
|
| 25 |
+
res = conn.execute(text("INSERT INTO chat_sessions (user_id, workspace_id, title) VALUES (:uid, :wid, 'New Chat') RETURNING id"), {"uid": str(user_id), "wid": str(workspace_id)}).fetchone()
|
| 26 |
+
sid = res[0]
|
| 27 |
+
|
| 28 |
conn.execute(text("INSERT INTO chat_attachments (session_id, file_name, file_type, analysis_summary) VALUES (:sid, :f, :t, :a)"), {"sid": sid, "f": filename, "t": filetype, "a": analysis})
|
| 29 |
+
conn.execute(text("UPDATE chat_sessions SET last_active = CURRENT_TIMESTAMP WHERE id = :sid"), {"sid": sid})
|
| 30 |
conn.commit()
|
| 31 |
+
except Exception as e: print(f"DB Error: {e}")
|
| 32 |
|
| 33 |
def add_message(self, user_id, workspace_id, role, content):
|
| 34 |
try:
|
| 35 |
with self.get_conn() as conn:
|
| 36 |
+
# Simple lookup for demo
|
| 37 |
+
sid_res = conn.execute(text("SELECT id FROM chat_sessions WHERE user_id = :uid ORDER BY last_active DESC LIMIT 1"), {"uid": str(user_id)}).fetchone()
|
| 38 |
+
sid = sid_res[0] if sid_res else 1
|
| 39 |
+
|
| 40 |
conn.execute(text("INSERT INTO chat_messages (session_id, role, content) VALUES (:sid, :role, :content)"), {"sid": sid, "role": role, "content": str(content)})
|
| 41 |
conn.commit()
|
| 42 |
except: pass
|
|
|
|
| 44 |
def get_context_string(self, user_id, limit=6):
|
| 45 |
try:
|
| 46 |
with self.get_conn() as conn:
|
| 47 |
+
# 1. Get Messages
|
| 48 |
rows = conn.execute(text("SELECT m.role, m.content FROM chat_messages m JOIN chat_sessions s ON m.session_id = s.id WHERE s.user_id = :uid ORDER BY m.created_at DESC LIMIT :lim"), {"uid": str(user_id), "lim": limit}).fetchall()
|
| 49 |
history = "\n".join([f"{r[0]}: {r[1]}" for r in reversed(rows)])
|
| 50 |
|
| 51 |
+
# 2. Get Attachments (SMARTER LOGIC)
|
| 52 |
+
# We fetch the *Latest* one separately to mark it as CURRENT
|
| 53 |
+
att_rows = conn.execute(text("SELECT a.file_name, a.analysis_summary, a.created_at FROM chat_attachments a JOIN chat_sessions s ON a.session_id = s.id WHERE s.user_id = :uid ORDER BY a.created_at DESC LIMIT 3"), {"uid": str(user_id)}).fetchall()
|
| 54 |
+
|
| 55 |
+
vision_context = ""
|
| 56 |
if att_rows:
|
| 57 |
+
# The first one is the latest
|
| 58 |
+
latest = att_rows[0]
|
| 59 |
+
vision_context += f"\n[CURRENTLY LOOKING AT IMAGE]:\nFile: {latest[0]}\nDescription: {latest[1]}\n"
|
| 60 |
+
|
| 61 |
+
# The rest are history
|
| 62 |
+
if len(att_rows) > 1:
|
| 63 |
+
vision_context += "\n[PREVIOUS IMAGES (Context only)]:\n"
|
| 64 |
+
for r in att_rows[1:]:
|
| 65 |
+
vision_context += f"- {r[0]}: {r[1]}\n"
|
| 66 |
+
|
| 67 |
+
return vision_context + "\n" + history
|
| 68 |
+
except Exception as e:
|
| 69 |
+
return f"Error: {e}"
|
| 70 |
|
| 71 |
def save_workflow(self, workspace_id, name, json_data):
|
| 72 |
+
# (Same as before)
|
| 73 |
with self.get_conn() as conn:
|
| 74 |
conn.execute(text("INSERT INTO scenarios (workspace_id, name, description, steps, status, created_at) VALUES (:wid, :name, 'AI Generated', :steps, 'active', :time)"),
|
| 75 |
{"wid": workspace_id, "name": name, "steps": json.dumps(json_data), "time": datetime.now().isoformat()})
|
| 76 |
conn.commit()
|
| 77 |
return 1
|
| 78 |
+
|
| 79 |
+
# Required for server.py imports
|
| 80 |
+
def get_user_workspaces(self, uid): return []
|
src/server.py
CHANGED
|
@@ -47,8 +47,6 @@ def clean_output(text):
|
|
| 47 |
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL)
|
| 48 |
return text.replace("</think>", "").replace("<think>", "").strip()
|
| 49 |
|
| 50 |
-
# --- ROUTES ---
|
| 51 |
-
|
| 52 |
@app.get("/", response_class=HTMLResponse)
|
| 53 |
async def root():
|
| 54 |
return """
|
|
@@ -59,142 +57,152 @@ async def root():
|
|
| 59 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 60 |
<title>Project A</title>
|
| 61 |
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
|
|
| 62 |
<style>
|
| 63 |
-
:root { --primary: #0084ff; --bg: #ffffff; --chat-bg: #f0f2f5; --user-msg: #0084ff; --ai-msg: #e4e6eb;
|
| 64 |
-
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
| 65 |
-
|
| 66 |
-
.chat-widget { width: 100%; max-width: 600px; height: 100vh; background: #fff; display: flex; flex-direction: column; box-shadow: 0 0 20px rgba(0,0,0,0.1); }
|
| 67 |
|
| 68 |
-
|
| 69 |
.header { padding: 15px 20px; border-bottom: 1px solid #eee; font-weight: 700; font-size: 18px; color: #333; display: flex; align-items: center; gap: 10px; }
|
| 70 |
.status-dot { width: 10px; height: 10px; background: #31a24c; border-radius: 50%; }
|
| 71 |
|
| 72 |
-
|
| 73 |
-
.messages { flex: 1; padding: 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; background: #fff; }
|
| 74 |
.msg-row { display: flex; flex-direction: column; max-width: 85%; }
|
| 75 |
.msg-row.user { align-self: flex-end; align-items: flex-end; }
|
| 76 |
.msg-row.ai { align-self: flex-start; align-items: flex-start; }
|
| 77 |
-
|
| 78 |
-
.msg { padding: 10px 16px; border-radius: 18px; font-size: 15px; line-height: 1.5; word-wrap: break-word; }
|
| 79 |
.msg.user { background: var(--user-msg); color: white; border-bottom-right-radius: 4px; }
|
| 80 |
-
.msg.ai { background: var(--ai-msg); color:
|
| 81 |
-
.msg p { margin: 0; }
|
| 82 |
-
|
| 83 |
-
/* Feedback */
|
| 84 |
-
.feedback-actions { display: flex; gap: 10px; margin-top: 5px; margin-left: 5px; font-size: 12px; color: #65676b; opacity: 0; transition: opacity 0.2s; }
|
| 85 |
-
.msg-row.ai:hover .feedback-actions { opacity: 1; }
|
| 86 |
-
.fb-btn { cursor: pointer; } .fb-btn:hover { color: var(--primary); }
|
| 87 |
-
.correction-box { display: none; margin-top: 5px; width: 100%; }
|
| 88 |
-
.correction-box input { width: 100%; padding: 5px; border: 1px solid #ddd; border-radius: 5px; font-size: 12px; }
|
| 89 |
|
| 90 |
-
/*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
.input-wrapper { padding: 15px; border-top: 1px solid #eee; background: #fff; }
|
| 92 |
-
.file-preview { display: none; padding: 8px 12px; background: #e7f3ff; border-radius: 8px; margin-bottom: 10px; font-size: 13px; color: #0084ff; align-items: center; justify-content: space-between; }
|
| 93 |
-
.remove-file { cursor: pointer; font-weight: bold; }
|
| 94 |
-
|
| 95 |
.input-bar { display: flex; align-items: center; gap: 10px; background: #f0f2f5; padding: 8px 12px; border-radius: 25px; }
|
| 96 |
-
|
| 97 |
-
.attach-btn { background: none; border: none; cursor: pointer; color: #65676b; padding: 5px; display: flex; align-items: center; }
|
| 98 |
.attach-btn:hover { color: var(--primary); }
|
| 99 |
-
.attach-btn svg { width: 24px; height: 24px; fill: currentColor; }
|
| 100 |
-
|
| 101 |
input[type="text"] { flex: 1; background: transparent; border: none; outline: none; font-size: 15px; }
|
|
|
|
| 102 |
|
| 103 |
-
.send-btn { background: var(--primary); color: white; border: none; width: 36px; height: 36px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: transform 0.1s; }
|
| 104 |
-
.send-btn:active { transform: scale(0.95); }
|
| 105 |
-
.send-btn svg { width: 18px; height: 18px; fill: white; margin-left: 2px; }
|
| 106 |
-
|
| 107 |
#typing { font-size: 12px; color: #888; margin-left: 20px; margin-bottom: 5px; display: none; }
|
| 108 |
</style>
|
| 109 |
</head>
|
| 110 |
<body>
|
| 111 |
|
| 112 |
<div class="chat-widget">
|
| 113 |
-
<div class="header">
|
| 114 |
-
|
| 115 |
-
|
| 116 |
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
</div>
|
| 120 |
-
|
| 121 |
-
<div id="typing">Project A đang soạn tin...</div>
|
| 122 |
|
| 123 |
<div class="input-wrapper">
|
| 124 |
-
<div id="file-preview" class="file-preview">
|
| 125 |
-
<span id="filename">image.png</span>
|
| 126 |
-
<span class="remove-file" onclick="clearFile()">✕</span>
|
| 127 |
-
</div>
|
| 128 |
-
|
| 129 |
<div class="input-bar">
|
| 130 |
-
<input type="file" id="fileInput" accept="image/*" style="display: none;" onchange="
|
| 131 |
-
<button class="attach-btn" onclick="document.getElementById('fileInput').click()"
|
| 132 |
-
<
|
| 133 |
</button>
|
| 134 |
-
|
| 135 |
<input type="text" id="input" placeholder="Nhập tin nhắn..." onkeypress="handleEnter(event)">
|
| 136 |
-
|
| 137 |
-
<button class="send-btn" onclick="sendMessage()">
|
| 138 |
-
<svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"></path></svg>
|
| 139 |
-
</button>
|
| 140 |
</div>
|
| 141 |
</div>
|
| 142 |
</div>
|
| 143 |
|
| 144 |
<script>
|
| 145 |
-
const API_URL = "";
|
|
|
|
| 146 |
|
| 147 |
-
|
| 148 |
const file = document.getElementById('fileInput').files[0];
|
| 149 |
if (!file) return;
|
|
|
|
| 150 |
|
| 151 |
-
const
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
const formData = new FormData();
|
| 158 |
-
formData.append("file", file);
|
| 159 |
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
| 171 |
}
|
| 172 |
|
| 173 |
function clearFile() {
|
| 174 |
-
|
| 175 |
-
document.getElementById(
|
|
|
|
|
|
|
| 176 |
}
|
| 177 |
|
| 178 |
async function sendMessage() {
|
| 179 |
const input = document.getElementById("input");
|
| 180 |
const text = input.value.trim();
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
-
addMessage(text, "user");
|
| 184 |
input.value = "";
|
| 185 |
-
clearFile(); // Visual clear, server remembers context
|
| 186 |
-
|
| 187 |
document.getElementById("typing").style.display = "block";
|
| 188 |
|
| 189 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
const res = await fetch(`${API_URL}/chat`, {
|
| 191 |
method: "POST",
|
| 192 |
headers: { "Content-Type": "application/json" },
|
| 193 |
-
body: JSON.stringify({ user_id: 1, store_id: 1, message:
|
| 194 |
});
|
|
|
|
| 195 |
const data = await res.json();
|
| 196 |
document.getElementById("typing").style.display = "none";
|
| 197 |
-
|
|
|
|
| 198 |
} catch (e) {
|
| 199 |
document.getElementById("typing").style.display = "none";
|
| 200 |
addMessage("Lỗi: " + e.message, "ai");
|
|
@@ -204,68 +212,12 @@ async def root():
|
|
| 204 |
function addMessage(text, role) {
|
| 205 |
const row = document.createElement("div");
|
| 206 |
row.className = `msg-row ${role}`;
|
| 207 |
-
|
| 208 |
const bubble = document.createElement("div");
|
| 209 |
bubble.className = `msg ${role}`;
|
| 210 |
bubble.innerHTML = marked.parse(text);
|
| 211 |
-
|
| 212 |
-
row.appendChild(bubble);
|
| 213 |
-
document.getElementById("messages").appendChild(row);
|
| 214 |
-
scrollToBottom();
|
| 215 |
-
}
|
| 216 |
-
|
| 217 |
-
function addAiMessage(text, prompt) {
|
| 218 |
-
const row = document.createElement("div");
|
| 219 |
-
row.className = "msg-row ai";
|
| 220 |
-
|
| 221 |
-
const bubble = document.createElement("div");
|
| 222 |
-
bubble.className = "msg ai";
|
| 223 |
-
bubble.innerHTML = marked.parse(text);
|
| 224 |
-
|
| 225 |
-
const actions = document.createElement("div");
|
| 226 |
-
actions.className = "feedback-actions";
|
| 227 |
-
actions.innerHTML = `
|
| 228 |
-
<span class="fb-btn" onclick="sendFeedback(this, 'up')">👍</span>
|
| 229 |
-
<span class="fb-btn" onclick="showCorrection(this)">👎</span>
|
| 230 |
-
`;
|
| 231 |
-
|
| 232 |
-
const correction = document.createElement("div");
|
| 233 |
-
correction.className = "correction-box";
|
| 234 |
-
correction.innerHTML = `
|
| 235 |
-
<input placeholder="Góp ý sửa lỗi..." onkeypress="if(event.key==='Enter') submitCorrection('${prompt}', '${text.replace(/'/g, "\'")}', this)">
|
| 236 |
-
`;
|
| 237 |
-
|
| 238 |
row.appendChild(bubble);
|
| 239 |
-
row.appendChild(actions);
|
| 240 |
-
row.appendChild(correction);
|
| 241 |
-
|
| 242 |
document.getElementById("messages").appendChild(row);
|
| 243 |
-
|
| 244 |
-
}
|
| 245 |
-
|
| 246 |
-
function showCorrection(btn) {
|
| 247 |
-
btn.parentElement.nextElementSibling.style.display = 'block';
|
| 248 |
-
}
|
| 249 |
-
|
| 250 |
-
function sendFeedback(btn, type) {
|
| 251 |
-
btn.parentElement.innerHTML = "<span style='color:green'>✓ Đã ghi nhận</span>";
|
| 252 |
-
// Call API if needed
|
| 253 |
-
}
|
| 254 |
-
|
| 255 |
-
async function submitCorrection(prompt, response, input) {
|
| 256 |
-
const feedback = input.value;
|
| 257 |
-
input.parentElement.innerHTML = "<span style='color:green; font-size:12px'>✓ Cảm ơn bạn!</span>";
|
| 258 |
-
|
| 259 |
-
await fetch(`${API_URL}/feedback`, {
|
| 260 |
-
method: "POST",
|
| 261 |
-
headers: { "Content-Type": "application/json" },
|
| 262 |
-
body: JSON.stringify({ prompt, response, feedback: "down", correction: feedback })
|
| 263 |
-
});
|
| 264 |
-
}
|
| 265 |
-
|
| 266 |
-
function scrollToBottom() {
|
| 267 |
-
const m = document.getElementById("messages");
|
| 268 |
-
m.scrollTop = m.scrollHeight;
|
| 269 |
}
|
| 270 |
|
| 271 |
function handleEnter(e) { if (e.key === "Enter") sendMessage(); }
|
|
@@ -286,7 +238,6 @@ async def upload_file(file: UploadFile = File(...)):
|
|
| 286 |
if file_ext in ['jpg', 'png', 'jpeg', 'webp']:
|
| 287 |
analysis = vision.analyze_media(save_path)
|
| 288 |
|
| 289 |
-
# Save to User 1 (Default)
|
| 290 |
memory.save_attachment(1, 1, file.filename, file_ext, analysis)
|
| 291 |
return {"status": "success", "vision_analysis": analysis}
|
| 292 |
|
|
@@ -305,7 +256,6 @@ async def chat_endpoint(req: ChatRequest):
|
|
| 305 |
decision = manager.analyze_task(req.message, history)
|
| 306 |
cat = decision.get("category", "GENERAL")
|
| 307 |
|
| 308 |
-
# Vision Override
|
| 309 |
if "ảnh" in req.message.lower() or "hình" in req.message.lower():
|
| 310 |
cat = "GENERAL"
|
| 311 |
|
|
|
|
| 47 |
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL)
|
| 48 |
return text.replace("</think>", "").replace("<think>", "").strip()
|
| 49 |
|
|
|
|
|
|
|
| 50 |
@app.get("/", response_class=HTMLResponse)
|
| 51 |
async def root():
|
| 52 |
return """
|
|
|
|
| 57 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 58 |
<title>Project A</title>
|
| 59 |
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
| 60 |
+
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
| 61 |
<style>
|
| 62 |
+
:root { --primary: #0084ff; --bg: #ffffff; --chat-bg: #f0f2f5; --user-msg: #0084ff; --ai-msg: #e4e6eb; }
|
| 63 |
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); display: flex; justify-content: center; height: 100vh; margin: 0; }
|
|
|
|
|
|
|
| 64 |
|
| 65 |
+
.chat-widget { width: 100%; max-width: 700px; height: 100vh; background: #fff; display: flex; flex-direction: column; box-shadow: 0 0 20px rgba(0,0,0,0.1); }
|
| 66 |
.header { padding: 15px 20px; border-bottom: 1px solid #eee; font-weight: 700; font-size: 18px; color: #333; display: flex; align-items: center; gap: 10px; }
|
| 67 |
.status-dot { width: 10px; height: 10px; background: #31a24c; border-radius: 50%; }
|
| 68 |
|
| 69 |
+
.messages { flex: 1; padding: 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; }
|
|
|
|
| 70 |
.msg-row { display: flex; flex-direction: column; max-width: 85%; }
|
| 71 |
.msg-row.user { align-self: flex-end; align-items: flex-end; }
|
| 72 |
.msg-row.ai { align-self: flex-start; align-items: flex-start; }
|
| 73 |
+
.msg { padding: 10px 16px; border-radius: 18px; font-size: 15px; line-height: 1.5; }
|
|
|
|
| 74 |
.msg.user { background: var(--user-msg); color: white; border-bottom-right-radius: 4px; }
|
| 75 |
+
.msg.ai { background: var(--ai-msg); color: #050505; border-bottom-left-radius: 4px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
+
/* --- NEW ATTACHMENT CARDS --- */
|
| 78 |
+
.attachment-area { display: flex; gap: 10px; padding: 10px 15px; background: #fff; overflow-x: auto; min-height: 60px; display: none; }
|
| 79 |
+
|
| 80 |
+
.att-card {
|
| 81 |
+
display: flex; align-items: center; gap: 10px;
|
| 82 |
+
background: #2b2d31; color: white;
|
| 83 |
+
padding: 8px 12px; border-radius: 8px;
|
| 84 |
+
font-size: 13px; min-width: 150px;
|
| 85 |
+
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.att-thumb { width: 30px; height: 30px; border-radius: 4px; object-fit: cover; background: #444; }
|
| 89 |
+
.att-info { display: flex; flex-direction: column; flex: 1; overflow: hidden; }
|
| 90 |
+
.att-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
| 91 |
+
.att-size { font-size: 10px; color: #aaa; }
|
| 92 |
+
.att-close { cursor: pointer; color: #aaa; padding: 5px; }
|
| 93 |
+
.att-close:hover { color: white; }
|
| 94 |
+
|
| 95 |
+
/* Input Bar */
|
| 96 |
.input-wrapper { padding: 15px; border-top: 1px solid #eee; background: #fff; }
|
|
|
|
|
|
|
|
|
|
| 97 |
.input-bar { display: flex; align-items: center; gap: 10px; background: #f0f2f5; padding: 8px 12px; border-radius: 25px; }
|
| 98 |
+
.attach-btn { background: none; border: none; cursor: pointer; color: #65676b; font-size: 20px; }
|
|
|
|
| 99 |
.attach-btn:hover { color: var(--primary); }
|
|
|
|
|
|
|
| 100 |
input[type="text"] { flex: 1; background: transparent; border: none; outline: none; font-size: 15px; }
|
| 101 |
+
.send-btn { background: var(--primary); color: white; border: none; width: 36px; height: 36px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
#typing { font-size: 12px; color: #888; margin-left: 20px; margin-bottom: 5px; display: none; }
|
| 104 |
</style>
|
| 105 |
</head>
|
| 106 |
<body>
|
| 107 |
|
| 108 |
<div class="chat-widget">
|
| 109 |
+
<div class="header"><div class="status-dot"></div> Project A</div>
|
| 110 |
+
<div class="messages" id="messages"></div>
|
| 111 |
+
<div id="typing">Project A đang suy nghĩ...</div>
|
| 112 |
|
| 113 |
+
<!-- ATTACHMENT PREVIEW AREA -->
|
| 114 |
+
<div id="attachment-area" class="attachment-area"></div>
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
<div class="input-wrapper">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
<div class="input-bar">
|
| 118 |
+
<input type="file" id="fileInput" accept="image/*" style="display: none;" onchange="handleFileSelect()">
|
| 119 |
+
<button class="attach-btn" onclick="document.getElementById('fileInput').click()">
|
| 120 |
+
<i class="fa-solid fa-paperclip"></i>
|
| 121 |
</button>
|
|
|
|
| 122 |
<input type="text" id="input" placeholder="Nhập tin nhắn..." onkeypress="handleEnter(event)">
|
| 123 |
+
<button class="send-btn" onclick="sendMessage()"><i class="fa-solid fa-paper-plane"></i></button>
|
|
|
|
|
|
|
|
|
|
| 124 |
</div>
|
| 125 |
</div>
|
| 126 |
</div>
|
| 127 |
|
| 128 |
<script>
|
| 129 |
+
const API_URL = "";
|
| 130 |
+
let currentFile = null;
|
| 131 |
|
| 132 |
+
function handleFileSelect() {
|
| 133 |
const file = document.getElementById('fileInput').files[0];
|
| 134 |
if (!file) return;
|
| 135 |
+
currentFile = file;
|
| 136 |
|
| 137 |
+
const reader = new FileReader();
|
| 138 |
+
reader.onload = function(e) {
|
| 139 |
+
showAttachmentCard(file.name, e.target.result);
|
| 140 |
+
};
|
| 141 |
+
reader.readAsDataURL(file);
|
| 142 |
+
}
|
|
|
|
|
|
|
| 143 |
|
| 144 |
+
function showAttachmentCard(name, src) {
|
| 145 |
+
const area = document.getElementById('attachment-area');
|
| 146 |
+
area.style.display = 'flex';
|
| 147 |
+
area.innerHTML = `
|
| 148 |
+
<div class="att-card">
|
| 149 |
+
<img src="${src}" class="att-thumb">
|
| 150 |
+
<div class="att-info">
|
| 151 |
+
<div class="att-name">${name}</div>
|
| 152 |
+
<div class="att-size">Ready to upload</div>
|
| 153 |
+
</div>
|
| 154 |
+
<div class="att-close" onclick="clearFile()">✕</div>
|
| 155 |
+
</div>
|
| 156 |
+
`;
|
| 157 |
}
|
| 158 |
|
| 159 |
function clearFile() {
|
| 160 |
+
currentFile = null;
|
| 161 |
+
document.getElementById('fileInput').value = "";
|
| 162 |
+
document.getElementById('attachment-area').style.display = 'none';
|
| 163 |
+
document.getElementById('attachment-area').innerHTML = "";
|
| 164 |
}
|
| 165 |
|
| 166 |
async function sendMessage() {
|
| 167 |
const input = document.getElementById("input");
|
| 168 |
const text = input.value.trim();
|
| 169 |
+
|
| 170 |
+
// Don't send if empty AND no file
|
| 171 |
+
if (!text && !currentFile) return;
|
| 172 |
+
|
| 173 |
+
// 1. UI Update
|
| 174 |
+
if (text) addMessage(text, "user");
|
| 175 |
+
if (currentFile) {
|
| 176 |
+
// Show a mini "uploaded" message for the file
|
| 177 |
+
addMessage(`📎 Đã gửi ảnh: ${currentFile.name}`, "user");
|
| 178 |
+
}
|
| 179 |
|
|
|
|
| 180 |
input.value = "";
|
|
|
|
|
|
|
| 181 |
document.getElementById("typing").style.display = "block";
|
| 182 |
|
| 183 |
try {
|
| 184 |
+
// 2. Upload File First (If exists)
|
| 185 |
+
if (currentFile) {
|
| 186 |
+
const formData = new FormData();
|
| 187 |
+
formData.append("file", currentFile);
|
| 188 |
+
await fetch(`${API_URL}/upload`, { method: "POST", body: formData });
|
| 189 |
+
clearFile(); // Clear from UI after upload
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
// 3. Send Text
|
| 193 |
+
// If text was empty but file was sent, prompt AI to describe it
|
| 194 |
+
const msgToSend = text || "Hãy mô tả ảnh tôi vừa gửi.";
|
| 195 |
+
|
| 196 |
const res = await fetch(`${API_URL}/chat`, {
|
| 197 |
method: "POST",
|
| 198 |
headers: { "Content-Type": "application/json" },
|
| 199 |
+
body: JSON.stringify({ user_id: 1, store_id: 1, message: msgToSend })
|
| 200 |
});
|
| 201 |
+
|
| 202 |
const data = await res.json();
|
| 203 |
document.getElementById("typing").style.display = "none";
|
| 204 |
+
addMessage(data.response, "ai");
|
| 205 |
+
|
| 206 |
} catch (e) {
|
| 207 |
document.getElementById("typing").style.display = "none";
|
| 208 |
addMessage("Lỗi: " + e.message, "ai");
|
|
|
|
| 212 |
function addMessage(text, role) {
|
| 213 |
const row = document.createElement("div");
|
| 214 |
row.className = `msg-row ${role}`;
|
|
|
|
| 215 |
const bubble = document.createElement("div");
|
| 216 |
bubble.className = `msg ${role}`;
|
| 217 |
bubble.innerHTML = marked.parse(text);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
row.appendChild(bubble);
|
|
|
|
|
|
|
|
|
|
| 219 |
document.getElementById("messages").appendChild(row);
|
| 220 |
+
document.getElementById("messages").scrollTop = document.getElementById("messages").scrollHeight;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
}
|
| 222 |
|
| 223 |
function handleEnter(e) { if (e.key === "Enter") sendMessage(); }
|
|
|
|
| 238 |
if file_ext in ['jpg', 'png', 'jpeg', 'webp']:
|
| 239 |
analysis = vision.analyze_media(save_path)
|
| 240 |
|
|
|
|
| 241 |
memory.save_attachment(1, 1, file.filename, file_ext, analysis)
|
| 242 |
return {"status": "success", "vision_analysis": analysis}
|
| 243 |
|
|
|
|
| 256 |
decision = manager.analyze_task(req.message, history)
|
| 257 |
cat = decision.get("category", "GENERAL")
|
| 258 |
|
|
|
|
| 259 |
if "ảnh" in req.message.lower() or "hình" in req.message.lower():
|
| 260 |
cat = "GENERAL"
|
| 261 |
|