mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-06-02 00:57:41 +08:00
Merge branch 'master' of github.com:zhayujie/chatgpt-on-wechat
This commit is contained in:
@@ -30,6 +30,8 @@ _DDL = """
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
channel_type TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
context_start_seq INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_active INTEGER NOT NULL,
|
||||
msg_count INTEGER NOT NULL DEFAULT 0
|
||||
@@ -57,6 +59,14 @@ _MIGRATION_ADD_CHANNEL_TYPE = """
|
||||
ALTER TABLE sessions ADD COLUMN channel_type TEXT NOT NULL DEFAULT '';
|
||||
"""
|
||||
|
||||
_MIGRATION_ADD_TITLE = """
|
||||
ALTER TABLE sessions ADD COLUMN title TEXT NOT NULL DEFAULT '';
|
||||
"""
|
||||
|
||||
_MIGRATION_ADD_CONTEXT_START_SEQ = """
|
||||
ALTER TABLE sessions ADD COLUMN context_start_seq INTEGER NOT NULL DEFAULT 0;
|
||||
"""
|
||||
|
||||
DEFAULT_MAX_AGE_DAYS: int = 30
|
||||
|
||||
|
||||
@@ -287,14 +297,21 @@ class ConversationStore:
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
# Respect context_start_seq: only load messages at or after the boundary
|
||||
ctx_row = conn.execute(
|
||||
"SELECT context_start_seq FROM sessions WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
ctx_start = ctx_row[0] if ctx_row else 0
|
||||
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT seq, role, content
|
||||
FROM messages
|
||||
WHERE session_id = ?
|
||||
WHERE session_id = ? AND seq >= ?
|
||||
ORDER BY seq DESC
|
||||
""",
|
||||
(session_id,),
|
||||
(session_id, ctx_start),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -302,10 +319,7 @@ class ConversationStore:
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
# Walk newest-to-oldest counting *visible* user turns (actual user text,
|
||||
# not tool_result injections). Record the seq of every visible user
|
||||
# message so we can find a clean cut point later.
|
||||
visible_turn_seqs: List[int] = [] # newest first
|
||||
visible_turn_seqs: List[int] = []
|
||||
for seq, role, raw_content in rows:
|
||||
if role != "user":
|
||||
continue
|
||||
@@ -316,17 +330,11 @@ class ConversationStore:
|
||||
if _is_visible_user_message(content):
|
||||
visible_turn_seqs.append(seq)
|
||||
|
||||
# Determine the seq of the oldest visible user message we want to keep.
|
||||
# If the total turns fit within max_turns, keep everything.
|
||||
if len(visible_turn_seqs) <= max_turns:
|
||||
cutoff_seq = None # keep all
|
||||
cutoff_seq = None
|
||||
else:
|
||||
# The Nth visible user message (0-indexed) is the oldest we keep.
|
||||
cutoff_seq = visible_turn_seqs[max_turns - 1]
|
||||
|
||||
# Build result in chronological order, starting from cutoff.
|
||||
# IMPORTANT: we start exactly at cutoff_seq (the visible user message),
|
||||
# never mid-group, so tool_use / tool_result pairs are always complete.
|
||||
result = []
|
||||
for seq, role, raw_content in reversed(rows):
|
||||
if cutoff_seq is not None and seq < cutoff_seq:
|
||||
@@ -415,6 +423,61 @@ class ConversationStore:
|
||||
""",
|
||||
(session_id, session_id),
|
||||
)
|
||||
|
||||
# Auto-generate title from the first visible user message
|
||||
cur_title = conn.execute(
|
||||
"SELECT title FROM sessions WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if cur_title and not cur_title[0]:
|
||||
for msg in messages:
|
||||
if msg.get("role") == "user":
|
||||
content = msg.get("content", "")
|
||||
text = _extract_display_text(content)
|
||||
if text:
|
||||
title = text[:50].split("\n")[0]
|
||||
conn.execute(
|
||||
"UPDATE sessions SET title = ? WHERE session_id = ?",
|
||||
(title, session_id),
|
||||
)
|
||||
break
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def clear_context(self, session_id: str) -> int:
|
||||
"""
|
||||
Set the context boundary to after the current last message.
|
||||
Messages before this boundary are still stored but excluded from LLM context.
|
||||
|
||||
Returns the new context_start_seq value.
|
||||
"""
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
with conn:
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(MAX(seq), -1) FROM messages WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
new_start = row[0] + 1
|
||||
conn.execute(
|
||||
"UPDATE sessions SET context_start_seq = ? WHERE session_id = ?",
|
||||
(new_start, session_id),
|
||||
)
|
||||
return new_start
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_context_start_seq(self, session_id: str) -> int:
|
||||
"""Return the context_start_seq for a session (0 if not set)."""
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT context_start_seq FROM sessions WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return row[0] if row else 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -436,6 +499,7 @@ class ConversationStore:
|
||||
def cleanup_old_sessions(self, max_age_days: Optional[int] = None) -> int:
|
||||
"""
|
||||
Delete sessions that have not been active within max_age_days.
|
||||
Web channel sessions are excluded — they are meant to be permanent.
|
||||
|
||||
Args:
|
||||
max_age_days: Override the default retention period.
|
||||
@@ -459,7 +523,8 @@ class ConversationStore:
|
||||
try:
|
||||
with conn:
|
||||
stale = conn.execute(
|
||||
"SELECT session_id FROM sessions WHERE last_active < ?",
|
||||
"SELECT session_id FROM sessions "
|
||||
"WHERE last_active < ? AND channel_type != 'web'",
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
for (sid,) in stale:
|
||||
@@ -518,9 +583,15 @@ class ConversationStore:
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
ctx_row = conn.execute(
|
||||
"SELECT context_start_seq FROM sessions WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
ctx_start = ctx_row[0] if ctx_row else 0
|
||||
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT role, content, created_at
|
||||
SELECT seq, role, content, created_at
|
||||
FROM messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY seq ASC
|
||||
@@ -530,7 +601,30 @@ class ConversationStore:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
visible = _group_into_display_turns(rows)
|
||||
# Strip seq for display grouping, but record max seq per visible user group
|
||||
plain_rows = [(role, content, created_at) for _seq, role, content, created_at in rows]
|
||||
visible = _group_into_display_turns(plain_rows)
|
||||
|
||||
# Build a mapping: find the seq of each visible user message to annotate context boundary.
|
||||
# Walk through rows to find visible user message seqs in order.
|
||||
visible_user_seqs: List[int] = []
|
||||
for seq, role, raw_content, _ts in rows:
|
||||
if role != "user":
|
||||
continue
|
||||
try:
|
||||
content = json.loads(raw_content)
|
||||
except Exception:
|
||||
content = raw_content
|
||||
if _is_visible_user_message(content):
|
||||
visible_user_seqs.append(seq)
|
||||
|
||||
# Each pair of display turns (user+assistant) corresponds to a visible user seq.
|
||||
# Mark which turns are before the context boundary.
|
||||
user_turn_idx = 0
|
||||
for turn in visible:
|
||||
if turn["role"] == "user" and user_turn_idx < len(visible_user_seqs):
|
||||
turn["_seq"] = visible_user_seqs[user_turn_idx]
|
||||
user_turn_idx += 1
|
||||
|
||||
total = len(visible)
|
||||
offset = (page - 1) * page_size
|
||||
@@ -539,12 +633,98 @@ class ConversationStore:
|
||||
|
||||
return {
|
||||
"messages": page_items,
|
||||
"context_start_seq": ctx_start,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"has_more": offset + page_size < total,
|
||||
}
|
||||
|
||||
def list_sessions(
|
||||
self,
|
||||
channel_type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
List sessions ordered by last_active DESC, with optional channel_type filter.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"sessions": [{session_id, title, created_at, last_active, msg_count}, ...],
|
||||
"total": int,
|
||||
"page": int,
|
||||
"page_size": int,
|
||||
"has_more": bool,
|
||||
}
|
||||
"""
|
||||
page = max(1, page)
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
if channel_type:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM sessions WHERE channel_type = ?",
|
||||
(channel_type,),
|
||||
).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT session_id, title, created_at, last_active, msg_count
|
||||
FROM sessions
|
||||
WHERE channel_type = ?
|
||||
ORDER BY last_active DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(channel_type, page_size, (page - 1) * page_size),
|
||||
).fetchall()
|
||||
else:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM sessions",
|
||||
).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT session_id, title, created_at, last_active, msg_count
|
||||
FROM sessions
|
||||
ORDER BY last_active DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(page_size, (page - 1) * page_size),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
sessions = [
|
||||
{
|
||||
"session_id": r[0],
|
||||
"title": r[1],
|
||||
"created_at": r[2],
|
||||
"last_active": r[3],
|
||||
"msg_count": r[4],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return {
|
||||
"sessions": sessions,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"has_more": (page - 1) * page_size + page_size < total,
|
||||
}
|
||||
|
||||
def rename_session(self, session_id: str, title: str) -> bool:
|
||||
"""Update the title of a session. Returns True if the session existed."""
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
with conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE sessions SET title = ? WHERE session_id = ?",
|
||||
(title, session_id),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Return basic stats keyed by channel_type, for monitoring."""
|
||||
with self._lock:
|
||||
@@ -599,6 +779,20 @@ class ConversationStore:
|
||||
logger.info("[ConversationStore] Migrated: added channel_type column")
|
||||
except Exception as e:
|
||||
logger.warning(f"[ConversationStore] Migration failed: {e}")
|
||||
if "title" not in cols:
|
||||
try:
|
||||
conn.execute(_MIGRATION_ADD_TITLE)
|
||||
conn.commit()
|
||||
logger.info("[ConversationStore] Migrated: added title column")
|
||||
except Exception as e:
|
||||
logger.warning(f"[ConversationStore] Migration (title) failed: {e}")
|
||||
if "context_start_seq" not in cols:
|
||||
try:
|
||||
conn.execute(_MIGRATION_ADD_CONTEXT_START_SEQ)
|
||||
conn.commit()
|
||||
logger.info("[ConversationStore] Migrated: added context_start_seq column")
|
||||
except Exception as e:
|
||||
logger.warning(f"[ConversationStore] Migration (context_start_seq) failed: {e}")
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(self._db_path), timeout=10)
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
(function() {
|
||||
var theme = localStorage.getItem('cow_theme') || 'dark';
|
||||
if (theme === 'dark') document.documentElement.classList.add('dark');
|
||||
var lang = localStorage.getItem('cow_lang') || 'zh';
|
||||
document.documentElement.setAttribute('lang', lang);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
@@ -94,7 +96,7 @@
|
||||
<!-- ================================================================ -->
|
||||
<!-- SIDEBAR -->
|
||||
<!-- ================================================================ -->
|
||||
<aside id="sidebar" class="fixed inset-y-0 left-0 z-50 w-64 bg-[#0A0A0A] text-neutral-400 flex flex-col
|
||||
<aside id="sidebar" class="fixed inset-y-0 left-0 z-50 w-52 bg-[#0A0A0A] text-neutral-400 flex flex-col
|
||||
transform -translate-x-full lg:relative lg:translate-x-0
|
||||
transition-transform duration-300 ease-in-out">
|
||||
<!-- Logo -->
|
||||
@@ -102,7 +104,7 @@
|
||||
<img src="assets/logo.jpg" alt="CowAgent" class="w-8 h-8 rounded-lg flex-shrink-0">
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="text-white font-semibold text-sm truncate">CowAgent</span>
|
||||
<span class="text-neutral-500 text-xs" data-i18n="console">Console</span>
|
||||
<span class="text-neutral-500 text-xs" data-i18n="console">控制台</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -112,13 +114,13 @@
|
||||
<div class="menu-group open" data-group="chat">
|
||||
<button class="w-full flex items-center gap-2 px-3 py-2 text-xs font-semibold uppercase tracking-wider text-neutral-500 hover:text-neutral-300 cursor-pointer transition-colors duration-150">
|
||||
<i class="fas fa-chevron-right text-[10px] chevron"></i>
|
||||
<span data-i18n="nav_chat">Chat</span>
|
||||
<span data-i18n="nav_chat">对话</span>
|
||||
</button>
|
||||
<div class="menu-group-items pl-2">
|
||||
<a class="sidebar-item active flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 hover:bg-white/5 hover:text-neutral-200 text-[14px]"
|
||||
data-view="chat">
|
||||
<i class="fas fa-message item-icon text-xs w-5 text-center"></i>
|
||||
<span data-i18n="menu_chat">Chat</span>
|
||||
<span data-i18n="menu_chat">对话</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,38 +129,38 @@
|
||||
<div class="menu-group open" data-group="manage">
|
||||
<button class="w-full flex items-center gap-2 px-3 py-2 text-xs font-semibold uppercase tracking-wider text-neutral-500 hover:text-neutral-300 cursor-pointer transition-colors duration-150">
|
||||
<i class="fas fa-chevron-right text-[10px] chevron"></i>
|
||||
<span data-i18n="nav_manage">Management</span>
|
||||
<span data-i18n="nav_manage">管理</span>
|
||||
</button>
|
||||
<div class="menu-group-items pl-2">
|
||||
<a class="sidebar-item flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 hover:bg-white/5 hover:text-neutral-200 text-[14px]"
|
||||
data-view="config">
|
||||
<i class="fas fa-sliders item-icon text-xs w-5 text-center"></i>
|
||||
<span data-i18n="menu_config">Config</span>
|
||||
<span data-i18n="menu_config">配置</span>
|
||||
</a>
|
||||
<a class="sidebar-item flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 hover:bg-white/5 hover:text-neutral-200 text-[14px]"
|
||||
data-view="skills">
|
||||
<i class="fas fa-bolt item-icon text-xs w-5 text-center"></i>
|
||||
<span data-i18n="menu_skills">Skills</span>
|
||||
<span data-i18n="menu_skills">技能</span>
|
||||
</a>
|
||||
<a class="sidebar-item flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 hover:bg-white/5 hover:text-neutral-200 text-[14px]"
|
||||
data-view="memory">
|
||||
<i class="fas fa-brain item-icon text-xs w-5 text-center"></i>
|
||||
<span data-i18n="menu_memory">Memory</span>
|
||||
<span data-i18n="menu_memory">记忆</span>
|
||||
</a>
|
||||
<a class="sidebar-item flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 hover:bg-white/5 hover:text-neutral-200 text-[14px]"
|
||||
data-view="knowledge">
|
||||
<i class="fas fa-book item-icon text-xs w-5 text-center"></i>
|
||||
<span data-i18n="menu_knowledge">Knowledge</span>
|
||||
<span data-i18n="menu_knowledge">知识</span>
|
||||
</a>
|
||||
<a class="sidebar-item flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 hover:bg-white/5 hover:text-neutral-200 text-[14px]"
|
||||
data-view="channels">
|
||||
<i class="fas fa-tower-broadcast item-icon text-xs w-5 text-center"></i>
|
||||
<span data-i18n="menu_channels">Channels</span>
|
||||
<span data-i18n="menu_channels">通道</span>
|
||||
</a>
|
||||
<a class="sidebar-item flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 hover:bg-white/5 hover:text-neutral-200 text-[14px]"
|
||||
data-view="tasks">
|
||||
<i class="fas fa-clock item-icon text-xs w-5 text-center"></i>
|
||||
<span data-i18n="menu_tasks">Tasks</span>
|
||||
<span data-i18n="menu_tasks">定时</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -167,13 +169,13 @@
|
||||
<div class="menu-group open" data-group="monitor">
|
||||
<button class="w-full flex items-center gap-2 px-3 py-2 text-xs font-semibold uppercase tracking-wider text-neutral-500 hover:text-neutral-300 cursor-pointer transition-colors duration-150">
|
||||
<i class="fas fa-chevron-right text-[10px] chevron"></i>
|
||||
<span data-i18n="nav_monitor">Monitor</span>
|
||||
<span data-i18n="nav_monitor">监控</span>
|
||||
</button>
|
||||
<div class="menu-group-items pl-2">
|
||||
<a class="sidebar-item flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 hover:bg-white/5 hover:text-neutral-200 text-[14px]"
|
||||
data-view="logs">
|
||||
<i class="fas fa-terminal item-icon text-xs w-5 text-center"></i>
|
||||
<span data-i18n="menu_logs">Logs</span>
|
||||
<span data-i18n="menu_logs">日志</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -194,6 +196,23 @@
|
||||
<!-- Mobile Overlay -->
|
||||
<div id="sidebar-overlay" class="fixed inset-0 bg-black/50 z-40 hidden lg:hidden cursor-pointer" onclick="toggleSidebar()"></div>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- SESSION PANEL (collapsible) -->
|
||||
<!-- ================================================================ -->
|
||||
<aside id="session-panel" class="session-panel hidden">
|
||||
<div class="session-panel-header">
|
||||
<span class="session-panel-title" data-i18n="session_history">历史会话</span>
|
||||
<button class="session-panel-close" onclick="toggleSessionPanel()" title="Close">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button class="session-panel-new" onclick="newChat()">
|
||||
<i class="fas fa-plus"></i>
|
||||
<span data-i18n="new_chat">新对话</span>
|
||||
</button>
|
||||
<div id="session-list" class="session-list"></div>
|
||||
</aside>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- MAIN CONTENT -->
|
||||
<!-- ================================================================ -->
|
||||
@@ -206,11 +225,17 @@
|
||||
<i class="fas fa-bars text-slate-600 dark:text-slate-300"></i>
|
||||
</button>
|
||||
|
||||
<!-- Session panel toggle -->
|
||||
<button id="session-toggle-btn" class="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer transition-colors duration-150"
|
||||
onclick="toggleSessionPanel()">
|
||||
<i class="fas fa-clock-rotate-left text-slate-500 dark:text-slate-400"></i>
|
||||
</button>
|
||||
|
||||
<!-- Breadcrumb (hidden on mobile) -->
|
||||
<div class="hidden lg:flex items-center gap-2 text-sm min-w-0">
|
||||
<span id="breadcrumb-group" class="text-slate-400 dark:text-slate-500 truncate" data-i18n="nav_chat">Chat</span>
|
||||
<span id="breadcrumb-group" class="text-slate-400 dark:text-slate-500 truncate" data-i18n="nav_chat">对话</span>
|
||||
<i class="fas fa-chevron-right text-[10px] text-slate-300 dark:text-slate-600"></i>
|
||||
<span id="breadcrumb-page" class="font-medium text-slate-700 dark:text-slate-200 truncate" data-i18n="menu_chat">Chat</span>
|
||||
<span id="breadcrumb-page" class="font-medium text-slate-700 dark:text-slate-200 truncate" data-i18n="menu_chat">对话</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
@@ -268,7 +293,7 @@
|
||||
<img src="assets/logo.jpg" alt="CowAgent" class="w-16 h-16 rounded-2xl mb-6 shadow-lg shadow-primary-500/20">
|
||||
<h1 id="welcome-title" class="text-2xl font-bold text-slate-800 dark:text-slate-100 mb-3">CowAgent</h1>
|
||||
<p id="welcome-subtitle" class="text-slate-500 dark:text-slate-400 text-center max-w-lg mb-10 leading-relaxed"
|
||||
data-i18n-html="welcome_subtitle">I can help you answer questions, manage your computer, create and execute skills,<br>and keep growing through long-term memory.</p>
|
||||
data-i18n-html="welcome_subtitle">我可以帮你解答问题、管理计算机、创造和执行技能,并通过<br>长期记忆和知识库不断成长</p>
|
||||
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 w-full max-w-2xl">
|
||||
<div class="example-card group bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4
|
||||
@@ -277,9 +302,9 @@
|
||||
<div class="w-7 h-7 rounded-lg bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<i class="fas fa-folder-open text-blue-500 text-xs"></i>
|
||||
</div>
|
||||
<span class="font-medium text-sm text-slate-700 dark:text-slate-200" data-i18n="example_sys_title">System</span>
|
||||
<span class="font-medium text-sm text-slate-700 dark:text-slate-200" data-i18n="example_sys_title">系统管理</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 leading-relaxed" data-i18n="example_sys_text">Show me the files in the workspace</p>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 leading-relaxed" data-i18n="example_sys_text">查看工作空间里有哪些文件</p>
|
||||
</div>
|
||||
<div class="example-card group bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4
|
||||
cursor-pointer hover:border-primary-300 dark:hover:border-primary-600 hover:shadow-md transition-all duration-200">
|
||||
@@ -289,7 +314,7 @@
|
||||
</div>
|
||||
<span class="font-medium text-sm text-slate-700 dark:text-slate-200" data-i18n="example_task_title">定时任务</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 leading-relaxed" data-i18n="example_task_text">Remind me to check the server in 5 minutes</p>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 leading-relaxed" data-i18n="example_task_text">1分钟后提醒我检查服务器</p>
|
||||
</div>
|
||||
<div class="example-card group bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4
|
||||
cursor-pointer hover:border-primary-300 dark:hover:border-primary-600 hover:shadow-md transition-all duration-200">
|
||||
@@ -297,9 +322,9 @@
|
||||
<div class="w-7 h-7 rounded-lg bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center">
|
||||
<i class="fas fa-code text-emerald-500 text-xs"></i>
|
||||
</div>
|
||||
<span class="font-medium text-sm text-slate-700 dark:text-slate-200" data-i18n="example_code_title">Coding</span>
|
||||
<span class="font-medium text-sm text-slate-700 dark:text-slate-200" data-i18n="example_code_title">编程助手</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 leading-relaxed" data-i18n="example_code_text">Write a Python web scraper script</p>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 leading-relaxed" data-i18n="example_code_text">搜索AI资讯并生成可视化网页报告</p>
|
||||
</div>
|
||||
<div class="example-card group bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4
|
||||
cursor-pointer hover:border-primary-300 dark:hover:border-primary-600 hover:shadow-md transition-all duration-200">
|
||||
@@ -309,7 +334,7 @@
|
||||
</div>
|
||||
<span class="font-medium text-sm text-slate-700 dark:text-slate-200" data-i18n="example_knowledge_title">知识库</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 leading-relaxed" data-i18n="example_knowledge_text">帮我把这篇文章整理到知识库</p>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 leading-relaxed" data-i18n="example_knowledge_text">查看知识库当前文档情况</p>
|
||||
</div>
|
||||
<div class="example-card group bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4
|
||||
cursor-pointer hover:border-primary-300 dark:hover:border-primary-600 hover:shadow-md transition-all duration-200">
|
||||
@@ -345,14 +370,20 @@
|
||||
<div class="flex items-center flex-shrink-0">
|
||||
<button id="new-chat-btn" class="w-9 h-10 flex items-center justify-center rounded-lg
|
||||
text-slate-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
cursor-pointer transition-colors duration-150" title="New Chat"
|
||||
cursor-pointer transition-colors duration-150"
|
||||
onclick="newChat()">
|
||||
<i class="fas fa-plus text-base"></i>
|
||||
</button>
|
||||
<button id="clear-context-btn" class="w-9 h-10 flex items-center justify-center rounded-lg
|
||||
text-slate-400 hover:text-amber-500 hover:bg-amber-50 dark:hover:bg-amber-900/20
|
||||
cursor-pointer transition-colors duration-150"
|
||||
onclick="clearContext()">
|
||||
<i class="fas fa-trash-can text-base"></i>
|
||||
</button>
|
||||
<button id="attach-btn" class="w-9 h-10 flex items-center justify-center rounded-lg
|
||||
text-slate-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
cursor-pointer transition-colors duration-150"
|
||||
title="Attach file" onclick="document.getElementById('file-input').click()">
|
||||
onclick="document.getElementById('file-input').click()">
|
||||
<i class="fas fa-paperclip text-base"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -367,7 +398,7 @@
|
||||
text-sm leading-relaxed"
|
||||
rows="1"
|
||||
data-i18n-placeholder="input_placeholder"
|
||||
placeholder="Type a message, or press / for commands"></textarea>
|
||||
placeholder="输入消息,或输入 / 使用指令"></textarea>
|
||||
<button id="send-btn"
|
||||
class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg
|
||||
bg-primary-400 text-white hover:bg-primary-500
|
||||
@@ -389,8 +420,8 @@
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="config_title">Configuration</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="config_desc">Manage model and agent settings</p>
|
||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="config_title">配置管理</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="config_desc">管理模型和 Agent 配置</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-6">
|
||||
@@ -401,12 +432,12 @@
|
||||
<div class="w-9 h-9 rounded-lg bg-primary-50 dark:bg-primary-900/30 flex items-center justify-center">
|
||||
<i class="fas fa-microchip text-primary-500 text-sm"></i>
|
||||
</div>
|
||||
<h3 class="font-semibold text-slate-800 dark:text-slate-100" data-i18n="config_model">Model Configuration</h3>
|
||||
<h3 class="font-semibold text-slate-800 dark:text-slate-100" data-i18n="config_model">模型配置</h3>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<!-- Provider -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="config_provider">Provider</label>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="config_provider">模型厂商</label>
|
||||
<div id="cfg-provider" class="cfg-dropdown" tabindex="0">
|
||||
<div class="cfg-dropdown-selected">
|
||||
<span class="cfg-dropdown-text">--</span>
|
||||
@@ -417,7 +448,7 @@
|
||||
</div>
|
||||
<!-- Model -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="config_model_name">Model</label>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="config_model_name">模型</label>
|
||||
<div id="cfg-model-select" class="cfg-dropdown" tabindex="0">
|
||||
<div class="cfg-dropdown-selected">
|
||||
<span class="cfg-dropdown-text">--</span>
|
||||
@@ -430,7 +461,7 @@
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 font-mono transition-colors"
|
||||
data-i18n-placeholder="config_custom_model_hint" placeholder="Enter custom model name">
|
||||
data-i18n-placeholder="config_custom_model_hint" placeholder="输入自定义模型名称">
|
||||
</div>
|
||||
</div>
|
||||
<!-- API Key -->
|
||||
@@ -465,7 +496,7 @@
|
||||
<button id="cfg-model-save"
|
||||
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||||
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick="saveModelConfig()" data-i18n="config_save">Save</button>
|
||||
onclick="saveModelConfig()" data-i18n="config_save">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -476,12 +507,12 @@
|
||||
<div class="w-9 h-9 rounded-lg bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center">
|
||||
<i class="fas fa-robot text-emerald-500 text-sm"></i>
|
||||
</div>
|
||||
<h3 class="font-semibold text-slate-800 dark:text-slate-100" data-i18n="config_agent">Agent Configuration</h3>
|
||||
<h3 class="font-semibold text-slate-800 dark:text-slate-100" data-i18n="config_agent">Agent 配置</h3>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="flex items-center gap-1.5 text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||
<span data-i18n="config_max_tokens">Max Context Tokens</span>
|
||||
<span data-i18n="config_max_tokens">最大上下文 Token</span>
|
||||
<span class="cfg-tip" data-tip-key="config_max_tokens_hint"><i class="fas fa-circle-question"></i></span>
|
||||
</label>
|
||||
<input id="cfg-max-tokens" type="number" min="1000" max="200000" step="1000"
|
||||
@@ -491,7 +522,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-1.5 text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||
<span data-i18n="config_max_turns">Max Memory Turns</span>
|
||||
<span data-i18n="config_max_turns">最大记忆轮次</span>
|
||||
<span class="cfg-tip" data-tip-key="config_max_turns_hint"><i class="fas fa-circle-question"></i></span>
|
||||
</label>
|
||||
<input id="cfg-max-turns" type="number" min="1" max="100" step="1"
|
||||
@@ -501,7 +532,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-1.5 text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||
<span data-i18n="config_max_steps">Max Steps</span>
|
||||
<span data-i18n="config_max_steps">最大执行步数</span>
|
||||
<span class="cfg-tip" data-tip-key="config_max_steps_hint"><i class="fas fa-circle-question"></i></span>
|
||||
</label>
|
||||
<input id="cfg-max-steps" type="number" min="1" max="50" step="1"
|
||||
@@ -526,7 +557,7 @@
|
||||
<button id="cfg-agent-save"
|
||||
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||||
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick="saveAgentConfig()" data-i18n="config_save">Save</button>
|
||||
onclick="saveAgentConfig()" data-i18n="config_save">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -537,25 +568,25 @@
|
||||
<div class="w-9 h-9 rounded-lg bg-amber-50 dark:bg-amber-900/30 flex items-center justify-center">
|
||||
<i class="fas fa-lock text-amber-500 text-sm"></i>
|
||||
</div>
|
||||
<h3 class="font-semibold text-slate-800 dark:text-slate-100" data-i18n="config_security">Security</h3>
|
||||
<h3 class="font-semibold text-slate-800 dark:text-slate-100" data-i18n="config_security">安全设置</h3>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="config_password">Password</label>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="config_password">访问密码</label>
|
||||
<input id="cfg-password" type="password" autocomplete="new-password"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 font-mono transition-colors
|
||||
cfg-key-masked"
|
||||
data-masked="1">
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1.5" data-i18n="config_password_hint">Leave empty to disable password protection</p>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1.5" data-i18n="config_password_hint">留空则不启用密码保护</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-3 pt-1">
|
||||
<span id="cfg-password-status" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
|
||||
<button id="cfg-password-save"
|
||||
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||||
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick="savePasswordConfig()" data-i18n="config_save">Save</button>
|
||||
onclick="savePasswordConfig()" data-i18n="config_save">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -573,25 +604,25 @@
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="skills_title">Skills</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="skills_desc">View, enable, or disable agent skills</p>
|
||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="skills_title">技能管理</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="skills_desc">查看、启用或禁用 Agent 技能</p>
|
||||
</div>
|
||||
<a href="https://skills.cowagent.ai/" target="_blank"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-primary-500 bg-primary-50 dark:bg-primary-900/20 hover:bg-primary-100 dark:hover:bg-primary-900/30 transition-colors">
|
||||
<i class="fas fa-puzzle-piece text-[10px]"></i>
|
||||
<span data-i18n="skills_hub_btn">Skill Hub</span>
|
||||
<span data-i18n="skills_hub_btn">探索技能广场</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Built-in Tools Section -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500" data-i18n="tools_section_title">Built-in Tools</span>
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500" data-i18n="tools_section_title">内置工具</span>
|
||||
<span id="tools-count-badge" class="hidden px-2 py-0.5 rounded-full text-xs bg-slate-100 dark:bg-white/10 text-slate-500 dark:text-slate-400"></span>
|
||||
</div>
|
||||
<div id="tools-empty" class="flex items-center gap-2 py-4 text-slate-400 dark:text-slate-500 text-sm">
|
||||
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||
<span data-i18n="tools_loading">Loading tools...</span>
|
||||
<span data-i18n="tools_loading">加载工具中...</span>
|
||||
</div>
|
||||
<div id="tools-list" class="grid gap-3 sm:grid-cols-2 hidden"></div>
|
||||
</div>
|
||||
@@ -599,15 +630,15 @@
|
||||
<!-- Skills Section -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500" data-i18n="skills_section_title">Skills</span>
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500" data-i18n="skills_section_title">技能</span>
|
||||
<span id="skills-count-badge" class="hidden px-2 py-0.5 rounded-full text-xs bg-slate-100 dark:bg-white/10 text-slate-500 dark:text-slate-400"></span>
|
||||
</div>
|
||||
<div id="skills-empty" class="flex flex-col items-center justify-center py-12">
|
||||
<div class="w-14 h-14 rounded-2xl bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center mb-3">
|
||||
<i class="fas fa-bolt text-amber-400 text-lg"></i>
|
||||
</div>
|
||||
<p class="text-slate-500 dark:text-slate-400 font-medium" data-i18n="skills_loading">Loading skills...</p>
|
||||
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1" data-i18n="skills_loading_desc">Skills will be displayed here after loading</p>
|
||||
<p class="text-slate-500 dark:text-slate-400 font-medium" data-i18n="skills_loading">加载技能中...</p>
|
||||
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1" data-i18n="skills_loading_desc">技能加载后将显示在此处</p>
|
||||
</div>
|
||||
<div id="skills-list" class="grid gap-4 sm:grid-cols-2"></div>
|
||||
</div>
|
||||
@@ -626,26 +657,26 @@
|
||||
<div id="memory-panel-list">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="memory_title">Memory</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="memory_desc">View agent memory files and contents</p>
|
||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="memory_title">记忆管理</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="memory_desc">查看 Agent 记忆文件和内容</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="memory-empty" class="flex flex-col items-center justify-center py-20">
|
||||
<div class="w-16 h-16 rounded-2xl bg-purple-50 dark:bg-purple-900/20 flex items-center justify-center mb-4">
|
||||
<i class="fas fa-brain text-purple-400 text-xl"></i>
|
||||
</div>
|
||||
<p class="text-slate-500 dark:text-slate-400 font-medium" data-i18n="memory_loading">Loading memory files...</p>
|
||||
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1" data-i18n="memory_loading_desc">Memory files will be displayed here</p>
|
||||
<p class="text-slate-500 dark:text-slate-400 font-medium" data-i18n="memory_loading">加载记忆文件中...</p>
|
||||
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1" data-i18n="memory_loading_desc">记忆文件将显示在此处</p>
|
||||
</div>
|
||||
<div id="memory-list" class="hidden">
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-200 dark:border-white/10">
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400" data-i18n="memory_col_name">Filename</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400" data-i18n="memory_col_type">Type</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400" data-i18n="memory_col_size">Size</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400" data-i18n="memory_col_updated">Updated</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400" data-i18n="memory_col_name">文件名</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400" data-i18n="memory_col_type">类型</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400" data-i18n="memory_col_size">大小</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400" data-i18n="memory_col_updated">更新时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="memory-table-body"></tbody>
|
||||
@@ -663,7 +694,7 @@
|
||||
text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10
|
||||
border border-slate-200 dark:border-white/10 transition-colors cursor-pointer">
|
||||
<i class="fas fa-arrow-left text-xs"></i>
|
||||
<span data-i18n="memory_back">Back</span>
|
||||
<span data-i18n="memory_back">返回列表</span>
|
||||
</button>
|
||||
<h2 id="memory-viewer-title"
|
||||
class="text-base font-semibold text-slate-800 dark:text-slate-100 font-mono truncate"></h2>
|
||||
@@ -689,19 +720,19 @@
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 md:mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="knowledge_title">Knowledge</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="knowledge_desc">Browse and explore your knowledge base</p>
|
||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="knowledge_title">知识库</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="knowledge_desc">浏览和探索你的知识库</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="knowledge-stats" class="text-xs text-slate-400 dark:text-slate-500 hidden sm:inline"></span>
|
||||
<div class="flex items-center bg-slate-100 dark:bg-white/10 rounded-lg p-0.5">
|
||||
<button id="knowledge-tab-docs" onclick="switchKnowledgeTab('docs')"
|
||||
class="knowledge-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150 active">
|
||||
<i class="fas fa-folder-tree mr-1.5"></i><span data-i18n="knowledge_tab_docs">Documents</span>
|
||||
<i class="fas fa-folder-tree mr-1.5"></i><span data-i18n="knowledge_tab_docs">文档</span>
|
||||
</button>
|
||||
<button id="knowledge-tab-graph" onclick="switchKnowledgeTab('graph')"
|
||||
class="knowledge-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150">
|
||||
<i class="fas fa-diagram-project mr-1.5"></i><span data-i18n="knowledge_tab_graph">Graph</span>
|
||||
<i class="fas fa-diagram-project mr-1.5"></i><span data-i18n="knowledge_tab_graph">图谱</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -712,15 +743,15 @@
|
||||
<div class="w-16 h-16 rounded-2xl bg-emerald-50 dark:bg-emerald-900/20 flex items-center justify-center mb-4">
|
||||
<i class="fas fa-book text-emerald-400 text-xl"></i>
|
||||
</div>
|
||||
<p class="text-slate-500 dark:text-slate-400 font-medium" data-i18n="knowledge_loading">Loading knowledge base...</p>
|
||||
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1" data-i18n="knowledge_loading_desc">Knowledge pages will be displayed here</p>
|
||||
<p class="text-slate-500 dark:text-slate-400 font-medium" data-i18n="knowledge_loading">加载知识库中...</p>
|
||||
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1" data-i18n="knowledge_loading_desc">知识页面将显示在这里</p>
|
||||
<div id="knowledge-empty-guide" class="hidden mt-6 max-w-sm text-center">
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mb-4" data-i18n="knowledge_empty_guide">Send documents, links or topics to the agent in chat, and it will automatically organize them into your knowledge base.</p>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mb-4" data-i18n="knowledge_empty_guide">在对话中发送文档、链接或主题给 Agent,它会自动整理到你的知识库中。</p>
|
||||
<button onclick="navigateTo('chat')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600
|
||||
text-white text-sm font-medium cursor-pointer transition-colors duration-150">
|
||||
<i class="fas fa-message text-xs"></i>
|
||||
<span data-i18n="knowledge_go_chat">Start a conversation</span>
|
||||
<span data-i18n="knowledge_go_chat">开始对话</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -745,9 +776,9 @@
|
||||
<!-- Content viewer -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div id="knowledge-content-placeholder"
|
||||
class="flex flex-col items-center justify-center py-20 text-slate-400 dark:text-slate-500"
|
||||
class="flex flex-col items-center justify-center py-20 text-slate-400 dark:text-slate-500">
|
||||
<i class="fas fa-file-lines text-3xl mb-3 opacity-40"></i>
|
||||
<p class="text-sm" data-i18n="knowledge_select_hint">Select a document to view</p>
|
||||
<p class="text-sm" data-i18n="knowledge_select_hint">选择一个文档查看</p>
|
||||
</div>
|
||||
<div id="knowledge-content-viewer" class="hidden">
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden">
|
||||
@@ -787,14 +818,14 @@
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="channels_title">Channels</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="channels_desc">View and manage messaging channels</p>
|
||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="channels_title">通道管理</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="channels_desc">管理已接入的消息通道</p>
|
||||
</div>
|
||||
<button id="add-channel-btn" onclick="openAddChannelPanel()"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600
|
||||
text-white text-sm font-medium cursor-pointer transition-colors duration-150">
|
||||
<i class="fas fa-plus text-xs"></i>
|
||||
<span data-i18n="channels_add">Connect</span>
|
||||
<span data-i18n="channels_add">接入通道</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="channels-content" class="grid gap-4"></div>
|
||||
@@ -811,8 +842,8 @@
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="tasks_title">Scheduled Tasks</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="tasks_desc">View and manage scheduled tasks</p>
|
||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="tasks_title">定时任务</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="tasks_desc">查看和管理定时任务</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tasks-empty" class="flex flex-col items-center justify-center py-20">
|
||||
@@ -834,8 +865,8 @@
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="logs_title">Logs</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="logs_desc">Real-time log output (run.log)</p>
|
||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="logs_title">日志</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="logs_desc">实时日志输出 (run.log)</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Log Terminal -->
|
||||
@@ -850,11 +881,11 @@
|
||||
<div class="flex-1"></div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
<span class="text-xs text-slate-500" data-i18n="logs_live">Live</span>
|
||||
<span class="text-xs text-slate-500" data-i18n="logs_live">实时</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="log-output" class="p-4 overflow-y-auto font-mono text-xs leading-relaxed text-slate-300 whitespace-pre-wrap break-all" style="height: calc(100vh - 272px)">
|
||||
<p class="text-slate-500" data-i18n="logs_coming_msg">Log streaming will be available here. Connects to run.log for real-time output similar to tail -f.</p>
|
||||
<p class="text-slate-500" data-i18n="logs_coming_msg">日志流即将在此提供。将连接 run.log 实现类似 tail -f 的实时输出。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,45 @@
|
||||
.dark ::-webkit-scrollbar-thumb { background: #475569; }
|
||||
.dark ::-webkit-scrollbar-thumb:hover { background: #64748b; }
|
||||
|
||||
/* Generic Tooltip (via data-tooltip attribute) */
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
}
|
||||
[data-tooltip]::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 8px);
|
||||
transform: translateX(-50%);
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 100;
|
||||
}
|
||||
[data-tooltip-pos="bottom"]::after {
|
||||
bottom: auto;
|
||||
top: calc(100% + 8px);
|
||||
}
|
||||
.dark [data-tooltip]::after {
|
||||
background: #334155;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
[data-tooltip]:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
[data-tooltip=""]:hover::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar-item.active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
@@ -24,9 +63,300 @@
|
||||
}
|
||||
.sidebar-item.active .item-icon { color: #4ABE6E; }
|
||||
|
||||
/* Session Panel */
|
||||
.session-panel {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fafafa;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
.dark .session-panel {
|
||||
background: #111111;
|
||||
border-right-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.session-panel.hidden { display: none; }
|
||||
.session-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dark .session-panel-header { border-bottom-color: rgba(255, 255, 255, 0.08); }
|
||||
.session-panel-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
.dark .session-panel-title { color: #d1d5db; }
|
||||
.session-panel-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
font-size: 12px;
|
||||
}
|
||||
.session-panel-close:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
.dark .session-panel-close:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #e5e5e5;
|
||||
}
|
||||
.session-panel-new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 10px 12px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed #d1d5db;
|
||||
background: none;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.session-panel-new:hover {
|
||||
border-color: #9ca3af;
|
||||
color: #374151;
|
||||
background: #f9fafb;
|
||||
}
|
||||
.dark .session-panel-new {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
color: #9ca3af;
|
||||
}
|
||||
.dark .session-panel-new:hover {
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
color: #e5e5e5;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* Session List */
|
||||
.session-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.session-list:hover { scrollbar-width: thin; }
|
||||
.session-list::-webkit-scrollbar { width: 4px; background: transparent; }
|
||||
.session-list::-webkit-scrollbar-thumb { background: transparent; border-radius: 2px; }
|
||||
.session-list:hover::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.2); }
|
||||
.dark .session-list:hover::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); }
|
||||
.session-group-label {
|
||||
padding: 10px 8px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.dark .session-group-label { color: #525252; }
|
||||
.session-empty {
|
||||
padding: 20px 12px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
margin: 1px 0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
position: relative;
|
||||
}
|
||||
.dark .session-item { color: #a3a3a3; }
|
||||
.session-item:hover {
|
||||
background: #f3f4f6;
|
||||
color: #111827;
|
||||
}
|
||||
.dark .session-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #e5e5e5;
|
||||
}
|
||||
.session-item.active {
|
||||
background: #e5e7eb;
|
||||
color: #111827;
|
||||
}
|
||||
.dark .session-item.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
}
|
||||
.session-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.dark .session-icon { color: #525252; }
|
||||
.session-item.active .session-icon { color: #4ABE6E; }
|
||||
.session-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.session-delete {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 5px;
|
||||
font-size: 10px;
|
||||
color: #9ca3af;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
.session-item:hover .session-delete { opacity: 1; }
|
||||
.session-delete:hover {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
.dark .session-delete:hover { background: rgba(239, 68, 68, 0.15); }
|
||||
|
||||
/* Context Divider */
|
||||
.context-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 24px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.context-divider::before, .context-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, #d1d5db, transparent);
|
||||
}
|
||||
.dark .context-divider::before, .dark .context-divider::after {
|
||||
background: linear-gradient(to right, transparent, rgba(255,255,255,0.12), transparent);
|
||||
}
|
||||
.context-divider span {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Confirm Modal */
|
||||
.confirm-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.confirm-overlay.visible { opacity: 1; }
|
||||
.confirm-modal {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
width: 380px;
|
||||
max-width: 90vw;
|
||||
padding: 28px 24px 20px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.18);
|
||||
transform: scale(0.92);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.confirm-overlay.visible .confirm-modal { transform: scale(1); }
|
||||
.dark .confirm-modal {
|
||||
background: #1e1e1e;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.confirm-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.dark .confirm-title { color: #e5e7eb; }
|
||||
.confirm-message {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.dark .confirm-message { color: #9ca3af; }
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.confirm-btn {
|
||||
padding: 8px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.confirm-btn-cancel {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
.confirm-btn-cancel:hover { background: #e5e7eb; }
|
||||
.dark .confirm-btn-cancel {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #d1d5db;
|
||||
}
|
||||
.dark .confirm-btn-cancel:hover { background: rgba(255, 255, 255, 0.14); }
|
||||
.confirm-btn-ok {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
.confirm-btn-ok:hover { background: #dc2626; }
|
||||
|
||||
/* Mobile: session panel as overlay */
|
||||
@media (max-width: 768px) {
|
||||
.session-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 45;
|
||||
width: 220px;
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.dark .session-panel {
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/* Menu Groups */
|
||||
.menu-group-items { max-height: 0; overflow: hidden; transition: max-height 0.25s ease-out; }
|
||||
.menu-group.open .menu-group-items { max-height: 500px; transition: max-height 0.35s ease-in; }
|
||||
.menu-group.open .menu-group-items { max-height: 2000px; transition: max-height 0.35s ease-in; }
|
||||
.menu-group .chevron { transition: transform 0.25s ease; }
|
||||
.menu-group.open .chevron { transform: rotate(90deg); }
|
||||
|
||||
|
||||
@@ -80,6 +80,18 @@ const I18N = {
|
||||
tasks_coming: '即将推出', tasks_coming_desc: '定时任务管理功能即将在此提供',
|
||||
logs_title: '日志', logs_desc: '实时日志输出 (run.log)',
|
||||
logs_live: '实时', logs_coming_msg: '日志流即将在此提供。将连接 run.log 实现类似 tail -f 的实时输出。',
|
||||
new_chat: '新对话',
|
||||
session_history: '历史会话',
|
||||
today: '今天', yesterday: '昨天', earlier: '更早',
|
||||
delete_session_confirm: '确认删除该会话?所有消息将被清除。',
|
||||
delete_session_title: '删除会话',
|
||||
untitled_session: '新对话',
|
||||
context_cleared: '— 以上内容已从上下文中移除 —',
|
||||
tip_new_chat: '新建对话',
|
||||
tip_clear_context: '清除上下文',
|
||||
tip_attach_file: '上传附件',
|
||||
confirm_yes: '确认',
|
||||
confirm_cancel: '取消',
|
||||
error_send: '发送失败,请稍后再试。', error_timeout: '请求超时,请再试一次。',
|
||||
thinking_in_progress: '思考中...', thinking_done: '已深度思考', thinking_duration: '耗时',
|
||||
},
|
||||
@@ -152,6 +164,18 @@ const I18N = {
|
||||
tasks_coming: 'Coming Soon', tasks_coming_desc: 'Scheduled task management will be available here',
|
||||
logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)',
|
||||
logs_live: 'Live', logs_coming_msg: 'Log streaming will be available here. Connects to run.log for real-time output similar to tail -f.',
|
||||
new_chat: 'New Chat',
|
||||
session_history: 'History',
|
||||
today: 'Today', yesterday: 'Yesterday', earlier: 'Earlier',
|
||||
delete_session_confirm: 'Delete this session? All messages will be removed.',
|
||||
delete_session_title: 'Delete Session',
|
||||
untitled_session: 'New Chat',
|
||||
context_cleared: '— Context above has been cleared —',
|
||||
tip_new_chat: 'New Chat',
|
||||
tip_clear_context: 'Clear Context',
|
||||
tip_attach_file: 'Attach File',
|
||||
confirm_yes: 'Confirm',
|
||||
confirm_cancel: 'Cancel',
|
||||
error_send: 'Failed to send. Please try again.', error_timeout: 'Request timeout. Please try again.',
|
||||
thinking_in_progress: 'Thinking...', thinking_done: 'Thought', thinking_duration: 'Duration',
|
||||
}
|
||||
@@ -176,13 +200,15 @@ function applyI18n() {
|
||||
document.querySelectorAll('[data-tip-key]').forEach(el => {
|
||||
el.setAttribute('data-tooltip', t(el.dataset.tipKey));
|
||||
});
|
||||
document.getElementById('lang-label').textContent = currentLang === 'zh' ? 'EN' : '中文';
|
||||
const langLabel = document.getElementById('lang-label');
|
||||
if (langLabel) langLabel.textContent = currentLang === 'zh' ? 'EN' : '中文';
|
||||
}
|
||||
|
||||
function toggleLanguage() {
|
||||
currentLang = currentLang === 'zh' ? 'en' : 'zh';
|
||||
localStorage.setItem('cow_lang', currentLang);
|
||||
applyI18n();
|
||||
_applyInputTooltips();
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@@ -793,8 +819,11 @@ function sendMessage() {
|
||||
}
|
||||
|
||||
const ws = document.getElementById('welcome-screen');
|
||||
const isFirstMessage = !!ws;
|
||||
if (ws) ws.remove();
|
||||
|
||||
const titleInfo = (isFirstMessage && text) ? { sid: sessionId, userMsg: text } : null;
|
||||
|
||||
const timestamp = new Date();
|
||||
const attachments = [...pendingAttachments];
|
||||
addUserMessage(text, timestamp, attachments);
|
||||
@@ -830,7 +859,7 @@ function sendMessage() {
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
if (data.stream) {
|
||||
startSSE(data.request_id, loadingEl, timestamp);
|
||||
startSSE(data.request_id, loadingEl, timestamp, titleInfo);
|
||||
} else {
|
||||
loadingContainers[data.request_id] = loadingEl;
|
||||
}
|
||||
@@ -858,7 +887,7 @@ function sendMessage() {
|
||||
postWithRetry(0);
|
||||
}
|
||||
|
||||
function startSSE(requestId, loadingEl, timestamp) {
|
||||
function startSSE(requestId, loadingEl, timestamp, titleInfo) {
|
||||
let botEl = null;
|
||||
let stepsEl = null; // .agent-steps (thinking summaries + tool indicators)
|
||||
let contentEl = null; // .answer-content (final streaming answer)
|
||||
@@ -1074,6 +1103,13 @@ function startSSE(requestId, loadingEl, timestamp) {
|
||||
}
|
||||
scrollChatToBottom();
|
||||
|
||||
if (titleInfo) {
|
||||
generateSessionTitle(titleInfo.sid, titleInfo.userMsg, '');
|
||||
titleInfo = null;
|
||||
} else if (sessionPanelOpen) {
|
||||
loadSessionList();
|
||||
}
|
||||
|
||||
} else if (item.type === 'error') {
|
||||
done = true;
|
||||
es.close();
|
||||
@@ -1358,10 +1394,23 @@ function loadHistory(page) {
|
||||
// Keep the "load more" sentinel in place (inserted below)
|
||||
}
|
||||
|
||||
const ctxStartSeq = data.context_start_seq || 0;
|
||||
let dividerInserted = false;
|
||||
|
||||
data.messages.forEach(msg => {
|
||||
const hasContent = msg.content && msg.content.trim();
|
||||
const hasToolCalls = msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0;
|
||||
if (!hasContent && !hasToolCalls) return;
|
||||
|
||||
// Insert context divider when transitioning from above to below boundary
|
||||
if (ctxStartSeq > 0 && !dividerInserted && msg._seq !== undefined && msg._seq >= ctxStartSeq) {
|
||||
dividerInserted = true;
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'context-divider';
|
||||
divider.innerHTML = `<span>${t('context_cleared')}</span>`;
|
||||
fragment.appendChild(divider);
|
||||
}
|
||||
|
||||
const ts = new Date(msg.created_at * 1000);
|
||||
const el = msg.role === 'user'
|
||||
? createUserMessageEl(msg.content, ts)
|
||||
@@ -1369,6 +1418,14 @@ function loadHistory(page) {
|
||||
fragment.appendChild(el);
|
||||
});
|
||||
|
||||
// If context was cleared but no new messages exist yet, append divider at the end
|
||||
if (ctxStartSeq > 0 && !dividerInserted) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'context-divider';
|
||||
divider.innerHTML = `<span>${t('context_cleared')}</span>`;
|
||||
fragment.appendChild(divider);
|
||||
}
|
||||
|
||||
// Prepend history above any existing messages
|
||||
const sentinel = document.getElementById('history-load-more');
|
||||
const insertBefore = sentinel ? sentinel.nextSibling : messagesDiv.firstChild;
|
||||
@@ -1517,13 +1574,345 @@ function newChat() {
|
||||
});
|
||||
});
|
||||
if (currentView !== 'chat') navigateTo('chat');
|
||||
|
||||
// Show panel and load full session list, then prepend the new session on top
|
||||
const panel = document.getElementById('session-panel');
|
||||
if (panel && !sessionPanelOpen) {
|
||||
sessionPanelOpen = true;
|
||||
panel.classList.remove('hidden');
|
||||
_persistPanelState();
|
||||
}
|
||||
const newSid = sessionId;
|
||||
loadSessionList(() => _addOptimisticSessionItem(newSid));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Session Panel
|
||||
// =====================================================================
|
||||
|
||||
const SESSION_PANEL_KEY = 'cow_session_panel_open';
|
||||
let sessionPanelOpen = localStorage.getItem(SESSION_PANEL_KEY) === '1';
|
||||
|
||||
function _persistPanelState() {
|
||||
localStorage.setItem(SESSION_PANEL_KEY, sessionPanelOpen ? '1' : '0');
|
||||
}
|
||||
|
||||
function toggleSessionPanel() {
|
||||
const panel = document.getElementById('session-panel');
|
||||
if (!panel) return;
|
||||
sessionPanelOpen = !sessionPanelOpen;
|
||||
panel.classList.toggle('hidden', !sessionPanelOpen);
|
||||
_persistPanelState();
|
||||
if (sessionPanelOpen) loadSessionList();
|
||||
}
|
||||
|
||||
function openSessionPanel() {
|
||||
const panel = document.getElementById('session-panel');
|
||||
if (!panel || sessionPanelOpen) return;
|
||||
sessionPanelOpen = true;
|
||||
panel.classList.remove('hidden');
|
||||
_persistPanelState();
|
||||
loadSessionList();
|
||||
}
|
||||
|
||||
function _restoreSessionPanel() {
|
||||
const panel = document.getElementById('session-panel');
|
||||
if (!panel) return;
|
||||
if (sessionPanelOpen) {
|
||||
panel.classList.remove('hidden');
|
||||
loadSessionList();
|
||||
} else {
|
||||
panel.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function _applyInputTooltips() {
|
||||
const set = (id, key, pos) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.setAttribute('data-tooltip', t(key));
|
||||
el.removeAttribute('title');
|
||||
if (pos) el.setAttribute('data-tooltip-pos', pos);
|
||||
};
|
||||
set('new-chat-btn', 'tip_new_chat');
|
||||
set('clear-context-btn', 'tip_clear_context');
|
||||
set('attach-btn', 'tip_attach_file');
|
||||
set('session-toggle-btn', 'session_history', 'bottom');
|
||||
}
|
||||
|
||||
function _addOptimisticSessionItem(sid) {
|
||||
const container = document.getElementById('session-list');
|
||||
if (!container) return;
|
||||
|
||||
const emptyEl = container.querySelector('.session-empty');
|
||||
if (emptyEl) emptyEl.remove();
|
||||
|
||||
document.querySelectorAll('.session-item.active').forEach(el => el.classList.remove('active'));
|
||||
|
||||
const todayLabel = t('today');
|
||||
let firstGroup = container.querySelector('.session-group-label');
|
||||
if (!firstGroup || firstGroup.textContent !== todayLabel) {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'session-group-label';
|
||||
header.textContent = todayLabel;
|
||||
container.prepend(header);
|
||||
firstGroup = header;
|
||||
}
|
||||
|
||||
const title = t('new_chat');
|
||||
const item = document.createElement('div');
|
||||
item.className = 'session-item active';
|
||||
item.dataset.sessionId = sid;
|
||||
item.innerHTML = `
|
||||
<i class="fas fa-message session-icon"></i>
|
||||
<span class="session-title" title="${escapeHtml(title)}">${escapeHtml(title)}</span>
|
||||
<button class="session-delete" onclick="event.stopPropagation(); deleteSession('${sid}')" title="Delete">
|
||||
<i class="fas fa-trash-can"></i>
|
||||
</button>
|
||||
`;
|
||||
item.addEventListener('click', () => switchSession(sid));
|
||||
firstGroup.insertAdjacentElement('afterend', item);
|
||||
}
|
||||
|
||||
function _sessionTimeGroup(ts) {
|
||||
const now = new Date();
|
||||
const d = new Date(ts * 1000);
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1);
|
||||
if (d >= today) return t('today');
|
||||
if (d >= yesterday) return t('yesterday');
|
||||
return t('earlier');
|
||||
}
|
||||
|
||||
let _sessionPage = 1;
|
||||
let _sessionHasMore = false;
|
||||
let _sessionLoading = false;
|
||||
const _SESSION_PAGE_SIZE = 50;
|
||||
|
||||
function loadSessionList(onDone) {
|
||||
const container = document.getElementById('session-list');
|
||||
if (!container) return;
|
||||
|
||||
_sessionPage = 1;
|
||||
_sessionHasMore = false;
|
||||
|
||||
_fetchSessionPage(1, true, onDone);
|
||||
}
|
||||
|
||||
function _fetchSessionPage(page, clear, onDone) {
|
||||
if (_sessionLoading) return;
|
||||
_sessionLoading = true;
|
||||
|
||||
const container = document.getElementById('session-list');
|
||||
if (!container) { _sessionLoading = false; return; }
|
||||
|
||||
// Remove existing "load more" sentinel before fetching
|
||||
const oldSentinel = container.querySelector('.session-load-more');
|
||||
if (oldSentinel) oldSentinel.remove();
|
||||
|
||||
fetch(`/api/sessions?page=${page}&page_size=${_SESSION_PAGE_SIZE}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
_sessionLoading = false;
|
||||
if (data.status !== 'success') return;
|
||||
|
||||
if (clear) container.innerHTML = '';
|
||||
|
||||
const sessions = data.sessions || [];
|
||||
_sessionPage = page;
|
||||
_sessionHasMore = !!data.has_more;
|
||||
|
||||
if (sessions.length === 0 && page === 1) {
|
||||
container.innerHTML = '<div class="session-empty">' + t('untitled_session') + '</div>';
|
||||
if (typeof onDone === 'function') onDone();
|
||||
return;
|
||||
}
|
||||
|
||||
// Track last group label already in the container
|
||||
const existingLabels = container.querySelectorAll('.session-group-label');
|
||||
let lastGroup = existingLabels.length > 0
|
||||
? existingLabels[existingLabels.length - 1].textContent
|
||||
: '';
|
||||
|
||||
sessions.forEach(s => {
|
||||
const group = _sessionTimeGroup(s.last_active);
|
||||
if (group !== lastGroup) {
|
||||
lastGroup = group;
|
||||
const header = document.createElement('div');
|
||||
header.className = 'session-group-label';
|
||||
header.textContent = group;
|
||||
container.appendChild(header);
|
||||
}
|
||||
|
||||
const item = document.createElement('div');
|
||||
const isActive = s.session_id === sessionId;
|
||||
item.className = 'session-item' + (isActive ? ' active' : '');
|
||||
item.dataset.sessionId = s.session_id;
|
||||
|
||||
const title = s.title || t('untitled_session');
|
||||
item.innerHTML = `
|
||||
<i class="fas fa-message session-icon"></i>
|
||||
<span class="session-title" title="${escapeHtml(title)}">${escapeHtml(title)}</span>
|
||||
<button class="session-delete" onclick="event.stopPropagation(); deleteSession('${s.session_id}')" title="Delete">
|
||||
<i class="fas fa-trash-can"></i>
|
||||
</button>
|
||||
`;
|
||||
item.addEventListener('click', () => switchSession(s.session_id));
|
||||
container.appendChild(item);
|
||||
});
|
||||
|
||||
if (typeof onDone === 'function') onDone();
|
||||
})
|
||||
.catch(() => { _sessionLoading = false; });
|
||||
}
|
||||
|
||||
function _onSessionListScroll() {
|
||||
if (!_sessionHasMore || _sessionLoading) return;
|
||||
const container = document.getElementById('session-list');
|
||||
if (!container) return;
|
||||
// Trigger when scrolled near the bottom (within 60px)
|
||||
if (container.scrollHeight - container.scrollTop - container.clientHeight < 60) {
|
||||
_fetchSessionPage(_sessionPage + 1, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Attach scroll listener once DOM is ready
|
||||
(function _initSessionScroll() {
|
||||
const el = document.getElementById('session-list');
|
||||
if (el) {
|
||||
el.addEventListener('scroll', _onSessionListScroll);
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const el2 = document.getElementById('session-list');
|
||||
if (el2) el2.addEventListener('scroll', _onSessionListScroll);
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
function switchSession(newSessionId) {
|
||||
if (newSessionId === sessionId) {
|
||||
if (currentView !== 'chat') navigateTo('chat');
|
||||
return;
|
||||
}
|
||||
|
||||
Object.values(activeStreams).forEach(es => { try { es.close(); } catch (_) {} });
|
||||
activeStreams = {};
|
||||
loadingContainers = {};
|
||||
|
||||
sessionId = newSessionId;
|
||||
localStorage.setItem(SESSION_ID_KEY, sessionId);
|
||||
|
||||
historyPage = 0;
|
||||
historyHasMore = false;
|
||||
historyLoading = false;
|
||||
|
||||
messagesDiv.innerHTML = '';
|
||||
loadHistory(1);
|
||||
startPolling();
|
||||
|
||||
document.querySelectorAll('.session-item').forEach(el => {
|
||||
el.classList.toggle('active', el.dataset.sessionId === sessionId);
|
||||
});
|
||||
|
||||
if (currentView !== 'chat') navigateTo('chat');
|
||||
}
|
||||
|
||||
function deleteSession(sid) {
|
||||
showConfirmModal(t('delete_session_title'), t('delete_session_confirm'), () => {
|
||||
fetch(`/api/sessions/${encodeURIComponent(sid)}`, { method: 'DELETE' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status !== 'success') return;
|
||||
if (sid === sessionId) {
|
||||
newChat();
|
||||
} else {
|
||||
loadSessionList();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
function showConfirmModal(title, message, onConfirm) {
|
||||
let overlay = document.getElementById('confirm-modal-overlay');
|
||||
if (overlay) overlay.remove();
|
||||
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'confirm-modal-overlay';
|
||||
overlay.className = 'confirm-overlay';
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'confirm-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="confirm-title">${escapeHtml(title)}</div>
|
||||
<div class="confirm-message">${escapeHtml(message)}</div>
|
||||
<div class="confirm-actions">
|
||||
<button class="confirm-btn confirm-btn-cancel">${t('confirm_cancel')}</button>
|
||||
<button class="confirm-btn confirm-btn-ok">${t('confirm_yes')}</button>
|
||||
</div>
|
||||
`;
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
requestAnimationFrame(() => overlay.classList.add('visible'));
|
||||
|
||||
const close = () => {
|
||||
overlay.classList.remove('visible');
|
||||
setTimeout(() => overlay.remove(), 200);
|
||||
};
|
||||
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
modal.querySelector('.confirm-btn-cancel').addEventListener('click', close);
|
||||
modal.querySelector('.confirm-btn-ok').addEventListener('click', () => {
|
||||
close();
|
||||
onConfirm();
|
||||
});
|
||||
}
|
||||
|
||||
function clearContext() {
|
||||
fetch(`/api/sessions/${encodeURIComponent(sessionId)}/clear_context`, { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status !== 'success') return;
|
||||
// Insert a visual divider in the chat
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'context-divider';
|
||||
divider.innerHTML = `<span>${t('context_cleared')}</span>`;
|
||||
messagesDiv.appendChild(divider);
|
||||
scrollChatToBottom();
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function generateSessionTitle(sid, userMsg, assistantReply) {
|
||||
fetch(`/api/sessions/${encodeURIComponent(sid)}/generate_title`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_message: userMsg, assistant_reply: assistantReply }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success' && sessionPanelOpen) {
|
||||
loadSessionList();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Utilities
|
||||
// =====================================================================
|
||||
function formatTime(date) {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const now = new Date();
|
||||
const sameDay = date.getFullYear() === now.getFullYear()
|
||||
&& date.getMonth() === now.getMonth()
|
||||
&& date.getDate() === now.getDate();
|
||||
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
if (sameDay) return time;
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
if (date.getFullYear() === now.getFullYear()) return `${m}-${d} ${time}`;
|
||||
return `${date.getFullYear()}-${m}-${d} ${time}`;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
@@ -3561,6 +3950,10 @@ window.fetch = function(...args) {
|
||||
};
|
||||
|
||||
function initApp() {
|
||||
applyI18n();
|
||||
_applyInputTooltips();
|
||||
_restoreSessionPanel();
|
||||
|
||||
fetch('/api/knowledge/list').then(r => r.json()).then(data => {
|
||||
if (data.status === 'success') _knowledgeTreeData = data.tree || [];
|
||||
}).catch(() => {});
|
||||
|
||||
@@ -90,6 +90,42 @@ def _get_upload_dir() -> str:
|
||||
return tmp_dir
|
||||
|
||||
|
||||
def _generate_session_title(user_message: str, assistant_reply: str = "") -> str:
|
||||
"""
|
||||
Generate a short session title by calling the current bot's reply_text.
|
||||
"""
|
||||
import re
|
||||
fallback = user_message[:50].split("\n")[0].strip() or "New Chat"
|
||||
try:
|
||||
from bridge.bridge import Bridge
|
||||
from models.session_manager import Session
|
||||
bot = Bridge().get_bot("chat")
|
||||
|
||||
prompt_parts = [f"User: {user_message[:300]}"]
|
||||
if assistant_reply:
|
||||
prompt_parts.append(f"Assistant: {assistant_reply[:300]}")
|
||||
|
||||
session = Session("__title_gen__", system_prompt="")
|
||||
session.messages = [
|
||||
{"role": "user", "content": (
|
||||
"Generate a very short title (max 15 characters for Chinese, max 6 words for English) "
|
||||
"summarizing this conversation. Return ONLY the title text, nothing else.\n\n"
|
||||
+ "\n".join(prompt_parts)
|
||||
)}
|
||||
]
|
||||
|
||||
result = bot.reply_text(session)
|
||||
raw = (result.get("content") or "").strip()
|
||||
# Strip <think>...</think> reasoning blocks
|
||||
title = re.sub(r'<think>.*?</think>', '', raw, flags=re.DOTALL).strip().strip('"\'')
|
||||
logger.info(f"[WebChannel] Title generation result: '{title}' (len={len(title)})")
|
||||
if title and len(title) <= 50:
|
||||
return title
|
||||
except Exception as e:
|
||||
logger.warning(f"[WebChannel] Title generation failed: {e}")
|
||||
return fallback
|
||||
|
||||
|
||||
class WebMessage(ChatMessage):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -519,6 +555,10 @@ class WebChannel(ChatChannel):
|
||||
'/api/knowledge/read', 'KnowledgeReadHandler',
|
||||
'/api/knowledge/graph', 'KnowledgeGraphHandler',
|
||||
'/api/scheduler', 'SchedulerHandler',
|
||||
'/api/sessions', 'SessionsHandler',
|
||||
'/api/sessions/(.*)/generate_title', 'SessionTitleHandler',
|
||||
'/api/sessions/(.*)/clear_context', 'SessionClearContextHandler',
|
||||
'/api/sessions/(.*)', 'SessionDetailHandler',
|
||||
'/api/history', 'HistoryHandler',
|
||||
'/api/logs', 'LogsHandler',
|
||||
'/api/version', 'VersionHandler',
|
||||
@@ -688,10 +728,15 @@ class StreamHandler:
|
||||
|
||||
class ChatHandler:
|
||||
def GET(self):
|
||||
# 正常返回聊天页面
|
||||
web.header('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||
web.header('Pragma', 'no-cache')
|
||||
file_path = os.path.join(os.path.dirname(__file__), 'chat.html')
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
html = f.read()
|
||||
cache_bust = str(int(time.time()))
|
||||
html = html.replace('assets/js/console.js', f'assets/js/console.js?v={cache_bust}')
|
||||
html = html.replace('assets/css/console.css', f'assets/css/console.css?v={cache_bust}')
|
||||
return html
|
||||
|
||||
|
||||
class ConfigHandler:
|
||||
@@ -1541,6 +1586,135 @@ class SchedulerHandler:
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
class SessionsHandler:
|
||||
def GET(self):
|
||||
_require_auth()
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
params = web.input(page='1', page_size='50')
|
||||
from agent.memory import get_conversation_store
|
||||
store = get_conversation_store()
|
||||
result = store.list_sessions(
|
||||
channel_type="web",
|
||||
page=int(params.page),
|
||||
page_size=int(params.page_size),
|
||||
)
|
||||
return json.dumps({"status": "success", **result}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Sessions API error: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
class SessionDetailHandler:
|
||||
def DELETE(self, session_id: str):
|
||||
_require_auth()
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
logger.info(f"[WebChannel] DELETE session request: {session_id}")
|
||||
try:
|
||||
if not session_id:
|
||||
return json.dumps({"status": "error", "message": "session_id required"})
|
||||
|
||||
from agent.memory import get_conversation_store
|
||||
store = get_conversation_store()
|
||||
store.clear_session(session_id)
|
||||
|
||||
# Also remove the Agent instance from AgentBridge if exists
|
||||
try:
|
||||
from bridge.bridge import Bridge
|
||||
ab = Bridge().get_agent_bridge()
|
||||
if session_id in ab.agents:
|
||||
del ab.agents[session_id]
|
||||
logger.info(f"[WebChannel] Removed agent instance for session {session_id}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
channel = WebChannel()
|
||||
channel.session_queues.pop(session_id, None)
|
||||
|
||||
logger.info(f"[WebChannel] Session deleted: {session_id}")
|
||||
return json.dumps({"status": "success"})
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Session delete error: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
def PUT(self, session_id: str):
|
||||
_require_auth()
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
if not session_id:
|
||||
return json.dumps({"status": "error", "message": "session_id required"})
|
||||
body = json.loads(web.data())
|
||||
title = body.get("title", "").strip()
|
||||
if not title:
|
||||
return json.dumps({"status": "error", "message": "title required"})
|
||||
|
||||
from agent.memory import get_conversation_store
|
||||
store = get_conversation_store()
|
||||
found = store.rename_session(session_id, title)
|
||||
if not found:
|
||||
return json.dumps({"status": "error", "message": "session not found"})
|
||||
return json.dumps({"status": "success"})
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Session rename error: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
class SessionTitleHandler:
|
||||
def POST(self, session_id: str):
|
||||
_require_auth()
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
if not session_id:
|
||||
return json.dumps({"status": "error", "message": "session_id required"})
|
||||
|
||||
body = json.loads(web.data())
|
||||
user_message = body.get("user_message", "")
|
||||
assistant_reply = body.get("assistant_reply", "")
|
||||
if not user_message:
|
||||
return json.dumps({"status": "error", "message": "user_message required"})
|
||||
|
||||
title = _generate_session_title(user_message, assistant_reply)
|
||||
|
||||
from agent.memory import get_conversation_store
|
||||
store = get_conversation_store()
|
||||
updated = store.rename_session(session_id, title)
|
||||
logger.info(f"[WebChannel] Session title set: sid={session_id}, title='{title}', db_updated={updated}")
|
||||
|
||||
return json.dumps({"status": "success", "title": title}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Title generation error: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
class SessionClearContextHandler:
|
||||
def POST(self, session_id: str):
|
||||
_require_auth()
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
if not session_id:
|
||||
return json.dumps({"status": "error", "message": "session_id required"})
|
||||
|
||||
from agent.memory import get_conversation_store
|
||||
store = get_conversation_store()
|
||||
new_seq = store.clear_context(session_id)
|
||||
|
||||
# Delete the agent instance so a fresh one is created on the next message
|
||||
try:
|
||||
from bridge.bridge import Bridge
|
||||
bridge = Bridge()
|
||||
ab = bridge.get_agent_bridge()
|
||||
if session_id in ab.agents:
|
||||
del ab.agents[session_id]
|
||||
logger.info(f"[WebChannel] Cleared agent instance for session {session_id}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return json.dumps({"status": "success", "context_start_seq": new_seq})
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Clear context error: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
class HistoryHandler:
|
||||
def GET(self):
|
||||
_require_auth()
|
||||
|
||||
@@ -45,6 +45,16 @@ Web 控制台是 CowAgent 的默认通道,启动后会自动运行,通过浏
|
||||
|
||||
<img width="850" src="https://cdn.link-ai.tech/doc/20260227180120.png" />
|
||||
|
||||
#### 多会话管理
|
||||
|
||||
对话界面支持多会话(Session)管理,所有会话记录持久化存储在数据库中:
|
||||
|
||||
- **会话列表**:点击左侧历史会话图标可展开/收起会话列表面板,支持滚动加载全部历史会话
|
||||
- **AI 生成标题**:新会话在首轮对话完成后,自动调用模型生成简短的会话摘要标题
|
||||
- **新建会话**:点击会话列表顶部的「新对话」按钮或输入区的 `+` 按钮创建新会话
|
||||
- **删除会话**:点击会话项的删除按钮,确认后永久删除该会话及其所有消息
|
||||
- **清除上下文**:点击输入区的清除按钮,在当前会话中插入一条分隔线,分隔线以上的消息仍然展示但不再作为模型的上下文输入
|
||||
|
||||
### 模型管理
|
||||
|
||||
支持在线管理模型配置,无需手动编辑配置文件:
|
||||
|
||||
@@ -38,6 +38,16 @@ Supports streaming output with real-time display of the Agent's reasoning proces
|
||||
|
||||
<img width="850" src="https://cdn.link-ai.tech/doc/20260227180120.png" />
|
||||
|
||||
#### Multi-Session Management
|
||||
|
||||
The chat interface supports multi-session management. All session records are persistently stored in a SQLite database:
|
||||
|
||||
- **Session List**: Click the history icon on the left to expand/collapse the session list panel, with scroll-to-load support for all historical sessions
|
||||
- **AI-Generated Titles**: After the first exchange in a new session, the model is automatically called to generate a short summary title
|
||||
- **New Session**: Click the "New Chat" button at the top of the session list or the `+` button in the input area to create a new session
|
||||
- **Delete Session**: Click the delete button on a session item and confirm to permanently delete the session and all its messages
|
||||
- **Clear Context**: Click the clear button in the input area to insert a divider in the current session. Messages above the divider are still displayed but no longer included as context for the model
|
||||
|
||||
### Model Management
|
||||
|
||||
Manage model configurations online without manually editing config files:
|
||||
|
||||
@@ -38,6 +38,16 @@ Web コンソールは CowAgent のデフォルトチャネルです。起動後
|
||||
|
||||
<img width="850" src="https://cdn.link-ai.tech/doc/20260227180120.png" />
|
||||
|
||||
#### マルチセッション管理
|
||||
|
||||
チャット画面はマルチセッション管理に対応しています。すべてのセッション記録は SQLite データベースに永続的に保存されます:
|
||||
|
||||
- **セッション一覧**:左側の履歴アイコンをクリックしてセッション一覧パネルを展開/折りたたみでき、スクロールですべての履歴セッションを読み込めます
|
||||
- **AI によるタイトル生成**:新しいセッションの最初のやり取りが完了すると、自動的にモデルを呼び出して短い要約タイトルを生成します
|
||||
- **新規セッション**:セッション一覧上部の「新しい会話」ボタン、または入力エリアの `+` ボタンをクリックして新しいセッションを作成します
|
||||
- **セッション削除**:セッション項目の削除ボタンをクリックし、確認後にそのセッションとすべてのメッセージを完全に削除します
|
||||
- **コンテキストクリア**:入力エリアのクリアボタンをクリックすると、現在のセッションに区切り線が挿入されます。区切り線より上のメッセージは表示されたままですが、モデルのコンテキストには含まれなくなります
|
||||
|
||||
### モデル管理
|
||||
|
||||
設定ファイルを手動で編集せずに、オンラインでモデル設定を管理できます:
|
||||
|
||||
Reference in New Issue
Block a user