feat: agent proactively re-engages users on returning sessions
Two complementary changes to reduce conversation cold-start friction: A. Returning-user re-greeting (backend) - When WS reconnects to a non-empty conversation and last_message_at is older than chat_re_greeting_idle_hours (default 6h), the agent emits a warm continuation message that references prior history instead of staying silent. - Self-debouncing: the AI message updates last_message_at, so reconnects within the window will not re-trigger. - Skipped while profile collection is still pending. D. Topic suggestion chips (backend + Expo) - New WS message type topic_suggestions carries 3-4 quick-start chips derived from the current memoir stage's empty slots (deterministic, no extra LLM cost). Sent alongside opening / re-greeting / resume. - Expo chat screen renders a horizontally-scrollable chip row above the input bar; tapping a chip sends the chip's text as a user message and clears the row. Sending any text/voice also clears the chips.
This commit is contained in:
@@ -23,6 +23,7 @@ from app.agents.chat.prompt_context import ChatPromptContext
|
||||
from app.agents.chat.prompts_conversation import (
|
||||
SLOT_NAME_MAP,
|
||||
get_opening_prompt,
|
||||
get_re_greeting_prompt,
|
||||
)
|
||||
from app.agents.chat.reply_limits import (
|
||||
nonempty_segments_or_fallback,
|
||||
@@ -503,3 +504,118 @@ class InterviewAgent:
|
||||
except Exception as e:
|
||||
logger.error("生成开场白失败: {}", e, exc_info=True)
|
||||
return ["你好呀~ 又见面了。今天想从人生里哪一小段回忆开始聊聊?"]
|
||||
|
||||
async def generate_re_greeting_message(
|
||||
self,
|
||||
conversation_id: str,
|
||||
memoir_state: MemoirStateSchema,
|
||||
idle_hours: float,
|
||||
user_profile_context: str = "",
|
||||
background_voice: str = "default",
|
||||
occupation: str = "",
|
||||
profile_birth_year: Optional[int] = None,
|
||||
profile_era_place: str = "",
|
||||
) -> List[str]:
|
||||
"""老对话回访问候:用户带着已有历史回到对话时,AI 主动做承接式开场。
|
||||
|
||||
与 generate_opening_message 的差异:prompt 明确告知有历史 + 距上次的时间感受,
|
||||
要求轻轻引用历史里的具体细节,不能用首次见面式硬开场。
|
||||
"""
|
||||
if not self.llm:
|
||||
return ["上次聊到的事我还记着,今天想继续往下讲讲吗?"]
|
||||
try:
|
||||
narrative_state = narrative_coverage_state(memoir_state)
|
||||
control_state = interview_control_state(memoir_state)
|
||||
empty_slots = control_state.prompt_empty_slots_for_stage(
|
||||
narrative_state, memoir_state.current_stage
|
||||
)
|
||||
empty_slots_readable = [SLOT_NAME_MAP.get(s, s) for s in empty_slots]
|
||||
persona = normalize_interview_persona(settings.chat_interview_persona)
|
||||
prompt = get_re_greeting_prompt(
|
||||
current_stage=memoir_state.current_stage,
|
||||
empty_slots_readable=empty_slots_readable,
|
||||
user_profile_context=user_profile_context,
|
||||
persona=persona,
|
||||
background_voice=background_voice,
|
||||
occupation=occupation,
|
||||
profile_birth_year=profile_birth_year,
|
||||
profile_era_place=profile_era_place,
|
||||
idle_hours=idle_hours,
|
||||
)
|
||||
hw = await get_history_with_window(
|
||||
conversation_id,
|
||||
max_pairs=settings.chat_history_max_pairs,
|
||||
max_chars=settings.chat_history_max_chars,
|
||||
)
|
||||
messages: List[Any] = [SystemMessage(content=prompt)]
|
||||
messages.extend(hw.window)
|
||||
messages.append(
|
||||
HumanMessage(
|
||||
content=(
|
||||
"(用户回到这个已有历史的对话,还没说话。"
|
||||
"请基于上文做温和的承接式回访问候。)"
|
||||
)
|
||||
)
|
||||
)
|
||||
log_agent_payload(
|
||||
logger,
|
||||
"InterviewAgent.re_greeting.prompt",
|
||||
format_history_string(
|
||||
messages,
|
||||
omit_system_body=settings.agent_log_omit_system_message_body,
|
||||
),
|
||||
)
|
||||
re_greet_llm = self.llm.bind(
|
||||
max_tokens=settings.chat_opening_max_tokens,
|
||||
temperature=float(settings.chat_interview_temperature),
|
||||
)
|
||||
llm_t0 = time.perf_counter()
|
||||
with agent_span(
|
||||
logger,
|
||||
"InterviewAgent.re_greeting.llm",
|
||||
conversation_id=conversation_id,
|
||||
):
|
||||
logger.info(
|
||||
"event=chat_prompt_built agent=InterviewAgent.generate_re_greeting_message "
|
||||
"prompt_chars={} history_pairs_total={} history_pairs_windowed={} idle_hours={:.2f}",
|
||||
_message_contents_char_count(messages),
|
||||
hw.turn_total,
|
||||
len(hw.window) // 2,
|
||||
idle_hours,
|
||||
)
|
||||
response = await re_greet_llm.ainvoke(messages)
|
||||
logger.info(
|
||||
"event=chat_llm_done agent=InterviewAgent.generate_re_greeting_message "
|
||||
"response_latency_ms={:.2f}",
|
||||
(time.perf_counter() - llm_t0) * 1000,
|
||||
)
|
||||
response_text = (
|
||||
response.content if hasattr(response, "content") else str(response)
|
||||
)
|
||||
log_agent_payload(
|
||||
logger, "InterviewAgent.re_greeting.raw_response", response_text
|
||||
)
|
||||
raw_list = segments_from_llm_response(response_text, max_segments=2)
|
||||
if not raw_list:
|
||||
raw_list = [response_text.strip()]
|
||||
max_chars = int(settings.chat_interview_max_chars_per_segment)
|
||||
out = truncate_chat_segments(
|
||||
raw_list,
|
||||
max_segments=2,
|
||||
max_chars_per_segment=max_chars,
|
||||
)
|
||||
log_agent_summary(
|
||||
logger,
|
||||
"InterviewAgent.re_greeting segments={} conversation_id={} idle_hours={:.2f}",
|
||||
len(out),
|
||||
conversation_id,
|
||||
idle_hours,
|
||||
)
|
||||
segments = out if out else [response_text.strip()[:max_chars]]
|
||||
return nonempty_segments_or_fallback(
|
||||
segments,
|
||||
fallback="上次聊到的事我还记着,今天想继续往下讲讲吗?",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("生成回访问候失败: {}", e, exc_info=True)
|
||||
return ["上次聊到的事我还记着,今天想继续往下讲讲吗?"]
|
||||
|
||||
@@ -477,3 +477,26 @@ class ChatOrchestrator:
|
||||
profile_birth_year=profile_birth_year,
|
||||
profile_era_place=profile_era_place,
|
||||
)
|
||||
|
||||
async def generate_re_greeting_message(
|
||||
self,
|
||||
conversation_id: str,
|
||||
memoir_state: MemoirStateSchema,
|
||||
idle_hours: float,
|
||||
user_profile_context: str = "",
|
||||
background_voice: str = "default",
|
||||
occupation: str = "",
|
||||
profile_birth_year: Optional[int] = None,
|
||||
profile_era_place: str = "",
|
||||
) -> List[str]:
|
||||
"""委托 InterviewAgent 生成老对话回访问候(持久化由调用方负责)。"""
|
||||
return await self.interview_agent.generate_re_greeting_message(
|
||||
conversation_id=conversation_id,
|
||||
memoir_state=memoir_state,
|
||||
idle_hours=idle_hours,
|
||||
user_profile_context=user_profile_context,
|
||||
background_voice=background_voice,
|
||||
occupation=occupation,
|
||||
profile_birth_year=profile_birth_year,
|
||||
profile_era_place=profile_era_place,
|
||||
)
|
||||
|
||||
@@ -305,8 +305,176 @@ def get_guided_conversation_prompt(
|
||||
# 旧的超大 system prompt 已拆入 BehaviorPolicy / Context / StyleProfile 三层,此处不再保留快照。
|
||||
|
||||
|
||||
def get_re_greeting_prompt(
|
||||
current_stage: str,
|
||||
empty_slots_readable: List[str],
|
||||
user_profile_context: str = "",
|
||||
persona: str = "default",
|
||||
background_voice: str = "default",
|
||||
occupation: str = "",
|
||||
profile_birth_year: Optional[int] = None,
|
||||
profile_era_place: str = "",
|
||||
idle_hours: float = 6.0,
|
||||
) -> str:
|
||||
"""老对话回访问候提示词:用户带着已有历史回到对话,AI 先开口做承接式问候。"""
|
||||
stage_name = STAGE_DISPLAY_ZH.get(current_stage, current_stage)
|
||||
bv = normalize_background_voice(background_voice)
|
||||
|
||||
if idle_hours >= 168:
|
||||
idle_phrase = "好一阵子没聊了"
|
||||
elif idle_hours >= 48:
|
||||
idle_phrase = "好几天没聊了"
|
||||
elif idle_hours >= 20:
|
||||
idle_phrase = "隔了一天"
|
||||
else:
|
||||
idle_phrase = "今天又见面"
|
||||
|
||||
if empty_slots_readable:
|
||||
topics_str = "、".join(empty_slots_readable[:4])
|
||||
topic_hint = (
|
||||
f"## 当前阶段({stage_name})还可以聊\n"
|
||||
f"如果上次聊过的事不便直接接续,可从这些方向里挑一个落点:{topics_str}。"
|
||||
)
|
||||
else:
|
||||
topic_hint = (
|
||||
f"## 当前阶段({stage_name})\n"
|
||||
"这一阶段主要话题已有覆盖;优先回到上次聊过的人/事/地方,做温和的承接。"
|
||||
)
|
||||
|
||||
if bv == "cadre":
|
||||
style_note = "## 语气\n稳重、敬语适度;问候不油滑、不堆排比。"
|
||||
elif bv == "military":
|
||||
style_note = "## 语气\n简洁、得体;不过度起势、不堆军事辞藻。"
|
||||
else:
|
||||
style_note = "## 语气\n像许久未见的老朋友,温暖而克制;不要排比、不要长段文学描写。"
|
||||
|
||||
profile_lines: List[str] = []
|
||||
if user_profile_context.strip():
|
||||
profile_lines.append(user_profile_context.strip())
|
||||
occ = get_occupation_chat_hint(occupation, background_voice)
|
||||
if occ:
|
||||
profile_lines.append(occ)
|
||||
profile_section = ""
|
||||
if profile_lines:
|
||||
profile_section = "## 用户信息\n" + "\n".join(profile_lines) + "\n"
|
||||
|
||||
persona_key = normalize_interview_persona(persona)
|
||||
persona_tone = get_interview_persona_tone_hint(persona_key)
|
||||
voice_tone = get_background_voice_tone_hint(background_voice)
|
||||
tone_bits = [t for t in (persona_tone, voice_tone) if t]
|
||||
tone_paragraph = ""
|
||||
if tone_bits:
|
||||
tone_paragraph = " " + " ".join(tone_bits) + "\n\n"
|
||||
|
||||
head = (
|
||||
"你是「岁月知己」——主持式知己。用户带着**已有的对话历史**回到这里,**还没说话**,请你先开口。"
|
||||
f"语境:距上次消息已经{idle_phrase}。"
|
||||
"**职责**:用一句温暖的承接打招呼,让对方感到「我记得你上次说过的事」,再轻轻递上一个**回忆向**的钩子,把话头交还给他。\n\n"
|
||||
"## 要求\n"
|
||||
"1. **必须**轻轻引用历史里的具体人/事/地方/物件做承接(一两个细节即可,不要罗列),不要空喊「上次聊得很好」。\n"
|
||||
"2. **不要**用与刚开新对话相同的「您好/你好呀」式硬开场;像「上次你说到 X,今天想接着讲讲吗?」更合适。\n"
|
||||
"3. 钩子要**具体、好回答、有画面感**,落在人生回忆里;不要问「最近怎么样」「今天忙吗」这种纯社交寒暄。\n"
|
||||
"4. 若历史里没有可用细节,可从「当前阶段还可以聊」里挑一个轻巧落点;仍要避免泛泛盘问。\n"
|
||||
"5. 简短:两三句内,不要排比、不要长段。\n"
|
||||
)
|
||||
|
||||
return f"""{head}{tone_paragraph}{profile_section}{topic_hint}
|
||||
{style_note}
|
||||
## 格式
|
||||
- 可用 [SPLIT] 分成最多 2 条;或一条里「承接 + 钩子」。
|
||||
- {chat_output_rules()} 不要替用户编回答。
|
||||
|
||||
直接输出(仅自然口语,无 Markdown):"""
|
||||
|
||||
|
||||
_STAGE_TOPIC_CHIP_BANK: Dict[str, List[tuple[str, str]]] = {
|
||||
"childhood": [
|
||||
("place", "童年长大的地方"),
|
||||
("people", "童年里重要的人"),
|
||||
("daily_life", "童年的一天"),
|
||||
("turning_event", "童年最难忘的一件事"),
|
||||
("emotion", "童年最深的感受"),
|
||||
],
|
||||
"education": [
|
||||
("school", "学生时代的学校"),
|
||||
("city", "求学的城市"),
|
||||
("motivation", "读书时的动力"),
|
||||
("challenge", "求学路上的难关"),
|
||||
("change", "求学带来的变化"),
|
||||
],
|
||||
"career": [
|
||||
("job", "做过的工作"),
|
||||
("environment", "工作的环境"),
|
||||
("decision", "职业里的关键决定"),
|
||||
("pressure", "工作中的压力"),
|
||||
("growth", "职业上的成长"),
|
||||
],
|
||||
"family": [
|
||||
("relationship", "家人之间的关系"),
|
||||
("conflict", "家里的矛盾与化解"),
|
||||
("support", "家人之间的相互支持"),
|
||||
("responsibility", "肩上的家庭责任"),
|
||||
],
|
||||
"later_life": [
|
||||
("value", "现在最看重的事"),
|
||||
("regret", "心里的遗憾"),
|
||||
("pride", "最骄傲的事"),
|
||||
("lesson", "想留下的人生经验"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_topic_chips(
|
||||
current_stage: str,
|
||||
empty_slots: List[str],
|
||||
*,
|
||||
max_chips: int = 4,
|
||||
) -> List[Dict[str, str]]:
|
||||
"""根据当前阶段与空 slot 列表生成 quick-start 话题 chips。
|
||||
|
||||
返回结构:[{"id": slot_key, "label": 短标签, "text": 用户点击后发出的句子}]
|
||||
"""
|
||||
stage_bank = _STAGE_TOPIC_CHIP_BANK.get(current_stage) or []
|
||||
seen: set[str] = set()
|
||||
chips: List[Dict[str, str]] = []
|
||||
|
||||
# 优先从「当前阶段空 slot」挑选(与开场提问方向一致)
|
||||
empty_set = {s for s in empty_slots if s}
|
||||
for slot_key, label in stage_bank:
|
||||
if slot_key in empty_set and slot_key not in seen:
|
||||
chips.append(
|
||||
{
|
||||
"id": slot_key,
|
||||
"label": label,
|
||||
"text": f"我想聊聊{label}",
|
||||
}
|
||||
)
|
||||
seen.add(slot_key)
|
||||
if len(chips) >= max_chips:
|
||||
return chips
|
||||
|
||||
# 不足则用阶段默认话题补齐
|
||||
for slot_key, label in stage_bank:
|
||||
if slot_key in seen:
|
||||
continue
|
||||
chips.append(
|
||||
{
|
||||
"id": slot_key,
|
||||
"label": label,
|
||||
"text": f"我想聊聊{label}",
|
||||
}
|
||||
)
|
||||
seen.add(slot_key)
|
||||
if len(chips) >= max_chips:
|
||||
return chips
|
||||
|
||||
return chips
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SLOT_NAME_MAP",
|
||||
"build_topic_chips",
|
||||
"get_guided_conversation_prompt",
|
||||
"get_opening_prompt",
|
||||
"get_re_greeting_prompt",
|
||||
]
|
||||
|
||||
@@ -99,6 +99,12 @@ class Settings(BaseSettings):
|
||||
chat_reply_planner_llm_enabled: bool = False
|
||||
chat_reply_planner_max_tokens: int = Field(default=256, ge=64, le=1024)
|
||||
chat_reply_planner_temperature: float = Field(default=0.2, ge=0.0, le=1.0)
|
||||
# 老对话回访问候:连接时若距上次消息超过该小时数,由 AI 主动发一条承接式开场(自防抖:发完即更新 last_message_at)
|
||||
chat_re_greeting_enabled: bool = True
|
||||
chat_re_greeting_idle_hours: float = Field(default=6.0, ge=0.25, le=240.0)
|
||||
# 话题建议 chips:连接首帧附带 3-4 个 quick-start 话题(来自当前阶段的空 slots)
|
||||
chat_topic_chips_enabled: bool = True
|
||||
chat_topic_chips_max: int = Field(default=4, ge=1, le=8)
|
||||
|
||||
# ── Memoir 叙事忠实度检查(FidelityCheckAgent)────────────────
|
||||
memoir_fidelity_check_enabled: bool = True
|
||||
|
||||
@@ -21,4 +21,5 @@ class MessageType(str, Enum):
|
||||
PONG = "pong"
|
||||
END_CONVERSATION = "end_conversation"
|
||||
MEMOIR_UPDATE = "memoir_update"
|
||||
TOPIC_SUGGESTIONS = "topic_suggestions"
|
||||
ERROR = "error"
|
||||
|
||||
@@ -11,8 +11,14 @@ from fastapi import WebSocket, WebSocketDisconnect, status
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
from app.agents.chat.background_voice import infer_background_voice
|
||||
from app.agents.chat.prompts_conversation import build_topic_chips
|
||||
from app.agents.chat.prompts_profile import format_user_profile_context
|
||||
from app.agents.stage_constants import STAGE_TO_ORDER
|
||||
from app.agents.state_schema import (
|
||||
interview_control_state,
|
||||
narrative_coverage_state,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.core.db import AsyncSessionLocal
|
||||
from app.core.dependencies import get_asr_provider
|
||||
from app.core.logging import get_logger
|
||||
@@ -43,6 +49,18 @@ from app.features.user.models import User
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _idle_hours_since(ts) -> float | None:
|
||||
"""计算距 ts 的小时数;ts 为 None 或非 datetime 时返回 None。"""
|
||||
if ts is None:
|
||||
return None
|
||||
if not isinstance(ts, datetime):
|
||||
return None
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
delta = datetime.now(timezone.utc) - ts
|
||||
return max(0.0, delta.total_seconds() / 3600.0)
|
||||
|
||||
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
conversation_id: str,
|
||||
@@ -156,6 +174,87 @@ async def websocket_endpoint(
|
||||
history = await conversation_service.ensure_redis_history_from_db(
|
||||
conversation_id
|
||||
)
|
||||
|
||||
async def _stream_ai_only_messages(
|
||||
texts: list[str], log_label: str
|
||||
) -> None:
|
||||
"""统一:把一组 AI 消息落库并按 [SPLIT] 分段下发。"""
|
||||
if not texts:
|
||||
return
|
||||
ai_msg_id = await ConversationHistoryStore(db).record_ai_only_turn(
|
||||
conversation_id, texts
|
||||
)
|
||||
if not ai_msg_id:
|
||||
return
|
||||
total_n = len(texts)
|
||||
for i, text in enumerate(texts):
|
||||
await manager.send_message(
|
||||
conversation_id,
|
||||
{
|
||||
"type": MessageType.AGENT_RESPONSE,
|
||||
"conversation_id": conversation_id,
|
||||
"data": {
|
||||
"text": text,
|
||||
"index": i,
|
||||
"total": total_n,
|
||||
"assistant_message_id": ai_msg_id,
|
||||
},
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
if i < total_n - 1:
|
||||
await asyncio.sleep(0.5)
|
||||
logger.info(
|
||||
"event=ws_auto_ai_sent label={} conversation_id={} segments={}",
|
||||
log_label,
|
||||
conversation_id,
|
||||
total_n,
|
||||
)
|
||||
|
||||
async def _maybe_send_topic_chips(reason: str) -> None:
|
||||
"""根据当前阶段空 slot 生成 quick-start 话题 chips;失败静默。"""
|
||||
if not settings.chat_topic_chips_enabled:
|
||||
return
|
||||
# 资料未齐时不送 chips:profile 收集走另一条流程,chips 反而噪音
|
||||
if get_missing_profile_fields(user):
|
||||
return
|
||||
try:
|
||||
narrative_state = narrative_coverage_state(memoir_state)
|
||||
control_state = interview_control_state(memoir_state)
|
||||
empty_slots = control_state.prompt_empty_slots_for_stage(
|
||||
narrative_state, memoir_state.current_stage
|
||||
)
|
||||
chips = build_topic_chips(
|
||||
memoir_state.current_stage,
|
||||
empty_slots,
|
||||
max_chips=settings.chat_topic_chips_max,
|
||||
)
|
||||
if not chips:
|
||||
return
|
||||
await manager.send_message(
|
||||
conversation_id,
|
||||
{
|
||||
"type": MessageType.TOPIC_SUGGESTIONS,
|
||||
"conversation_id": conversation_id,
|
||||
"data": {
|
||||
"reason": reason,
|
||||
"stage": memoir_state.current_stage,
|
||||
"suggestions": chips,
|
||||
},
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
"event=ws_topic_chips_sent reason={} conversation_id={} "
|
||||
"stage={} count={}",
|
||||
reason,
|
||||
conversation_id,
|
||||
memoir_state.current_stage,
|
||||
len(chips),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("发送话题 chips 失败: {}", e)
|
||||
|
||||
if not history:
|
||||
missing_profile = get_missing_profile_fields(user)
|
||||
if missing_profile:
|
||||
@@ -165,35 +264,13 @@ async def websocket_endpoint(
|
||||
missing_fields=missing_profile,
|
||||
nickname=user.nickname or "",
|
||||
)
|
||||
ai_msg_id = await ConversationHistoryStore(
|
||||
db
|
||||
).record_ai_only_turn(conversation_id, greetings)
|
||||
if ai_msg_id:
|
||||
ng = len(greetings)
|
||||
for i, text in enumerate(greetings):
|
||||
await manager.send_message(
|
||||
conversation_id,
|
||||
{
|
||||
"type": MessageType.AGENT_RESPONSE,
|
||||
"conversation_id": conversation_id,
|
||||
"data": {
|
||||
"text": text,
|
||||
"index": i,
|
||||
"total": ng,
|
||||
"assistant_message_id": ai_msg_id,
|
||||
},
|
||||
"timestamp": datetime.now(
|
||||
timezone.utc
|
||||
).isoformat(),
|
||||
},
|
||||
await _stream_ai_only_messages(
|
||||
greetings, log_label="profile_greeting"
|
||||
)
|
||||
if i < ng - 1:
|
||||
await asyncio.sleep(0.5)
|
||||
except Exception as e:
|
||||
logger.exception("发送资料收集开场白失败: {}", e)
|
||||
else:
|
||||
try:
|
||||
state = memoir_state
|
||||
user_profile_context = format_user_profile_context(
|
||||
birth_year=user.birth_year,
|
||||
birth_place=user.birth_place,
|
||||
@@ -204,7 +281,7 @@ async def websocket_endpoint(
|
||||
opening_messages = (
|
||||
await chat_orchestrator.generate_opening_message(
|
||||
conversation_id=conversation_id,
|
||||
memoir_state=state,
|
||||
memoir_state=memoir_state,
|
||||
user_profile_context=user_profile_context,
|
||||
background_voice=infer_background_voice(
|
||||
user.occupation
|
||||
@@ -214,32 +291,60 @@ async def websocket_endpoint(
|
||||
profile_era_place=era_place,
|
||||
)
|
||||
)
|
||||
ai_msg_id = await ConversationHistoryStore(
|
||||
db
|
||||
).record_ai_only_turn(conversation_id, opening_messages)
|
||||
if ai_msg_id:
|
||||
no = len(opening_messages)
|
||||
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": no,
|
||||
"assistant_message_id": ai_msg_id,
|
||||
},
|
||||
"timestamp": datetime.now(
|
||||
timezone.utc
|
||||
).isoformat(),
|
||||
},
|
||||
await _stream_ai_only_messages(
|
||||
opening_messages, log_label="opening"
|
||||
)
|
||||
if i < no - 1:
|
||||
await asyncio.sleep(0.5)
|
||||
await _maybe_send_topic_chips(reason="opening")
|
||||
except Exception as e:
|
||||
logger.exception("发送空对话开场白失败: {}", e)
|
||||
else:
|
||||
# 历史非空:判断是否需要回访问候(距上次消息超过阈值)
|
||||
idle_hours = _idle_hours_since(conversation.last_message_at)
|
||||
threshold = float(settings.chat_re_greeting_idle_hours)
|
||||
if (
|
||||
settings.chat_re_greeting_enabled
|
||||
and not get_missing_profile_fields(user)
|
||||
and idle_hours is not None
|
||||
and idle_hours >= threshold
|
||||
):
|
||||
try:
|
||||
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,
|
||||
)
|
||||
era_place = (user.grew_up_place or user.birth_place or "") or ""
|
||||
re_greetings = (
|
||||
await chat_orchestrator.generate_re_greeting_message(
|
||||
conversation_id=conversation_id,
|
||||
memoir_state=memoir_state,
|
||||
idle_hours=idle_hours,
|
||||
user_profile_context=user_profile_context,
|
||||
background_voice=infer_background_voice(
|
||||
user.occupation
|
||||
),
|
||||
occupation=user.occupation or "",
|
||||
profile_birth_year=user.birth_year,
|
||||
profile_era_place=era_place,
|
||||
)
|
||||
)
|
||||
await _stream_ai_only_messages(
|
||||
re_greetings, log_label="re_greeting"
|
||||
)
|
||||
logger.info(
|
||||
"event=ws_re_greeting_emitted conversation_id={} "
|
||||
"idle_hours={:.2f} threshold={:.2f}",
|
||||
conversation_id,
|
||||
idle_hours,
|
||||
threshold,
|
||||
)
|
||||
await _maybe_send_topic_chips(reason="re_greeting")
|
||||
except Exception as e:
|
||||
logger.exception("发送回访问候失败: {}", e)
|
||||
else:
|
||||
# 不触发回访问候时,仍可下发 chips 以减少冷启动门槛
|
||||
await _maybe_send_topic_chips(reason="resume")
|
||||
|
||||
while True:
|
||||
try:
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type NativeSyntheticEvent,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text as RNText,
|
||||
TextInput,
|
||||
@@ -50,6 +51,7 @@ import { useThemeColors } from '@/hooks/use-theme-colors';
|
||||
import { useTypography } from '@/core/typography-context';
|
||||
import { useMessages, useRealtimeSession } from '@/features/conversation/hooks';
|
||||
import type { TtsSegmentPayload } from '@/features/conversation/realtime-session';
|
||||
import type { TopicSuggestion } from '@/core/ws/types';
|
||||
import { conversationKeys } from '@/features/conversation/query-keys';
|
||||
import {
|
||||
assistantSegmentMessageId,
|
||||
@@ -715,6 +717,58 @@ function VoiceRecordButton({
|
||||
);
|
||||
}
|
||||
|
||||
function TopicChipsRow({
|
||||
chips,
|
||||
onPressChip,
|
||||
onDismiss,
|
||||
dismissLabel,
|
||||
}: {
|
||||
chips: TopicSuggestion[];
|
||||
onPressChip: (text: string) => void;
|
||||
onDismiss: () => void;
|
||||
dismissLabel: string;
|
||||
}) {
|
||||
if (!chips || chips.length === 0) return null;
|
||||
return (
|
||||
<View style={styles.topicChipsRow}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.topicChipsScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{chips.map((chip) => (
|
||||
<Pressable
|
||||
key={chip.id}
|
||||
onPress={() => onPressChip(chip.text)}
|
||||
style={({ pressed }) => [
|
||||
styles.topicChip,
|
||||
pressed && styles.topicChipPressed,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={chip.label}
|
||||
>
|
||||
<Text style={styles.topicChipText} numberOfLines={1}>
|
||||
{chip.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
<Pressable
|
||||
onPress={onDismiss}
|
||||
style={({ pressed }) => [
|
||||
styles.topicChipDismiss,
|
||||
pressed && styles.topicChipPressed,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={dismissLabel}
|
||||
>
|
||||
<Text style={styles.topicChipDismissText}>{dismissLabel}</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatInputBar({
|
||||
value,
|
||||
onChangeText,
|
||||
@@ -1171,6 +1225,8 @@ export default function ConversationScreen() {
|
||||
connectionState,
|
||||
streamingMessage,
|
||||
awaitingAssistantReply,
|
||||
topicSuggestions,
|
||||
dismissTopicSuggestions,
|
||||
sendText,
|
||||
sendVoiceMessage,
|
||||
sendTtsCancel,
|
||||
@@ -1394,6 +1450,38 @@ export default function ConversationScreen() {
|
||||
scheduleRefocusComposer();
|
||||
};
|
||||
|
||||
const handleTopicChipPress = useCallback(
|
||||
(chipText: string) => {
|
||||
const text = chipText.trim();
|
||||
if (!text) return;
|
||||
if (connectionState === 'disconnected') {
|
||||
Alert.alert(t('chatUnavailableTitle'), t('chatUnavailableDisconnected'));
|
||||
return;
|
||||
}
|
||||
if (connectionState === 'connecting') {
|
||||
pendingTextSendRef.current = text;
|
||||
clearConnectingSendTimeout();
|
||||
connectingSendTimeoutRef.current = setTimeout(() => {
|
||||
connectingSendTimeoutRef.current = null;
|
||||
if (pendingTextSendRef.current !== text) return;
|
||||
pendingTextSendRef.current = null;
|
||||
Alert.alert(t('chatUnavailableTitle'), t('chatQueueSendTimeout'));
|
||||
}, CONNECTING_SEND_TIMEOUT_MS);
|
||||
dismissTopicSuggestions();
|
||||
return;
|
||||
}
|
||||
sendText(text);
|
||||
dismissTopicSuggestions();
|
||||
},
|
||||
[
|
||||
connectionState,
|
||||
sendText,
|
||||
dismissTopicSuggestions,
|
||||
clearConnectingSendTimeout,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
/** 仅完全断开时禁用发送/语音;连接中可点发送(排队) */
|
||||
const composerDisabled = connectionState === 'disconnected';
|
||||
|
||||
@@ -1533,6 +1621,12 @@ export default function ConversationScreen() {
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<TopicChipsRow
|
||||
chips={topicSuggestions}
|
||||
onPressChip={handleTopicChipPress}
|
||||
onDismiss={dismissTopicSuggestions}
|
||||
dismissLabel={t('topicSuggestionsDismiss')}
|
||||
/>
|
||||
<ChatInputBar
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
@@ -1826,6 +1920,40 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
topicChipsRow: {
|
||||
paddingTop: 10,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
topicChipsScrollContent: {
|
||||
paddingHorizontal: 14,
|
||||
gap: 8,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
topicChip: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 18,
|
||||
backgroundColor: 'rgba(141, 140, 144, 0.14)',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: 'rgba(141, 140, 144, 0.24)',
|
||||
},
|
||||
topicChipPressed: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
topicChipText: {
|
||||
fontSize: 14,
|
||||
color: CHAT_COLORS.onSurface,
|
||||
},
|
||||
topicChipDismiss: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 18,
|
||||
},
|
||||
topicChipDismissText: {
|
||||
fontSize: 13,
|
||||
color: 'rgba(60, 60, 67, 0.6)',
|
||||
},
|
||||
iconButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
|
||||
@@ -59,6 +59,28 @@ function mapServerMessage(raw: RawServerMessage): WsEvent | null {
|
||||
case 'memoir_update':
|
||||
return { kind: 'memoir_updated', conversationId: cid, data: d };
|
||||
|
||||
case 'topic_suggestions': {
|
||||
const rawSuggestions = Array.isArray(d.suggestions) ? d.suggestions : [];
|
||||
const suggestions = rawSuggestions
|
||||
.map((raw) => {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const s = raw as Record<string, unknown>;
|
||||
const id = typeof s.id === 'string' ? s.id : '';
|
||||
const label = typeof s.label === 'string' ? s.label : '';
|
||||
const text = typeof s.text === 'string' ? s.text : '';
|
||||
if (!id || !label || !text) return null;
|
||||
return { id, label, text };
|
||||
})
|
||||
.filter((x): x is { id: string; label: string; text: string } => !!x);
|
||||
return {
|
||||
kind: 'topic_suggestions',
|
||||
conversationId: cid,
|
||||
reason: typeof d.reason === 'string' ? d.reason : '',
|
||||
stage: typeof d.stage === 'string' ? d.stage : undefined,
|
||||
suggestions,
|
||||
};
|
||||
}
|
||||
|
||||
case 'error':
|
||||
return {
|
||||
kind: 'session_error',
|
||||
|
||||
@@ -7,6 +7,7 @@ export type ServerMessageType =
|
||||
| 'tts_audio'
|
||||
| 'end_conversation'
|
||||
| 'memoir_update'
|
||||
| 'topic_suggestions'
|
||||
| 'error';
|
||||
|
||||
export type ClientMessageType =
|
||||
@@ -81,6 +82,22 @@ export interface MemoirUpdatedEvent {
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TopicSuggestion {
|
||||
id: string;
|
||||
label: string;
|
||||
/** 用户点击后作为用户文本发送的内容 */
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface TopicSuggestionsEvent {
|
||||
kind: 'topic_suggestions';
|
||||
conversationId: string;
|
||||
/** 触发原因:opening | re_greeting | resume */
|
||||
reason: string;
|
||||
stage?: string;
|
||||
suggestions: TopicSuggestion[];
|
||||
}
|
||||
|
||||
export interface SessionErrorEvent {
|
||||
kind: 'session_error';
|
||||
conversationId: string;
|
||||
@@ -96,6 +113,7 @@ export type WsEvent =
|
||||
| TtsAudioReceivedEvent
|
||||
| ConversationEndedEvent
|
||||
| MemoirUpdatedEvent
|
||||
| TopicSuggestionsEvent
|
||||
| SessionErrorEvent;
|
||||
|
||||
// ─── Connection state ───
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { File, Paths } from 'expo-file-system';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { WsConnectionState } from '@/core/ws/types';
|
||||
import type { TopicSuggestion, WsConnectionState } from '@/core/ws/types';
|
||||
|
||||
import { conversationApi } from './api';
|
||||
import { conversationMessagesRepository } from './conversation-messages-repository';
|
||||
@@ -183,6 +183,9 @@ interface RealtimeSessionState {
|
||||
/** 已发出用户消息,尚未收到助手首段流式文本(用于「正在回复」气泡) */
|
||||
awaitingAssistantReply: boolean;
|
||||
error: string | null;
|
||||
/** 服务端下发的 quick-start 话题 chips;用户首次发文本/语音后清空 */
|
||||
topicSuggestions: TopicSuggestion[];
|
||||
dismissTopicSuggestions: () => void;
|
||||
sendText: (text: string) => void;
|
||||
sendVoiceMessage: (uri: string, durationMs: number) => Promise<boolean>;
|
||||
sendEndConversation: () => void;
|
||||
@@ -204,6 +207,9 @@ export function useRealtimeSession({
|
||||
useState<StreamingAgentMessage | null>(null);
|
||||
const [awaitingAssistantReply, setAwaitingAssistantReply] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [topicSuggestions, setTopicSuggestions] = useState<TopicSuggestion[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const handleStreamingText: StreamingTextCallback = useCallback(
|
||||
(text, isComplete) => {
|
||||
@@ -225,6 +231,17 @@ export function useRealtimeSession({
|
||||
setError(message);
|
||||
}, []);
|
||||
|
||||
const handleTopicSuggestions = useCallback(
|
||||
(payload: { suggestions: TopicSuggestion[] }) => {
|
||||
setTopicSuggestions(payload.suggestions);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const dismissTopicSuggestions = useCallback(() => {
|
||||
setTopicSuggestions([]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !conversationId) return;
|
||||
|
||||
@@ -233,6 +250,7 @@ export function useRealtimeSession({
|
||||
queryClient,
|
||||
onStreamingText: handleStreamingText,
|
||||
onTtsSegment,
|
||||
onTopicSuggestions: handleTopicSuggestions,
|
||||
onError: handleError,
|
||||
onStateChange: setConnectionState,
|
||||
});
|
||||
@@ -246,6 +264,7 @@ export function useRealtimeSession({
|
||||
setConnectionState('disconnected');
|
||||
setStreamingMessage(null);
|
||||
setAwaitingAssistantReply(false);
|
||||
setTopicSuggestions([]);
|
||||
};
|
||||
}, [
|
||||
conversationId,
|
||||
@@ -253,6 +272,7 @@ export function useRealtimeSession({
|
||||
queryClient,
|
||||
handleStreamingText,
|
||||
handleError,
|
||||
handleTopicSuggestions,
|
||||
onTtsSegment,
|
||||
]);
|
||||
|
||||
@@ -267,6 +287,7 @@ export function useRealtimeSession({
|
||||
}
|
||||
|
||||
setAwaitingAssistantReply(true);
|
||||
setTopicSuggestions([]);
|
||||
onTtsPlaybackResume?.();
|
||||
|
||||
const localId = `pending_${Date.now()}`;
|
||||
@@ -326,6 +347,7 @@ export function useRealtimeSession({
|
||||
}
|
||||
|
||||
setAwaitingAssistantReply(true);
|
||||
setTopicSuggestions([]);
|
||||
const localId = `pending_voice_${Date.now()}`;
|
||||
await voiceSegmentStore.recordSentSegment({
|
||||
voiceSessionId,
|
||||
@@ -385,6 +407,8 @@ export function useRealtimeSession({
|
||||
streamingMessage,
|
||||
awaitingAssistantReply,
|
||||
error,
|
||||
topicSuggestions,
|
||||
dismissTopicSuggestions,
|
||||
sendText,
|
||||
sendVoiceMessage,
|
||||
sendEndConversation,
|
||||
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
type WsEventListener,
|
||||
type WsStateListener,
|
||||
} from '@/core/ws/client';
|
||||
import type { WsConnectionState, WsEvent } from '@/core/ws/types';
|
||||
import type {
|
||||
TopicSuggestion,
|
||||
WsConnectionState,
|
||||
WsEvent,
|
||||
} from '@/core/ws/types';
|
||||
|
||||
import { handleWsEvent } from './event-handlers';
|
||||
import { assistantSegmentMessageId, lastSegmentPreview } from './message-split';
|
||||
@@ -14,6 +18,11 @@ import type { ConversationListItem, MessageItem } from './types';
|
||||
|
||||
export type StreamingTextCallback = (text: string, isComplete: boolean) => void;
|
||||
export type ErrorCallback = (message: string, code?: string) => void;
|
||||
export type TopicSuggestionsCallback = (payload: {
|
||||
reason: string;
|
||||
stage?: string;
|
||||
suggestions: TopicSuggestion[];
|
||||
}) => void;
|
||||
|
||||
/** WebSocket `tts_audio`:服务端可能只带 base64、只带 COS URL,或两者都有 */
|
||||
export type TtsSegmentPayload = {
|
||||
@@ -31,6 +40,8 @@ interface RealtimeSessionOptions {
|
||||
onStreamingText?: StreamingTextCallback;
|
||||
/** 收到 TTS 片段时入队播放(与「气泡上的手动朗读按钮」无关) */
|
||||
onTtsSegment?: (payload: TtsSegmentPayload) => void;
|
||||
/** 服务端下发 quick-start 话题 chips 时回调;用户点击后调 sendText */
|
||||
onTopicSuggestions?: TopicSuggestionsCallback;
|
||||
onError?: ErrorCallback;
|
||||
onStateChange?: WsStateListener;
|
||||
}
|
||||
@@ -52,6 +63,7 @@ export class RealtimeSession {
|
||||
private queryClient: QueryClient;
|
||||
private onStreamingText?: StreamingTextCallback;
|
||||
private onTtsSegment?: (payload: TtsSegmentPayload) => void;
|
||||
private onTopicSuggestions?: TopicSuggestionsCallback;
|
||||
private onError?: ErrorCallback;
|
||||
private unsubEvent: (() => void) | null = null;
|
||||
private unsubState: (() => void) | null = null;
|
||||
@@ -66,6 +78,7 @@ export class RealtimeSession {
|
||||
this.queryClient = options.queryClient;
|
||||
this.onStreamingText = options.onStreamingText;
|
||||
this.onTtsSegment = options.onTtsSegment;
|
||||
this.onTopicSuggestions = options.onTopicSuggestions;
|
||||
this.onError = options.onError;
|
||||
|
||||
this.unsubEvent = this.client.onEvent(this.handleEvent);
|
||||
@@ -154,6 +167,15 @@ export class RealtimeSession {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.kind === 'topic_suggestions') {
|
||||
this.onTopicSuggestions?.({
|
||||
reason: event.reason,
|
||||
stage: event.stage,
|
||||
suggestions: event.suggestions,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
handleWsEvent(this.queryClient, event);
|
||||
|
||||
if (event.kind === 'session_error') {
|
||||
|
||||
@@ -99,6 +99,7 @@ interface Resources {
|
||||
switchToVoice: 'Switch to voice input';
|
||||
tapToEndRecording: 'Tap to end';
|
||||
tapToStartRecording: 'Tap to start recording';
|
||||
topicSuggestionsDismiss: 'Hide';
|
||||
timeDaysAgo_one: '{{count}} day ago';
|
||||
timeDaysAgo_other: '{{count}} days ago';
|
||||
timeHoursAgo_one: '{{count}} hour ago';
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"switchToVoice": "Switch to voice input",
|
||||
"tapToEndRecording": "Tap to end",
|
||||
"tapToStartRecording": "Tap to start recording",
|
||||
"topicSuggestionsDismiss": "Hide",
|
||||
"viewAll": "View All",
|
||||
"voiceMessagePreview": "Voice message",
|
||||
"timeJustNow": "Just now",
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"switchToVoice": "切换到语音输入",
|
||||
"tapToEndRecording": "点击结束",
|
||||
"tapToStartRecording": "点击开始录音",
|
||||
"topicSuggestionsDismiss": "收起",
|
||||
"viewAll": "查看全部",
|
||||
"voiceMessagePreview": "语音消息",
|
||||
"timeJustNow": "刚刚",
|
||||
|
||||
Reference in New Issue
Block a user