feat: auto-generate chat titles with personalization toggle

This commit is contained in:
JOJO 2025-12-30 09:43:53 +08:00
parent 7b735e252f
commit 5bdbfa138e
5 changed files with 132 additions and 5 deletions

View File

@ -34,6 +34,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"thinking_interval": None,
"disabled_tool_categories": [],
"default_run_mode": None,
"auto_generate_title": True,
}
__all__ = [
@ -108,6 +109,7 @@ def sanitize_personalization_payload(
return _sanitize_short_field(base.get(key))
base["enabled"] = bool(data.get("enabled", base["enabled"]))
base["auto_generate_title"] = bool(data.get("auto_generate_title", base["auto_generate_title"]))
base["self_identify"] = _resolve_short_field("self_identify")
base["user_name"] = _resolve_short_field("user_name")
base["profession"] = _resolve_short_field("profession")

View File

@ -207,6 +207,20 @@
</button>
</div>
</div>
<div class="behavior-field">
<div class="behavior-field-header">
<span class="field-title">自动生成对话标题</span>
<p class="field-desc">默认开启关闭后标题将沿用首条消息</p>
</div>
<label class="toggle-row">
<input
type="checkbox"
:checked="form.auto_generate_title"
@change="personalization.updateField({ key: 'auto_generate_title', value: $event.target.checked })"
/>
<span>使用快速模型为新对话生成含 emoji 的简短标题</span>
</label>
</div>
<div class="behavior-field">
<div class="behavior-field-header">
<span class="field-title">思考频率</span>

View File

@ -4,6 +4,7 @@ type RunMode = 'fast' | 'thinking' | 'deep';
interface PersonalForm {
enabled: boolean;
auto_generate_title: boolean;
self_identify: string;
user_name: string;
profession: string;
@ -51,6 +52,7 @@ const EXPERIMENT_STORAGE_KEY = 'agents_personalization_experiments';
const defaultForm = (): PersonalForm => ({
enabled: false,
auto_generate_title: true,
self_identify: '',
user_name: '',
profession: '',
@ -169,6 +171,7 @@ export const usePersonalizationStore = defineStore('personalization', {
applyPersonalizationData(data: any) {
this.form = {
enabled: !!data.enabled,
auto_generate_title: data.auto_generate_title !== false,
self_identify: data.self_identify || '',
user_name: data.user_name || '',
profession: data.profession || '',
@ -282,7 +285,7 @@ export const usePersonalizationStore = defineStore('personalization', {
this.saving = false;
}
},
updateField(payload: { key: keyof PersonalForm; value: string }) {
updateField(payload: { key: keyof PersonalForm; value: any }) {
if (!payload || !payload.key) {
return;
}

View File

@ -323,6 +323,25 @@ class ConversationManager:
print(f"📝 创建新对话: {conversation_id} - {conversation_data['title']}")
return conversation_id
def update_conversation_title(self, conversation_id: str, title: str) -> bool:
"""更新对话标题并刷新索引。"""
if not conversation_id or not title:
return False
try:
data = self.load_conversation(conversation_id)
if not data:
return False
data["title"] = title
data["updated_at"] = datetime.now().isoformat()
self._save_conversation_file(conversation_id, data)
self._update_index(conversation_id, data)
if self.current_conversation_id == conversation_id:
self.current_conversation_title = title
return True
except Exception as exc:
print(f"⚠️ 更新对话标题失败 {conversation_id}: {exc}")
return False
def _save_conversation_file(self, conversation_id: str, data: Dict):
"""保存对话文件"""

View File

@ -100,6 +100,7 @@ from modules.user_container_manager import UserContainerManager
from modules.usage_tracker import UsageTracker, QUOTA_DEFAULTS
from utils.tool_result_formatter import format_tool_result_for_context
from utils.conversation_manager import ConversationManager
from utils.api_client import DeepSeekClient
app = Flask(__name__, static_folder='static')
app.config['MAX_CONTENT_LENGTH'] = MAX_UPLOAD_SIZE
@ -180,6 +181,18 @@ LAST_ACTIVE_FILE = Path(LOGS_DIR).expanduser().resolve() / "last_active.json"
_last_active_lock = threading.Lock()
_last_active_cache: Dict[str, float] = {}
_idle_reaper_started = False
TITLE_PROMPT = """### 任务:
生成一个简洁的3-5个词的标题并包含一个emoji用于总结聊天内容
### 指导原则:
- 标题应清晰代表主要话题
- 使用与话题相关且单个的emoji
- 保持简洁3-5个词
- 清晰度优先于创意
- 使用用户输入的语言回复
### 输出格式:
仅输出标题本身不要附加解释"""
def sanitize_filename_preserve_unicode(filename: str) -> str:
@ -284,6 +297,64 @@ def start_background_jobs():
_load_last_active_cache()
socketio.start_background_task(idle_reaper_loop)
async def _generate_title_async(user_message: str) -> Optional[str]:
"""使用快速模型生成对话标题。"""
if not user_message:
return None
client = DeepSeekClient(thinking_mode=False, web_mode=True)
messages = [
{"role": "system", "content": TITLE_PROMPT},
{"role": "user", "content": user_message}
]
try:
async for resp in client.chat(messages, tools=[], stream=False):
try:
content = resp.get("choices", [{}])[0].get("message", {}).get("content")
if content:
return " ".join(str(content).strip().split())
except Exception:
continue
except Exception as exc:
debug_log(f"[TitleGen] 生成标题异常: {exc}")
return None
def generate_conversation_title_background(web_terminal: WebTerminal, conversation_id: str, user_message: str, username: str):
"""在后台生成对话标题并更新索引、推送给前端。"""
if not conversation_id or not user_message:
return
async def _runner():
title = await _generate_title_async(user_message)
if not title:
return
# 限长,避免标题过长
safe_title = title[:80]
ok = False
try:
ok = web_terminal.context_manager.conversation_manager.update_conversation_title(conversation_id, safe_title)
except Exception as exc:
debug_log(f"[TitleGen] 保存标题失败: {exc}")
if not ok:
return
try:
socketio.emit('conversation_changed', {
'conversation_id': conversation_id,
'title': safe_title
}, room=f"user_{username}")
socketio.emit('conversation_list_update', {
'action': 'updated',
'conversation_id': conversation_id
}, room=f"user_{username}")
except Exception as exc:
debug_log(f"[TitleGen] 推送标题更新失败: {exc}")
try:
asyncio.run(_runner())
except Exception as exc:
debug_log(f"[TitleGen] 任务执行失败: {exc}")
def cache_monitor_snapshot(execution_id: Optional[str], stage: str, snapshot: Optional[Dict[str, Any]]):
"""缓存工具执行前/后的文件快照。"""
if not execution_id or not snapshot or not snapshot.get('content'):
@ -2300,7 +2371,7 @@ def handle_message(data):
send_to_client(event_type, data)
# 传递客户端ID
socketio.start_background_task(process_message_task, terminal, message, send_with_activity, client_sid)
socketio.start_background_task(process_message_task, terminal, message, send_with_activity, client_sid, workspace, username)
@socketio.on('client_chunk_log')
@ -2783,14 +2854,14 @@ def get_current_conversation(terminal: WebTerminal, workspace: UserWorkspace, us
"error": str(e)
}), 500
def process_message_task(terminal: WebTerminal, message: str, sender, client_sid):
def process_message_task(terminal: WebTerminal, message: str, sender, client_sid, workspace: UserWorkspace, username: str):
"""在后台处理消息任务"""
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# 创建可取消的任务
task = loop.create_task(handle_task_with_sender(terminal, message, sender, client_sid))
task = loop.create_task(handle_task_with_sender(terminal, workspace, message, sender, client_sid, username))
entry = stop_flags.get(client_sid)
if not isinstance(entry, dict):
@ -2865,7 +2936,7 @@ def detect_malformed_tool_call(text):
return False
async def handle_task_with_sender(terminal: WebTerminal, message, sender, client_sid):
async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspace, message, sender, client_sid, username: str):
"""处理任务并发送消息 - 集成token统计版本"""
web_terminal = terminal
conversation_id = getattr(web_terminal.context_manager, "current_conversation_id", None)
@ -2879,8 +2950,26 @@ async def handle_task_with_sender(terminal: WebTerminal, message, sender, client
state["suppress_next"] = False
# 添加到对话历史
history_len_before = len(getattr(web_terminal.context_manager, "conversation_history", []) or [])
is_first_user_message = history_len_before == 0
web_terminal.context_manager.add_conversation("user", message)
if is_first_user_message and getattr(web_terminal, "context_manager", None):
try:
personal_config = load_personalization_config(workspace.data_dir)
except Exception:
personal_config = {}
auto_title_enabled = personal_config.get("auto_generate_title", True)
if auto_title_enabled:
conv_id = getattr(web_terminal.context_manager, "current_conversation_id", None)
socketio.start_background_task(
generate_conversation_title_background,
web_terminal,
conv_id,
message,
username
)
# === 移除不在这里计算输入token改为在每次API调用前计算 ===
# 构建上下文和消息用于API调用