feat(personalization): 个人空间支持配置对话标题生成模型

新增 title_model 配置,复用子智能体模型库条目生成对话标题:
- 个人空间「常规」页签新增「标题生成模型」下拉菜单(复用子智能体
  模型列表,general 页签挂载时加载)
- 标题生成改为使用所选子智能体模型 profile;未配置时回落模型库
  default_model,移除 AGENT_TITLE_* 环境变量覆盖与主智能体默认
  模型回退(个人空间为唯一配置来源)
- personalization 白名单注册 title_model 键(默认值 + sanitize)
This commit is contained in:
JOJO 2026-09-01 09:46:05 +08:00
parent 55b3ee44b7
commit deaa1560a5
11 changed files with 122 additions and 43 deletions

View File

@ -82,6 +82,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"default_permission_mode": "unrestricted", "default_permission_mode": "unrestricted",
"default_work_mode": "plan", # 默认运行模式plan / ask / execute "default_work_mode": "plan", # 默认运行模式plan / ask / execute
"auto_generate_title": True, "auto_generate_title": True,
"title_model": "", # 对话标题生成使用的子智能体模型条目名(空=跟随主对话默认模型)
"recent_conversations_prompt_enabled": False, "recent_conversations_prompt_enabled": False,
"recent_conversations_prompt_limit": RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT, "recent_conversations_prompt_limit": RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT,
"project_memory_inject_limit": PROJECT_MEMORY_INJECT_LIMIT_DEFAULT, # 项目记忆索引最大注入条数None-无上限 / >=5-该值 "project_memory_inject_limit": PROJECT_MEMORY_INJECT_LIMIT_DEFAULT, # 项目记忆索引最大注入条数None-无上限 / >=5-该值
@ -278,6 +279,7 @@ def sanitize_personalization_payload(
else "medium" else "medium"
) )
base["auto_generate_title"] = bool(data.get("auto_generate_title", base["auto_generate_title"])) base["auto_generate_title"] = bool(data.get("auto_generate_title", base["auto_generate_title"]))
base["title_model"] = str(data.get("title_model", base.get("title_model", "")) or "").strip()
base["recent_conversations_prompt_enabled"] = bool( base["recent_conversations_prompt_enabled"] = bool(
data.get("recent_conversations_prompt_enabled", base.get("recent_conversations_prompt_enabled", False)) data.get("recent_conversations_prompt_enabled", base.get("recent_conversations_prompt_enabled", False))
) )

View File

@ -18,7 +18,7 @@ from config import DATA_DIR
from config.sub_agent import SUB_AGENT_MODELS_CONFIG_FILE from config.sub_agent import SUB_AGENT_MODELS_CONFIG_FILE
from modules.personalization_manager import REVIEW_AGENT_KEYS from modules.personalization_manager import REVIEW_AGENT_KEYS
__all__ = ["resolve_review_agent_config", "REVIEW_AGENT_KEYS"] __all__ = ["resolve_review_agent_config", "resolve_sub_agent_model_profile", "REVIEW_AGENT_KEYS"]
def _load_model_entry(model_name: str) -> Dict[str, Any]: def _load_model_entry(model_name: str) -> Dict[str, Any]:
@ -52,6 +52,15 @@ def _load_model_entry(model_name: str) -> Dict[str, Any]:
return model_map.get(chosen) return model_map.get(chosen)
def resolve_sub_agent_model_profile(model_name: str) -> Dict[str, Any]:
"""按名称从子智能体模型库解析模型 profileAPIClient.apply_profile 格式)。
名称留空或不存在时回落模型库 default_model模型库也不可用时返回 None
由调用方决定兜底行为
"""
return _load_model_entry(str(model_name).strip() if model_name and str(model_name).strip() else "")
def resolve_review_agent_config(agent_key: str) -> Dict[str, Any]: def resolve_review_agent_config(agent_key: str) -> Dict[str, Any]:
"""解析指定审核智能体的完整运行配置。 """解析指定审核智能体的完整运行配置。

View File

