feat & fix: 新增打个招呼选项 创建新会话;修复ai重复性提问的问题;修复输入键盘覆盖对话气泡的问题
This commit is contained in:
@@ -12,7 +12,7 @@ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
|||||||
|
|
||||||
from services.llm_service import llm_service
|
from services.llm_service import llm_service
|
||||||
from services.redis_service import redis_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 (
|
from .prompts.profile_prompts import (
|
||||||
get_profile_greeting_prompt,
|
get_profile_greeting_prompt,
|
||||||
get_profile_extraction_prompt,
|
get_profile_extraction_prompt,
|
||||||
@@ -20,6 +20,7 @@ from .prompts.profile_prompts import (
|
|||||||
format_user_profile_context,
|
format_user_profile_context,
|
||||||
get_missing_profile_fields,
|
get_missing_profile_fields,
|
||||||
)
|
)
|
||||||
|
from .prompts.conversation_prompts import SLOT_NAME_MAP
|
||||||
from .state_schema import MemoirStateSchema
|
from .state_schema import MemoirStateSchema
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -138,19 +139,83 @@ class ConversationAgent:
|
|||||||
logger.error(f"生成资料收集开场白失败: {e}")
|
logger.error(f"生成资料收集开场白失败: {e}")
|
||||||
return ["你好!在我们开始聊人生故事之前,能先简单介绍一下你自己吗?比如你是哪一年出生的?"]
|
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:
|
if not self.llm or not missing_fields:
|
||||||
return {}
|
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:
|
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)
|
response = await self.llm.ainvoke(prompt)
|
||||||
content = response.content.strip()
|
content = response.content.strip()
|
||||||
parsed = json.loads(content)
|
parsed = json.loads(content)
|
||||||
result = {}
|
result = {}
|
||||||
if "birth_year" in parsed and isinstance(parsed["birth_year"], int):
|
if "birth_year" in parsed and parsed["birth_year"] is not None:
|
||||||
result["birth_year"] = parsed["birth_year"]
|
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"]:
|
if "birth_place" in parsed and parsed["birth_place"]:
|
||||||
result["birth_place"] = str(parsed["birth_place"])
|
result["birth_place"] = str(parsed["birth_place"])
|
||||||
if "grew_up_place" in parsed and parsed["grew_up_place"]:
|
if "grew_up_place" in parsed and parsed["grew_up_place"]:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from .conversation_prompts import (
|
|||||||
get_system_prompt as get_conversation_prompt,
|
get_system_prompt as get_conversation_prompt,
|
||||||
get_questions_for_stage,
|
get_questions_for_stage,
|
||||||
get_guided_conversation_prompt,
|
get_guided_conversation_prompt,
|
||||||
|
get_opening_prompt,
|
||||||
INTERVIEW_QUESTIONS,
|
INTERVIEW_QUESTIONS,
|
||||||
)
|
)
|
||||||
from .memory_prompts import (
|
from .memory_prompts import (
|
||||||
@@ -34,6 +35,7 @@ __all__ = [
|
|||||||
"get_conversation_prompt",
|
"get_conversation_prompt",
|
||||||
"get_questions_for_stage",
|
"get_questions_for_stage",
|
||||||
"get_guided_conversation_prompt",
|
"get_guided_conversation_prompt",
|
||||||
|
"get_opening_prompt",
|
||||||
"INTERVIEW_QUESTIONS",
|
"INTERVIEW_QUESTIONS",
|
||||||
"get_memory_prompt",
|
"get_memory_prompt",
|
||||||
"get_chapter_classification_prompt",
|
"get_chapter_classification_prompt",
|
||||||
|
|||||||
@@ -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:
|
def _build_era_context(current_stage: str, user_profile_context: str) -> str:
|
||||||
"""
|
"""
|
||||||
根据用户的人生阶段和出生年份,生成对应时代的历史/政治/文化背景提示。
|
根据用户的人生阶段和出生年份,生成对应时代的历史/政治/文化背景提示。
|
||||||
@@ -402,6 +443,7 @@ def get_guided_conversation_prompt(
|
|||||||
- 禁止反复追问同一件事
|
- 禁止反复追问同一件事
|
||||||
- 禁止每次都以问题结尾
|
- 禁止每次都以问题结尾
|
||||||
- **禁止在用户聊别的话题时强行拉回之前的话题**
|
- **禁止在用户聊别的话题时强行拉回之前的话题**
|
||||||
|
- **禁止询问或再次确认「用户基本信息」中已列出的内容**(如出生年份、出生地、成长地、职业等,这些你已经知道,不要问第二遍)
|
||||||
|
|
||||||
## 好的回应示例
|
## 好的回应示例
|
||||||
- "哈哈,你这说的让我想起..."(轻松)
|
- "哈哈,你这说的让我想起..."(轻松)
|
||||||
|
|||||||
@@ -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}
|
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}"
|
"{user_message}"
|
||||||
|
|
||||||
需要提取的字段(只提取确实提到的):
|
需要提取的字段(只提取确实在对话中出现过的):
|
||||||
{missing_names}
|
{missing_names}
|
||||||
|
|
||||||
请返回 JSON 格式,只包含确实提到的字段:
|
请返回 JSON 格式,只包含确实提到的字段:
|
||||||
@@ -69,8 +78,8 @@ def get_profile_extraction_prompt(user_message: str, missing_fields: List[str])
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
1. birth_year 必须是整数(四位数年份),如"65年出生"应转为 1965
|
1. birth_year 填整数(四位数),如"65年出生"转为 1965
|
||||||
2. 如果用户说"在老家长大"而之前提到了出生地,grew_up_place 可以和 birth_place 相同
|
2. 如果用户在任一轮说过出生地/成长地/职业等,都要提取
|
||||||
3. 只提取明确提到的信息,不要猜测
|
3. 只提取明确提到的信息,不要猜测
|
||||||
4. 如果没有提取到任何信息,返回空对象 {{}}
|
4. 如果没有提取到任何信息,返回空对象 {{}}
|
||||||
|
|
||||||
@@ -107,17 +116,19 @@ def get_profile_followup_prompt(
|
|||||||
|
|
||||||
return f"""你是「岁月知己」,正在和用户聊天收集基本信息。
|
return f"""你是「岁月知己」,正在和用户聊天收集基本信息。
|
||||||
|
|
||||||
已知信息:
|
## 已知信息(严禁再次询问以下任何一项)
|
||||||
{filled_str}
|
{filled_str}
|
||||||
|
|
||||||
还需要了解:{missing_str}
|
## 还需要了解
|
||||||
|
{missing_str}
|
||||||
|
|
||||||
用户刚才说:"{user_message}"
|
用户刚才说:"{user_message}"
|
||||||
|
|
||||||
请先对用户说的内容做出自然回应,然后继续询问还未了解的信息(每次问 1-2 个)。
|
请先对用户说的内容做出自然回应,然后**只**询问「还需要了解」中的信息(每次问 1-2 个)。
|
||||||
语气要像朋友聊天一样自然亲切。
|
语气要像朋友聊天一样自然亲切。
|
||||||
|
|
||||||
严格禁止:
|
严格禁止:
|
||||||
|
- **严禁再次询问「已知信息」中已列出的内容**(例如已知出生年份就绝不要再问哪年出生)
|
||||||
- 禁止输出括号注释、思考过程
|
- 禁止输出括号注释、思考过程
|
||||||
- 禁止说"我注意到""我需要了解"
|
- 禁止说"我注意到""我需要了解"
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ from database.models import Conversation, Segment
|
|||||||
from database.models import User as UserModel
|
from database.models import User as UserModel
|
||||||
from services.auth_service import verify_token
|
from services.auth_service import verify_token
|
||||||
from services.memoir_state_service import get_or_create_state
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
LEGACY_VOICE_SESSION_ID = "legacy"
|
LEGACY_VOICE_SESSION_ID = "legacy"
|
||||||
@@ -498,6 +499,35 @@ async def websocket_endpoint(
|
|||||||
await _asyncio_greet.sleep(0.5)
|
await _asyncio_greet.sleep(0.5)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"发送资料收集开场白失败: {e}", exc_info=True)
|
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:
|
while True:
|
||||||
@@ -889,7 +919,9 @@ async def process_user_message(
|
|||||||
missing = _get_missing_profile_fields(user)
|
missing = _get_missing_profile_fields(user)
|
||||||
if missing:
|
if missing:
|
||||||
try:
|
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:
|
if extracted:
|
||||||
await _apply_extracted_profile(user, extracted, db)
|
await _apply_extracted_profile(user, extracted, db)
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,14 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
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.ui.theme.LightPurple
|
||||||
import com.huaga.life_echo.utils.TimeUtils
|
import com.huaga.life_echo.utils.TimeUtils
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 消息列表组件
|
* 消息列表组件
|
||||||
@@ -52,7 +58,9 @@ fun MessageList(
|
|||||||
audioDurations: Map<String, Int> = emptyMap() // messageId -> 时长(秒)
|
audioDurations: Map<String, Int> = emptyMap() // messageId -> 时长(秒)
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var lastHeightPx by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
// 计算实际的列表项数量(考虑分割消息和附加项)
|
// 计算实际的列表项数量(考虑分割消息和附加项)
|
||||||
val estimatedItemCount = remember(messages, isStreaming, streamingText, isTyping) {
|
val estimatedItemCount = remember(messages, isStreaming, streamingText, isTyping) {
|
||||||
var count = 0
|
var count = 0
|
||||||
@@ -110,7 +118,25 @@ fun MessageList(
|
|||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
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),
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBars
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -37,8 +40,10 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
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.Cream
|
||||||
import com.huaga.life_echo.ui.theme.DeepPurple
|
import com.huaga.life_echo.ui.theme.DeepPurple
|
||||||
import com.huaga.life_echo.ui.theme.DividerColor
|
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.MediumPurple
|
||||||
import com.huaga.life_echo.ui.theme.SlatePurple
|
import com.huaga.life_echo.ui.theme.SlatePurple
|
||||||
import com.huaga.life_echo.ui.viewmodel.ConversationListViewModel
|
import com.huaga.life_echo.ui.viewmodel.ConversationListViewModel
|
||||||
@@ -77,33 +83,11 @@ fun ConversationListScreen(
|
|||||||
var isSelectionMode by remember { mutableStateOf(false) }
|
var isSelectionMode by remember { mutableStateOf(false) }
|
||||||
var selectedIds by remember { mutableStateOf(mutableSetOf<String>()) }
|
var selectedIds by remember { mutableStateOf(mutableSetOf<String>()) }
|
||||||
|
|
||||||
// 是否正在自动创建对话
|
// 是否正在创建新对话(点击「打个招呼」)
|
||||||
var isAutoCreating by remember { mutableStateOf(false) }
|
var isCreating by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
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 ->
|
val handleLongClick: (String) -> Unit = { conversationId ->
|
||||||
if (!isSelectionMode) {
|
if (!isSelectionMode) {
|
||||||
@@ -192,22 +176,43 @@ fun ConversationListScreen(
|
|||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
conversations.isEmpty() -> {
|
|
||||||
if (isAutoCreating) {
|
|
||||||
LoadingIndicator()
|
|
||||||
} else {
|
|
||||||
EmptyStateView(
|
|
||||||
title = "正在初始化",
|
|
||||||
message = "正在为您准备回忆录对话...",
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
else -> {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = AppDimensions.screenPadding)
|
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 {
|
item {
|
||||||
Text(
|
Text(
|
||||||
@@ -224,7 +229,7 @@ fun ConversationListScreen(
|
|||||||
|
|
||||||
// 对话列表
|
// 对话列表
|
||||||
items(conversations, key = { it.id }) { conversation ->
|
items(conversations, key = { it.id }) { conversation ->
|
||||||
// 兼容新旧数据:将"岁月知己"和旧名称"回忆录助手"都识别为默认助手
|
// 兼容新旧数据:将"岁月知己"和旧名称"回忆录助手"都识别为默认助手,展示为「岁月知己」
|
||||||
val isAssistant = conversation.title == null || conversation.title == "岁月知己" || conversation.title == "回忆录助手"
|
val isAssistant = conversation.title == null || conversation.title == "岁月知己" || conversation.title == "回忆录助手"
|
||||||
val displayTitle = if (isAssistant) "岁月知己" else conversation.title!!
|
val displayTitle = if (isAssistant) "岁月知己" else conversation.title!!
|
||||||
val dto = ConversationListItemDto(
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 多选模式头部
|
* 多选模式头部
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -152,7 +152,12 @@ fun CreateMemoryScreen(
|
|||||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.imePadding()
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
@@ -200,7 +205,6 @@ fun CreateMemoryScreen(
|
|||||||
|
|
||||||
// 使用新的ChatInputField组件(支持语音输入)
|
// 使用新的ChatInputField组件(支持语音输入)
|
||||||
ChatInputField(
|
ChatInputField(
|
||||||
modifier = Modifier.imePadding(),
|
|
||||||
value = inputText,
|
value = inputText,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
conversationDrafts = ConversationDrafts.updateDraft(
|
conversationDrafts = ConversationDrafts.updateDraft(
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#ffffff"
|
||||||
},
|
},
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
"predictiveBackGestureEnabled": false
|
"predictiveBackGestureEnabled": false,
|
||||||
|
"softwareKeyboardLayoutMode": "resize"
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
|
|||||||
Reference in New Issue
Block a user