feat & fix: 新增打个招呼选项 创建新会话;修复ai重复性提问的问题;修复输入键盘覆盖对话气泡的问题

This commit is contained in:
yangshilin
2026-03-11 14:39:39 +08:00
parent 4b4dea8a45
commit 4d2c31b5a6
9 changed files with 309 additions and 59 deletions

View File

@@ -12,7 +12,7 @@ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from services.llm_service import llm_service
from services.redis_service import redis_service
from .prompts import ConversationStage, get_conversation_prompt, get_guided_conversation_prompt
from .prompts import ConversationStage, get_conversation_prompt, get_guided_conversation_prompt, get_opening_prompt
from .prompts.profile_prompts import (
get_profile_greeting_prompt,
get_profile_extraction_prompt,
@@ -20,6 +20,7 @@ from .prompts.profile_prompts import (
format_user_profile_context,
get_missing_profile_fields,
)
from .prompts.conversation_prompts import SLOT_NAME_MAP
from .state_schema import MemoirStateSchema
logger = logging.getLogger(__name__)
@@ -138,19 +139,83 @@ class ConversationAgent:
logger.error(f"生成资料收集开场白失败: {e}")
return ["你好!在我们开始聊人生故事之前,能先简单介绍一下你自己吗?比如你是哪一年出生的?"]
async def extract_profile_from_message(self, user_message: str, missing_fields: List[str]) -> Dict[str, Any]:
"""从用户消息中提取基础资料信息"""
async def generate_opening_message(
self,
conversation_id: str,
memoir_state: MemoirStateSchema,
user_profile_context: str = "",
) -> List[str]:
"""
空对话时 AI 先开口:用户通过「打个招呼」进入,尚未发任何消息。
生成问候 + 一个引导问题,写入 Redis 并返回消息列表。
"""
if not self.llm:
return ["你好呀~ 有空聊聊你的人生故事吗?你小时候是在哪儿长大的?"]
try:
empty_slots = memoir_state.empty_slots_for_current_stage()
empty_slots_readable = [SLOT_NAME_MAP.get(s, s) for s in empty_slots]
if not empty_slots_readable:
empty_slots_readable = ["成长的地方", "难忘的事", "重要的人"]
prompt = get_opening_prompt(
current_stage=memoir_state.current_stage,
empty_slots_readable=empty_slots_readable,
user_profile_context=user_profile_context,
)
full_prompt = f"{prompt}\n\nAssistant:"
response = await self.llm.ainvoke(full_prompt)
response_text = response.content if hasattr(response, "content") else str(response)
await self._save_message(conversation_id, "ai", response_text)
messages = [msg.strip() for msg in response_text.split("[SPLIT]") if msg.strip()]
return messages[:3] if messages else [response_text]
except Exception as e:
logger.error(f"生成开场白失败: {e}", exc_info=True)
return ["你好呀~ 有空聊聊你的人生故事吗?你童年里印象最深的一件事是什么?"]
async def extract_profile_from_message(
self,
user_message: str,
missing_fields: List[str],
conversation_id: Optional[str] = None,
) -> Dict[str, Any]:
"""从用户消息中提取基础资料信息;若提供 conversation_id会结合最近几轮对话一起提取避免漏提。"""
if not self.llm or not missing_fields:
return {}
recent_dialogue = ""
if conversation_id:
history_messages = await self._get_history_messages(conversation_id)
# 取最近 4 条2 轮),不包含本轮;本轮由 user_message 单独传入
recent = history_messages[-4:] if len(history_messages) > 4 else history_messages
parts = []
for msg in recent:
if isinstance(msg, HumanMessage):
parts.append(f"用户: {msg.content}")
elif isinstance(msg, AIMessage):
parts.append(f"助手: {msg.content}")
recent_dialogue = "\n".join(parts) if parts else ""
try:
prompt = get_profile_extraction_prompt(user_message, missing_fields)
prompt = get_profile_extraction_prompt(
user_message, missing_fields, recent_dialogue=recent_dialogue or None
)
response = await self.llm.ainvoke(prompt)
content = response.content.strip()
parsed = json.loads(content)
result = {}
if "birth_year" in parsed and isinstance(parsed["birth_year"], int):
result["birth_year"] = parsed["birth_year"]
if "birth_year" in parsed and parsed["birth_year"] is not None:
raw = parsed["birth_year"]
if isinstance(raw, int) and 1900 <= raw <= 2100:
result["birth_year"] = raw
elif isinstance(raw, str) and raw.isdigit():
y = int(raw)
if y < 100: # "65" -> 1965
y = 1900 + y if y >= 50 else 2000 + y
if 1900 <= y <= 2100:
result["birth_year"] = y
if "birth_place" in parsed and parsed["birth_place"]:
result["birth_place"] = str(parsed["birth_place"])
if "grew_up_place" in parsed and parsed["grew_up_place"]:

View File

@@ -6,6 +6,7 @@ from .conversation_prompts import (
get_system_prompt as get_conversation_prompt,
get_questions_for_stage,
get_guided_conversation_prompt,
get_opening_prompt,
INTERVIEW_QUESTIONS,
)
from .memory_prompts import (
@@ -34,6 +35,7 @@ __all__ = [
"get_conversation_prompt",
"get_questions_for_stage",
"get_guided_conversation_prompt",
"get_opening_prompt",
"INTERVIEW_QUESTIONS",
"get_memory_prompt",
"get_chapter_classification_prompt",

View File

@@ -173,6 +173,47 @@ RESPONSE_STYLES = [
]
def get_opening_prompt(
current_stage: str,
empty_slots_readable: List[str],
user_profile_context: str = "",
) -> str:
"""
空对话时 AI 先开口的提示词(用户通过「打个招呼」进入,尚未发送任何消息)。
要求 AI 先发一条问候 + 一个具体问题,引导用户开始分享。
"""
stage_name_map = {
"childhood": "童年时光",
"education": "求学经历",
"career": "职业生涯",
"family": "家庭生活",
"belief": "人生信念",
}
stage_name = stage_name_map.get(current_stage, current_stage)
topics_str = "".join(empty_slots_readable) if empty_slots_readable else "人生故事、童年、经历等"
profile_section = f"\n## 用户基本信息\n{user_profile_context}\n" if user_profile_context else ""
return f"""你是「岁月知己」,用户的老朋友。用户刚通过「打个招呼」进入空对话,**还没有发任何消息**,需要你先开口。
{profile_section}
## 当前建议话题({stage_name}
可以从中选一个来问:{topics_str}
## 你的任务
1. **先开口**:用一两句亲切的问候开场(如「你好呀,有空聊聊你的故事吗」)。
2. **必须问一个问题**:接着问一个**具体、好回答**的问题,引导用户开始分享(如童年、家乡、印象深的事等)。不要问太宽泛的「有什么想聊的」。
3. 语气像老朋友,自然、温暖。
## 回复格式
- 可以分成 2 条消息,用 [SPLIT] 分隔:第一条问候,第二条问题;或合并成一条「问候 + 问题」。
- 禁止输出括号、注释、思考过程。
示例(仅供参考风格):
"你好呀~ 有空的话想听听你的人生故事。你小时候是在哪儿长大的?那边有什么特别让你怀念的?"
"在的!今天想聊聊你。你童年里印象最深的一件事是什么?"
直接输出你要说的话(多条用 [SPLIT] 分隔):"""
def _build_era_context(current_stage: str, user_profile_context: str) -> str:
"""
根据用户的人生阶段和出生年份,生成对应时代的历史/政治/文化背景提示。
@@ -402,6 +443,7 @@ def get_guided_conversation_prompt(
- 禁止反复追问同一件事
- 禁止每次都以问题结尾
- **禁止在用户聊别的话题时强行拉回之前的话题**
- **禁止询问或再次确认「用户基本信息」中已列出的内容**(如出生年份、出生地、成长地、职业等,这些你已经知道,不要问第二遍)
## 好的回应示例
- "哈哈,你这说的让我想起..."(轻松)

View File

@@ -48,16 +48,25 @@ def get_profile_greeting_prompt(missing_fields: List[str], nickname: str = "") -
直接输出你要说的话:"""
def get_profile_extraction_prompt(user_message: str, missing_fields: List[str]) -> str:
"""从用户回答中提取基础资料信息"""
def get_profile_extraction_prompt(
user_message: str,
missing_fields: List[str],
recent_dialogue: Optional[str] = None,
) -> str:
"""从用户回答中提取基础资料信息(可包含最近几轮对话,避免漏提)"""
missing_names = {f: PROFILE_FIELD_NAMES[f] for f in missing_fields if f in PROFILE_FIELD_NAMES}
return f"""请从用户的回答中提取基础资料信息。
dialogue_section = ""
if recent_dialogue and recent_dialogue.strip():
dialogue_section = f"""
最近几轮对话(可从用户任一轮回答中提取):
{recent_dialogue.strip()}
用户的回答:
"""
return f"""请从以下内容中提取用户已提到的基础资料信息。{dialogue_section}用户本轮回答:
"{user_message}"
需要提取的字段(只提取确实提到的):
需要提取的字段(只提取确实在对话中出现过的):
{missing_names}
请返回 JSON 格式,只包含确实提到的字段:
@@ -69,8 +78,8 @@ def get_profile_extraction_prompt(user_message: str, missing_fields: List[str])
}}
规则:
1. birth_year 必须是整数(四位数年份),如"65年出生"转为 1965
2. 如果用户"在老家长大"而之前提到了出生地grew_up_place 可以和 birth_place 相同
1. birth_year 整数(四位数),如"65年出生"转为 1965
2. 如果用户在任一轮说过出生地/成长地/职业等,都要提取
3. 只提取明确提到的信息,不要猜测
4. 如果没有提取到任何信息,返回空对象 {{}}
@@ -107,17 +116,19 @@ def get_profile_followup_prompt(
return f"""你是「岁月知己」,正在和用户聊天收集基本信息。
已知信息
## 已知信息(严禁再次询问以下任何一项)
{filled_str}
还需要了解{missing_str}
## 还需要了解
{missing_str}
用户刚才说:"{user_message}"
请先对用户说的内容做出自然回应,然后继续询问还未了解的信息(每次问 1-2 个)。
请先对用户说的内容做出自然回应,然后**只**询问「还需要了解」中的信息(每次问 1-2 个)。
语气要像朋友聊天一样自然亲切。
严格禁止:
- **严禁再次询问「已知信息」中已列出的内容**(例如已知出生年份就绝不要再问哪年出生)
- 禁止输出括号注释、思考过程
- 禁止说"我注意到""我需要了解"

View File

@@ -22,7 +22,8 @@ from database.models import Conversation, Segment
from database.models import User as UserModel
from services.auth_service import verify_token
from services.memoir_state_service import get_or_create_state
from services import asr_service
from services import asr_service, redis_service
from agents.prompts.profile_prompts import format_user_profile_context
logger = logging.getLogger(__name__)
LEGACY_VOICE_SESSION_ID = "legacy"
@@ -498,6 +499,35 @@ async def websocket_endpoint(
await _asyncio_greet.sleep(0.5)
except Exception as e:
logger.error(f"发送资料收集开场白失败: {e}", exc_info=True)
else:
# 资料已完整若为空对话用户通过「打个招呼」进入AI 先开口提问
history = await redis_service.get_conversation_history(conversation_id)
if not history:
try:
state = await get_or_create_state(user_id, db)
user_profile_context = format_user_profile_context(
birth_year=user.birth_year,
birth_place=user.birth_place,
grew_up_place=user.grew_up_place,
occupation=user.occupation,
)
opening_messages = await manager.conversation_agent.generate_opening_message(
conversation_id=conversation_id,
memoir_state=state,
user_profile_context=user_profile_context,
)
import asyncio as _asyncio_open
for i, text in enumerate(opening_messages):
await manager.send_message(conversation_id, {
"type": MessageType.AGENT_RESPONSE,
"conversation_id": conversation_id,
"data": {"text": text, "index": i, "total": len(opening_messages)},
"timestamp": datetime.now(timezone.utc).isoformat()
})
if i < len(opening_messages) - 1:
await _asyncio_open.sleep(0.5)
except Exception as e:
logger.error(f"发送空对话开场白失败: {e}", exc_info=True)
# 主循环:处理消息
while True:
@@ -889,7 +919,9 @@ async def process_user_message(
missing = _get_missing_profile_fields(user)
if missing:
try:
extracted = await agent.extract_profile_from_message(user_message, missing)
extracted = await agent.extract_profile_from_message(
user_message, missing, conversation_id=conversation_id
)
if extracted:
await _apply_extracted_profile(user, extracted, db)

View File

@@ -11,9 +11,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -25,6 +30,7 @@ import com.huaga.life_echo.ui.theme.AppTypography
import com.huaga.life_echo.ui.theme.LightPurple
import com.huaga.life_echo.utils.TimeUtils
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* 消息列表组件
@@ -52,7 +58,9 @@ fun MessageList(
audioDurations: Map<String, Int> = emptyMap() // messageId -> 时长(秒)
) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
var lastHeightPx by remember { mutableIntStateOf(0) }
// 计算实际的列表项数量(考虑分割消息和附加项)
val estimatedItemCount = remember(messages, isStreaming, streamingText, isTyping) {
var count = 0
@@ -110,7 +118,25 @@ fun MessageList(
LazyColumn(
state = listState,
modifier = modifier.fillMaxSize(),
modifier = modifier
.fillMaxSize()
.onSizeChanged { size ->
val h = size.height
// 键盘弹出导致列表高度变矮时,滚到最底部,让最后一条气泡紧贴输入框上方
if (lastHeightPx > 0 && h < lastHeightPx) {
scope.launch {
delay(80)
val count = listState.layoutInfo.totalItemsCount
if (count > 0) {
val viewportHeight = listState.layoutInfo.viewportSize.height
// 一次平滑滚到底:用 scrollOffset 让最后一项贴底,避免两段滚动和中间停顿
val scrollOffset = (viewportHeight - 120).coerceAtLeast(0)
listState.animateScrollToItem(count - 1, scrollOffset)
}
}
}
lastHeightPx = h
},
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {

View File

@@ -14,12 +14,15 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.clickable
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -37,8 +40,10 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -54,6 +59,7 @@ import com.huaga.life_echo.ui.theme.AppWhite
import com.huaga.life_echo.ui.theme.Cream
import com.huaga.life_echo.ui.theme.DeepPurple
import com.huaga.life_echo.ui.theme.DividerColor
import com.huaga.life_echo.ui.theme.Lavender
import com.huaga.life_echo.ui.theme.MediumPurple
import com.huaga.life_echo.ui.theme.SlatePurple
import com.huaga.life_echo.ui.viewmodel.ConversationListViewModel
@@ -77,33 +83,11 @@ fun ConversationListScreen(
var isSelectionMode by remember { mutableStateOf(false) }
var selectedIds by remember { mutableStateOf(mutableSetOf<String>()) }
// 是否正在自动创建对话
var isAutoCreating by remember { mutableStateOf(false) }
// 是否正在创建对话(点击「打个招呼」)
var isCreating by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
// 加载完成后,当对话列表为空时,自动创建一个对话并进入
LaunchedEffect(conversations, isLoading, isAutoCreating, hasLoadedInitialConversations, error) {
if (
hasLoadedInitialConversations &&
!isLoading &&
error == null &&
conversations.isEmpty() &&
!isAutoCreating
) {
isAutoCreating = true
val result = viewModel.createConversation()
result.fold(
onSuccess = { conversationId ->
onConversationClick(conversationId)
},
onFailure = { exception ->
isAutoCreating = false
}
)
}
}
// 处理长按进入多选模式
val handleLongClick: (String) -> Unit = { conversationId ->
if (!isSelectionMode) {
@@ -192,22 +176,43 @@ fun ConversationListScreen(
modifier = Modifier.fillMaxSize()
)
}
conversations.isEmpty() -> {
if (isAutoCreating) {
LoadingIndicator()
} else {
EmptyStateView(
title = "正在初始化",
message = "正在为您准备回忆录对话...",
modifier = Modifier.fillMaxSize()
)
}
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = AppDimensions.screenPadding)
) {
// 置顶入口:打个招呼(点击即新建空会话)
item(key = "say_hi") {
SayHiEntry(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = !isCreating) {
if (isCreating) return@clickable
scope.launch {
isCreating = true
viewModel.createConversation()
.fold(
onSuccess = { conversationId ->
onConversationClick(conversationId)
},
onFailure = { }
)
isCreating = false
}
}
.padding(
horizontal = AppDimensions.screenPadding,
vertical = AppDimensions.itemSpacing
),
isLoading = isCreating
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = AppDimensions.screenPadding),
thickness = AppDimensions.dividerThickness,
color = DividerColor
)
}
// 区块标题(与章节正文字号一致,大字模式更易读)
item {
Text(
@@ -224,7 +229,7 @@ fun ConversationListScreen(
// 对话列表
items(conversations, key = { it.id }) { conversation ->
// 兼容新旧数据:将"岁月知己"和旧名称"回忆录助手"都识别为默认助手
// 兼容新旧数据:将"岁月知己"和旧名称"回忆录助手"都识别为默认助手,展示为「岁月知己」
val isAssistant = conversation.title == null || conversation.title == "岁月知己" || conversation.title == "回忆录助手"
val displayTitle = if (isAssistant) "岁月知己" else conversation.title!!
val dto = ConversationListItemDto(
@@ -284,6 +289,68 @@ fun ConversationListScreen(
}
}
/**
* 置顶入口:「打个招呼」— 点击即新建空会话
*/
@Composable
private fun SayHiEntry(
modifier: Modifier = Modifier,
isLoading: Boolean = false
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(AppDimensions.avatarSizeSmall)
.clip(RoundedCornerShape(AppDimensions.iconContainerRadius))
.background(Lavender),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = AppIcons.Conversation,
contentDescription = "打个招呼",
tint = DeepPurple,
modifier = Modifier.size(24.dp)
)
}
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "打个招呼",
fontSize = AppTypography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = DeepPurple,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "开始新对话",
fontSize = AppTypography.bodyMedium,
color = SlatePurple,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MediumPurple,
strokeWidth = 2.dp
)
} else {
Icon(
imageVector = AppIcons.ChevronRight,
contentDescription = null,
tint = SlatePurple,
modifier = Modifier.size(AppDimensions.iconSizeSmall)
)
}
}
}
/**
* 多选模式头部
*/

View File

@@ -152,7 +152,12 @@ fun CreateMemoryScreen(
contentWindowInsets = WindowInsets(0, 0, 0, 0),
modifier = Modifier.fillMaxSize()
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.imePadding()
) {
Column(
modifier = Modifier.fillMaxSize()
) {
@@ -200,7 +205,6 @@ fun CreateMemoryScreen(
// 使用新的ChatInputField组件支持语音输入
ChatInputField(
modifier = Modifier.imePadding(),
value = inputText,
onValueChange = {
conversationDrafts = ConversationDrafts.updateDraft(

View File

@@ -21,7 +21,8 @@
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
"predictiveBackGestureEnabled": false,
"softwareKeyboardLayoutMode": "resize"
},
"web": {
"favicon": "./assets/favicon.png"