@ -112,7 +112,7 @@ from modules.i18n import tr
conversation_bp = Blueprint('conversation', __name__) conversation_bp = Blueprint('conversation', __name__)
def generate_conversation_title_background(web_terminal: WebTerminal, conversation_id: str, user_message: str, username: str): def generate_conversation_title_background(web_terminal: WebTerminal, conversation_id: str, user_message: str, username: str, title_model: str = ""):
"""在后台生成对话标题并更新索引、推送给前端。""" """在后台生成对话标题并更新索引、推送给前端。"""
return _generate_conversation_title_background( return _generate_conversation_title_background(
web_terminal=web_terminal, web_terminal=web_terminal,
@ -122,6 +122,7 @@ def generate_conversation_title_background(web_terminal: WebTerminal, conversati
socketio_instance=socketio, socketio_instance=socketio,
title_prompt_path=TITLE_PROMPT_PATH, title_prompt_path=TITLE_PROMPT_PATH,
debug_logger=debug_log, debug_logger=debug_log,
title_model=title_model,
) )

View File

@ -2,7 +2,6 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import os
import re import re
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -11,7 +10,6 @@ from typing import Any, Dict, List, Optional
from core.web_terminal import WebTerminal from core.web_terminal import WebTerminal
from config import LOGS_DIR from config import LOGS_DIR
from utils.api_client import APIClient from utils.api_client import APIClient
from config.model_profiles import get_default_model_key, get_model_profile
TITLE_DEBUG_DIR = Path(LOGS_DIR).expanduser().resolve() / "title_debug" TITLE_DEBUG_DIR = Path(LOGS_DIR).expanduser().resolve() / "title_debug"
TITLE_DEBUG_FILE = TITLE_DEBUG_DIR / "title_generation.log" TITLE_DEBUG_FILE = TITLE_DEBUG_DIR / "title_generation.log"
@ -32,44 +30,33 @@ def _title_debug_log(message: str, **extra: Any) -> None:
pass pass
def _env_optional(name: str) -> Optional[str]:
"""从环境变量获取可选配置(已由 config/__init__.py 统一注入)。"""
value = os.environ.get(name)
if value is None:
return None
value = value.strip()
return value or None
async def _generate_title_async( async def _generate_title_async(
user_message: str, user_message: str,
title_prompt_path, title_prompt_path,
debug_logger, debug_logger,
model_profile: Optional[Dict[str, Any]] = None,
) -> Optional[str]: ) -> Optional[str]:
"""使用快速模型生成对话标题。""" """使用子智能体模型生成对话标题。
model_profile 来自个人空间标题生成模型配置解析出的子智能体模型库条目
未配置时为模型库 default_model个人空间是唯一配置来源不使用主智能体
默认模型也不支持 AGENT_TITLE_* 环境变量覆盖profile 缺失或应用失败时
直接放弃生成带日志不做其他回退
"""
if not user_message: if not user_message:
_title_debug_log("skip_empty_user_message") _title_debug_log("skip_empty_user_message")
return None return None
client = APIClient(thinking_mode=False, web_mode=True) client = APIClient(thinking_mode=False, web_mode=True)
if not model_profile:
_title_debug_log("title_model_profile_missing")
return None
try: try:
default_model = get_default_model_key() client.apply_profile(model_profile)
client.model_key = default_model _title_debug_log("title_model_profile_applied", model_name=model_profile.get("name"))
client.apply_profile(get_model_profile(default_model))
except Exception as exc: except Exception as exc:
_title_debug_log("default_title_model_profile_failed", error=str(exc)) _title_debug_log("title_model_profile_failed", error=str(exc), model_name=model_profile.get("name"))
title_base = _env_optional("AGENT_TITLE_API_BASE_URL") return None
title_key = _env_optional("AGENT_TITLE_API_KEY")
title_model = _env_optional("AGENT_TITLE_MODEL_ID")
if title_base:
client.fast_api_config["base_url"] = title_base
client.api_base_url = title_base
if title_key:
client.fast_api_config["api_key"] = title_key
client.api_key = title_key
if title_model:
client.fast_api_config["model_id"] = title_model
client.model_id = title_model
_title_debug_log("start_generate_title", user_message_preview=str(user_message)[:200], user_message_len=len(str(user_message))) _title_debug_log("start_generate_title", user_message_preview=str(user_message)[:200], user_message_len=len(str(user_message)))
_title_debug_log( _title_debug_log(
"title_api_config", "title_api_config",
@ -118,13 +105,27 @@ def generate_conversation_title_background(
socketio_instance, socketio_instance,
title_prompt_path, title_prompt_path,
debug_logger, debug_logger,
title_model: str = "",
): ):
"""在后台生成对话标题并更新索引、推送给前端。""" """在后台生成对话标题并更新索引、推送给前端。
title_model 为个人空间配置的子智能体模型条目名 = 子智能体模型库
default_model个人空间是唯一配置来源
"""
if not conversation_id or not user_message: if not conversation_id or not user_message:
return return
async def _runner(): async def _runner():
title = await _generate_title_async(user_message, title_prompt_path, debug_logger) try:
from modules.review_agent_config import resolve_sub_agent_model_profile
# 未配置(空)时回落子智能体模型库 default_model个人空间为唯一配置来源
model_profile = resolve_sub_agent_model_profile(title_model)
except Exception:
model_profile = None
if model_profile is None:
_title_debug_log("title_model_profile_unavailable", title_model=title_model, conversation_id=conversation_id)
return
title = await _generate_title_async(user_message, title_prompt_path, debug_logger, model_profile=model_profile)
if not title: if not title:
_title_debug_log("title_not_generated", conversation_id=conversation_id, username=username) _title_debug_log("title_not_generated", conversation_id=conversation_id, username=username)
return return

View File

@ -104,7 +104,7 @@ from .chat_flow_runner_helpers import (
) )
def generate_conversation_title_background(web_terminal: WebTerminal, conversation_id: str, user_message: str, username: str): def generate_conversation_title_background(web_terminal: WebTerminal, conversation_id: str, user_message: str, username: str, title_model: str = ""):
return _generate_conversation_title_background( return _generate_conversation_title_background(
web_terminal=web_terminal, web_terminal=web_terminal,
conversation_id=conversation_id, conversation_id=conversation_id,
@ -113,6 +113,7 @@ def generate_conversation_title_background(web_terminal: WebTerminal, conversati
socketio_instance=socketio, socketio_instance=socketio,
title_prompt_path=TITLE_PROMPT_PATH, title_prompt_path=TITLE_PROMPT_PATH,
debug_logger=debug_log, debug_logger=debug_log,
title_model=title_model,
) )
def detect_malformed_tool_call(text): def detect_malformed_tool_call(text):

View File

@ -1855,7 +1855,8 @@ async def handle_task_with_sender(
web_terminal, web_terminal,
conv_id, conv_id,
message, message,
username username,
personal_config.get("title_model", "")
) )
# 自动深层压缩(用户输入后触发) # 自动深层压缩(用户输入后触发)

View File

@ -874,14 +874,16 @@ watch(
if (isVisible && tab === 'review-agents') { if (isVisible && tab === 'review-agents') {
loadSubAgentModels(); loadSubAgentModels();
} }
if ( if (isVisible && tab === 'general') {
isVisible && //
tab === 'general' && loadSubAgentModels();
isAppShell.value && if (
!appUpdateInfo.value && isAppShell.value &&
!appUpdateChecking.value !appUpdateInfo.value &&
) { !appUpdateChecking.value
checkAppUpdate(); ) {
checkAppUpdate();
}
} }
} }
); );

View File

@ -32,6 +32,10 @@ const sandboxShowWizard = computed(() => {
*/ */
const ctx = inject<Record<string, any>>('personalizationDrawer')!; const ctx = inject<Record<string, any>>('personalizationDrawer')!;
const { const {
activeDropdown,
activeTheme,
closeDropdown,
floatingMenuStyle,
personalization, personalization,
form, form,
startTutorial, startTutorial,
@ -42,7 +46,9 @@ const {
appUpdateStateText, appUpdateStateText,
appUpdateChecking, appUpdateChecking,
checkAppUpdate, checkAppUpdate,
downloadLatestApp downloadLatestApp,
subAgentModels,
toggleDropdown
} = ctx; } = ctx;
</script> </script>
@ -65,6 +71,52 @@ const {
/> />
<FancyCheck :checked="form.auto_generate_title" /> <FancyCheck :checked="form.auto_generate_title" />
</label> </label>
<!-- 标题生成模型复用子智能体模型库配置个人空间为唯一配置来源 -->
<div class="settings-select-row">
<span class="settings-row-copy">
<span class="settings-row-title">{{ $t('personalization.titleModelTitle') }}</span>
<span class="settings-row-desc">{{ $t('personalization.titleModelDesc') }}</span>
</span>
<div
class="settings-select-wrap"
:class="{ open: activeDropdown === 'title-model' }"
@click.stop
>
<button
type="button"
class="settings-select-button"
@click="toggleDropdown('title-model')"
>
{{ form.title_model || $t('personalization.defaultModelOption') }}
<span class="select-chevron" aria-hidden="true"></span>
</button>
<div
:class="['settings-floating-menu', { dark: activeTheme === 'dark' }]"
:style="activeDropdown === 'title-model' ? floatingMenuStyle : undefined"
>
<button
type="button"
class="settings-menu-option"
:class="{ selected: !form.title_model }"
@click="personalization.updateField({ key: 'title_model', value: '' }); closeDropdown()"
>
<strong>{{ $t('personalization.defaultModelOption') }}</strong><span>{{ $t('personalization.titleDefaultModelDesc') }}</span><svg viewBox="0 0 24 24"><path d="M5 12.5 9.5 17 19 7" /></svg>
</button>
<button
v-for="m in subAgentModels"
:key="m.name"
type="button"
class="settings-menu-option"
:class="{ selected: form.title_model === m.name }"
@click="personalization.updateField({ key: 'title_model', value: m.name }); closeDropdown()"
>
<strong>{{ m.name }}</strong><span>{{ m.modes }} · {{ m.multimodal || $t('personalization.textOnly') }}</span><svg viewBox="0 0 24 24"><path d="M5 12.5 9.5 17 19 7" /></svg>
</button>
</div>
</div>
</div>
<div class="settings-action-row"> <div class="settings-action-row">
<span class="settings-row-copy"> <span class="settings-row-copy">
<span class="settings-row-title">{{ $t('personalization.tutorialTitle') }}</span> <span class="settings-row-title">{{ $t('personalization.tutorialTitle') }}</span>

View File

@ -330,6 +330,9 @@ export default {
reviewAgentWorkflowReviewDesc: 'Judges whether stage output meets the bar at workflow review nodes', reviewAgentWorkflowReviewDesc: 'Judges whether stage output meets the bar at workflow review nodes',
reviewModelEmptyDesc: 'Leave empty to use the model library default', reviewModelEmptyDesc: 'Leave empty to use the model library default',
reviewDefaultModelDesc: 'Uses the model library default_model', reviewDefaultModelDesc: 'Uses the model library default_model',
titleModelTitle: 'Title generation model',
titleModelDesc: 'Choose the AI model used to generate conversation titles, shared with sub-agents',
titleDefaultModelDesc: 'Uses the sub-agent model library default',
reviewThinkingDesc: 'Falls back to fast mode automatically when the model does not support thinking', reviewThinkingDesc: 'Falls back to fast mode automatically when the model does not support thinking',
timeoutTitle: 'Review request timeout', timeoutTitle: 'Review request timeout',
timeoutDesc: 'Single model request timeout (5-3600 seconds)', timeoutDesc: 'Single model request timeout (5-3600 seconds)',

View File

@ -331,6 +331,9 @@ export default {
reviewAgentWorkflowReviewDesc: '工作流审核节点判断阶段产出是否达标', reviewAgentWorkflowReviewDesc: '工作流审核节点判断阶段产出是否达标',
reviewModelEmptyDesc: '留空则使用模型库默认模型', reviewModelEmptyDesc: '留空则使用模型库默认模型',
reviewDefaultModelDesc: '使用模型库的 default_model', reviewDefaultModelDesc: '使用模型库的 default_model',
titleModelTitle: '标题生成模型',
titleModelDesc: '选择用于生成对话标题的 AI 模型,与子智能体共用模型库',
titleDefaultModelDesc: '使用子智能体模型库的默认模型',
reviewThinkingDesc: '模型不支持思考时自动回落快速模式', reviewThinkingDesc: '模型不支持思考时自动回落快速模式',
timeoutTitle: '审核请求超时', timeoutTitle: '审核请求超时',
timeoutDesc: '单次模型请求超时5-3600 秒)', timeoutDesc: '单次模型请求超时5-3600 秒)',

View File

@ -94,6 +94,8 @@ interface PersonalForm {
communication_style: CommunicationStyle; communication_style: CommunicationStyle;
conversation_continuity: ConversationContinuity; conversation_continuity: ConversationContinuity;
auto_generate_title: boolean; auto_generate_title: boolean;
/** 标题生成模型(子智能体模型库条目名);留空 = 模型库 default_model */
title_model: string;
recent_conversations_prompt_enabled: boolean; recent_conversations_prompt_enabled: boolean;
recent_conversations_prompt_limit: number | string; recent_conversations_prompt_limit: number | string;
project_memory_inject_limit: number | string | null; project_memory_inject_limit: number | string | null;
@ -300,6 +302,7 @@ const defaultForm = (): PersonalForm => ({
communication_style: 'default', communication_style: 'default',
conversation_continuity: 'medium', conversation_continuity: 'medium',
auto_generate_title: true, auto_generate_title: true,
title_model: '',
recent_conversations_prompt_enabled: false, recent_conversations_prompt_enabled: false,
recent_conversations_prompt_limit: DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT, recent_conversations_prompt_limit: DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT,
project_memory_inject_limit: DEFAULT_PROJECT_MEMORY_INJECT_LIMIT, project_memory_inject_limit: DEFAULT_PROJECT_MEMORY_INJECT_LIMIT,
@ -515,6 +518,7 @@ export const usePersonalizationStore = defineStore('personalization', {
? data.conversation_continuity ? data.conversation_continuity
: 'medium', : 'medium',
auto_generate_title: data.auto_generate_title !== false, auto_generate_title: data.auto_generate_title !== false,
title_model: typeof data.title_model === 'string' ? data.title_model : '',
recent_conversations_prompt_enabled: !!data.recent_conversations_prompt_enabled, recent_conversations_prompt_enabled: !!data.recent_conversations_prompt_enabled,
recent_conversations_prompt_limit: this.normalizeRecentConversationsPromptLimit( recent_conversations_prompt_limit: this.normalizeRecentConversationsPromptLimit(
data.recent_conversations_prompt_limit data.recent_conversations_prompt_limit