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 (
|
from app.agents.chat.prompts_conversation import (
|
||||||
SLOT_NAME_MAP,
|
SLOT_NAME_MAP,
|
||||||
get_opening_prompt,
|
get_opening_prompt,
|
||||||
|
get_re_greeting_prompt,
|
||||||
)
|
)
|
||||||
from app.agents.chat.reply_limits import (
|
from app.agents.chat.reply_limits import (
|
||||||
nonempty_segments_or_fallback,
|
nonempty_segments_or_fallback,
|
||||||
@@ -503,3 +504,118 @@ class InterviewAgent:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("生成开场白失败: {}", e, exc_info=True)
|
logger.error("生成开场白失败: {}", e, exc_info=True)
|
||||||
return ["你好呀~ 又见面了。今天想从人生里哪一小段回忆开始聊聊?"]
|
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_birth_year=profile_birth_year,
|
||||||
profile_era_place=profile_era_place,
|
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 三层,此处不再保留快照。
|
# 旧的超大 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__ = [
|
__all__ = [
|
||||||
"SLOT_NAME_MAP",
|
"SLOT_NAME_MAP",
|
||||||
|
"build_topic_chips",
|
||||||
"get_guided_conversation_prompt",
|
"get_guided_conversation_prompt",
|
||||||
"get_opening_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_llm_enabled: bool = False
|
||||||
chat_reply_planner_max_tokens: int = Field(default=256, ge=64, le=1024)
|
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)
|
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 叙事忠实度检查(FidelityCheckAgent)────────────────
|
||||||
memoir_fidelity_check_enabled: bool = True
|
memoir_fidelity_check_enabled: bool = True
|
||||||
|
|||||||
@@ -21,4 +21,5 @@ class MessageType(str, Enum):
|
|||||||
PONG = "pong"
|
PONG = "pong"
|
||||||
END_CONVERSATION = "end_conversation"
|
END_CONVERSATION = "end_conversation"
|
||||||
MEMOIR_UPDATE = "memoir_update"
|
MEMOIR_UPDATE = "memoir_update"
|
||||||
|
TOPIC_SUGGESTIONS = "topic_suggestions"
|
||||||
ERROR = "error"
|
ERROR = "error"
|
||||||
|
|||||||
@@ -11,8 +11,14 @@ from fastapi import WebSocket, WebSocketDisconnect, status
|
|||||||
from starlette.websockets import WebSocketState
|
from starlette.websockets import WebSocketState
|
||||||
|
|
||||||
from app.agents.chat.background_voice import infer_background_voice
|
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.chat.prompts_profile import format_user_profile_context
|
||||||
from app.agents.stage_constants import STAGE_TO_ORDER
|
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.db import AsyncSessionLocal
|
||||||
from app.core.dependencies import get_asr_provider
|
from app.core.dependencies import get_asr_provider
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
@@ -43,6 +49,18 @@ from app.features.user.models import User
|
|||||||
logger = get_logger(__name__)
|
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(
|
async def websocket_endpoint(
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
@@ -156,6 +174,87 @@ async def websocket_endpoint(
|
|||||||
history = await conversation_service.ensure_redis_history_from_db(
|
history = await conversation_service.ensure_redis_history_from_db(
|
||||||
conversation_id
|
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:
|
if not history:
|
||||||
missing_profile = get_missing_profile_fields(user)
|
missing_profile = get_missing_profile_fields(user)
|
||||||
if missing_profile:
|
if missing_profile:
|
||||||
@@ -165,35 +264,13 @@ async def websocket_endpoint(
|
|||||||
missing_fields=missing_profile,
|
missing_fields=missing_profile,
|
||||||
nickname=user.nickname or "",
|
nickname=user.nickname or "",
|
||||||
)
|
)
|
||||||
ai_msg_id = await ConversationHistoryStore(
|
await _stream_ai_only_messages(
|
||||||
db
|
greetings, log_label="profile_greeting"
|
||||||
).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(),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
if i < ng - 1:
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("发送资料收集开场白失败: {}", e)
|
logger.exception("发送资料收集开场白失败: {}", e)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
state = memoir_state
|
|
||||||
user_profile_context = format_user_profile_context(
|
user_profile_context = format_user_profile_context(
|
||||||
birth_year=user.birth_year,
|
birth_year=user.birth_year,
|
||||||
birth_place=user.birth_place,
|
birth_place=user.birth_place,
|
||||||
@@ -204,7 +281,7 @@ async def websocket_endpoint(
|
|||||||
opening_messages = (
|
opening_messages = (
|
||||||
await chat_orchestrator.generate_opening_message(
|
await chat_orchestrator.generate_opening_message(
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
memoir_state=state,
|
memoir_state=memoir_state,
|
||||||
user_profile_context=user_profile_context,
|
user_profile_context=user_profile_context,
|
||||||
background_voice=infer_background_voice(
|
background_voice=infer_background_voice(
|
||||||
user.occupation
|
user.occupation
|
||||||
@@ -214,32 +291,60 @@ async def websocket_endpoint(
|
|||||||
profile_era_place=era_place,
|
profile_era_place=era_place,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
ai_msg_id = await ConversationHistoryStore(
|
await _stream_ai_only_messages(
|
||||||
db
|
opening_messages, log_label="opening"
|
||||||
).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(),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
if i < no - 1:
|
await _maybe_send_topic_chips(reason="opening")
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("发送空对话开场白失败: {}", 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:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
type NativeSyntheticEvent,
|
type NativeSyntheticEvent,
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text as RNText,
|
Text as RNText,
|
||||||
TextInput,
|
TextInput,
|
||||||
@@ -50,6 +51,7 @@ import { useThemeColors } from '@/hooks/use-theme-colors';
|
|||||||
import { useTypography } from '@/core/typography-context';
|
import { useTypography } from '@/core/typography-context';
|
||||||
import { useMessages, useRealtimeSession } from '@/features/conversation/hooks';
|
import { useMessages, useRealtimeSession } from '@/features/conversation/hooks';
|
||||||
import type { TtsSegmentPayload } from '@/features/conversation/realtime-session';
|
import type { TtsSegmentPayload } from '@/features/conversation/realtime-session';
|
||||||
|
import type { TopicSuggestion } from '@/core/ws/types';
|
||||||
import { conversationKeys } from '@/features/conversation/query-keys';
|
import { conversationKeys } from '@/features/conversation/query-keys';
|
||||||
import {
|
import {
|
||||||
assistantSegmentMessageId,
|
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({
|
function ChatInputBar({
|
||||||
value,
|
value,
|
||||||
onChangeText,
|
onChangeText,
|
||||||
@@ -1171,6 +1225,8 @@ export default function ConversationScreen() {
|
|||||||
connectionState,
|
connectionState,
|
||||||
streamingMessage,
|
streamingMessage,
|
||||||
awaitingAssistantReply,
|
awaitingAssistantReply,
|
||||||
|
topicSuggestions,
|
||||||
|
dismissTopicSuggestions,
|
||||||
sendText,
|
sendText,
|
||||||
sendVoiceMessage,
|
sendVoiceMessage,
|
||||||
sendTtsCancel,
|
sendTtsCancel,
|
||||||
@@ -1394,6 +1450,38 @@ export default function ConversationScreen() {
|
|||||||
scheduleRefocusComposer();
|
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';
|
const composerDisabled = connectionState === 'disconnected';
|
||||||
|
|
||||||
@@ -1533,6 +1621,12 @@ export default function ConversationScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
<TopicChipsRow
|
||||||
|
chips={topicSuggestions}
|
||||||
|
onPressChip={handleTopicChipPress}
|
||||||
|
onDismiss={dismissTopicSuggestions}
|
||||||
|
dismissLabel={t('topicSuggestionsDismiss')}
|
||||||
|
/>
|
||||||
<ChatInputBar
|
<ChatInputBar
|
||||||
value={input}
|
value={input}
|
||||||
onChangeText={setInput}
|
onChangeText={setInput}
|
||||||
@@ -1826,6 +1920,40 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
paddingVertical: 12,
|
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: {
|
iconButton: {
|
||||||
width: 44,
|
width: 44,
|
||||||
height: 44,
|
height: 44,
|
||||||
|
|||||||
@@ -59,6 +59,28 @@ function mapServerMessage(raw: RawServerMessage): WsEvent | null {
|
|||||||
case 'memoir_update':
|
case 'memoir_update':
|
||||||
return { kind: 'memoir_updated', conversationId: cid, data: d };
|
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':
|
case 'error':
|
||||||
return {
|
return {
|
||||||
kind: 'session_error',
|
kind: 'session_error',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type ServerMessageType =
|
|||||||
| 'tts_audio'
|
| 'tts_audio'
|
||||||
| 'end_conversation'
|
| 'end_conversation'
|
||||||
| 'memoir_update'
|
| 'memoir_update'
|
||||||
|
| 'topic_suggestions'
|
||||||
| 'error';
|
| 'error';
|
||||||
|
|
||||||
export type ClientMessageType =
|
export type ClientMessageType =
|
||||||
@@ -81,6 +82,22 @@ export interface MemoirUpdatedEvent {
|
|||||||
data: Record<string, unknown>;
|
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 {
|
export interface SessionErrorEvent {
|
||||||
kind: 'session_error';
|
kind: 'session_error';
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
@@ -96,6 +113,7 @@ export type WsEvent =
|
|||||||
| TtsAudioReceivedEvent
|
| TtsAudioReceivedEvent
|
||||||
| ConversationEndedEvent
|
| ConversationEndedEvent
|
||||||
| MemoirUpdatedEvent
|
| MemoirUpdatedEvent
|
||||||
|
| TopicSuggestionsEvent
|
||||||
| SessionErrorEvent;
|
| SessionErrorEvent;
|
||||||
|
|
||||||
// ─── Connection state ───
|
// ─── Connection state ───
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { File, Paths } from 'expo-file-system';
|
import { File, Paths } from 'expo-file-system';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
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 { conversationApi } from './api';
|
||||||
import { conversationMessagesRepository } from './conversation-messages-repository';
|
import { conversationMessagesRepository } from './conversation-messages-repository';
|
||||||
@@ -183,6 +183,9 @@ interface RealtimeSessionState {
|
|||||||
/** 已发出用户消息,尚未收到助手首段流式文本(用于「正在回复」气泡) */
|
/** 已发出用户消息,尚未收到助手首段流式文本(用于「正在回复」气泡) */
|
||||||
awaitingAssistantReply: boolean;
|
awaitingAssistantReply: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
/** 服务端下发的 quick-start 话题 chips;用户首次发文本/语音后清空 */
|
||||||
|
topicSuggestions: TopicSuggestion[];
|
||||||
|
dismissTopicSuggestions: () => void;
|
||||||
sendText: (text: string) => void;
|
sendText: (text: string) => void;
|
||||||
sendVoiceMessage: (uri: string, durationMs: number) => Promise<boolean>;
|
sendVoiceMessage: (uri: string, durationMs: number) => Promise<boolean>;
|
||||||
sendEndConversation: () => void;
|
sendEndConversation: () => void;
|
||||||
@@ -204,6 +207,9 @@ export function useRealtimeSession({
|
|||||||
useState<StreamingAgentMessage | null>(null);
|
useState<StreamingAgentMessage | null>(null);
|
||||||
const [awaitingAssistantReply, setAwaitingAssistantReply] = useState(false);
|
const [awaitingAssistantReply, setAwaitingAssistantReply] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [topicSuggestions, setTopicSuggestions] = useState<TopicSuggestion[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const handleStreamingText: StreamingTextCallback = useCallback(
|
const handleStreamingText: StreamingTextCallback = useCallback(
|
||||||
(text, isComplete) => {
|
(text, isComplete) => {
|
||||||
@@ -225,6 +231,17 @@ export function useRealtimeSession({
|
|||||||
setError(message);
|
setError(message);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleTopicSuggestions = useCallback(
|
||||||
|
(payload: { suggestions: TopicSuggestion[] }) => {
|
||||||
|
setTopicSuggestions(payload.suggestions);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dismissTopicSuggestions = useCallback(() => {
|
||||||
|
setTopicSuggestions([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || !conversationId) return;
|
if (!enabled || !conversationId) return;
|
||||||
|
|
||||||
@@ -233,6 +250,7 @@ export function useRealtimeSession({
|
|||||||
queryClient,
|
queryClient,
|
||||||
onStreamingText: handleStreamingText,
|
onStreamingText: handleStreamingText,
|
||||||
onTtsSegment,
|
onTtsSegment,
|
||||||
|
onTopicSuggestions: handleTopicSuggestions,
|
||||||
onError: handleError,
|
onError: handleError,
|
||||||
onStateChange: setConnectionState,
|
onStateChange: setConnectionState,
|
||||||
});
|
});
|
||||||
@@ -246,6 +264,7 @@ export function useRealtimeSession({
|
|||||||
setConnectionState('disconnected');
|
setConnectionState('disconnected');
|
||||||
setStreamingMessage(null);
|
setStreamingMessage(null);
|
||||||
setAwaitingAssistantReply(false);
|
setAwaitingAssistantReply(false);
|
||||||
|
setTopicSuggestions([]);
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -253,6 +272,7 @@ export function useRealtimeSession({
|
|||||||
queryClient,
|
queryClient,
|
||||||
handleStreamingText,
|
handleStreamingText,
|
||||||
handleError,
|
handleError,
|
||||||
|
handleTopicSuggestions,
|
||||||
onTtsSegment,
|
onTtsSegment,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -267,6 +287,7 @@ export function useRealtimeSession({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setAwaitingAssistantReply(true);
|
setAwaitingAssistantReply(true);
|
||||||
|
setTopicSuggestions([]);
|
||||||
onTtsPlaybackResume?.();
|
onTtsPlaybackResume?.();
|
||||||
|
|
||||||
const localId = `pending_${Date.now()}`;
|
const localId = `pending_${Date.now()}`;
|
||||||
@@ -326,6 +347,7 @@ export function useRealtimeSession({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setAwaitingAssistantReply(true);
|
setAwaitingAssistantReply(true);
|
||||||
|
setTopicSuggestions([]);
|
||||||
const localId = `pending_voice_${Date.now()}`;
|
const localId = `pending_voice_${Date.now()}`;
|
||||||
await voiceSegmentStore.recordSentSegment({
|
await voiceSegmentStore.recordSentSegment({
|
||||||
voiceSessionId,
|
voiceSessionId,
|
||||||
@@ -385,6 +407,8 @@ export function useRealtimeSession({
|
|||||||
streamingMessage,
|
streamingMessage,
|
||||||
awaitingAssistantReply,
|
awaitingAssistantReply,
|
||||||
error,
|
error,
|
||||||
|
topicSuggestions,
|
||||||
|
dismissTopicSuggestions,
|
||||||
sendText,
|
sendText,
|
||||||
sendVoiceMessage,
|
sendVoiceMessage,
|
||||||
sendEndConversation,
|
sendEndConversation,
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import {
|
|||||||
type WsEventListener,
|
type WsEventListener,
|
||||||
type WsStateListener,
|
type WsStateListener,
|
||||||
} from '@/core/ws/client';
|
} 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 { handleWsEvent } from './event-handlers';
|
||||||
import { assistantSegmentMessageId, lastSegmentPreview } from './message-split';
|
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 StreamingTextCallback = (text: string, isComplete: boolean) => void;
|
||||||
export type ErrorCallback = (message: string, code?: string) => 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,或两者都有 */
|
/** WebSocket `tts_audio`:服务端可能只带 base64、只带 COS URL,或两者都有 */
|
||||||
export type TtsSegmentPayload = {
|
export type TtsSegmentPayload = {
|
||||||
@@ -31,6 +40,8 @@ interface RealtimeSessionOptions {
|
|||||||
onStreamingText?: StreamingTextCallback;
|
onStreamingText?: StreamingTextCallback;
|
||||||
/** 收到 TTS 片段时入队播放(与「气泡上的手动朗读按钮」无关) */
|
/** 收到 TTS 片段时入队播放(与「气泡上的手动朗读按钮」无关) */
|
||||||
onTtsSegment?: (payload: TtsSegmentPayload) => void;
|
onTtsSegment?: (payload: TtsSegmentPayload) => void;
|
||||||
|
/** 服务端下发 quick-start 话题 chips 时回调;用户点击后调 sendText */
|
||||||
|
onTopicSuggestions?: TopicSuggestionsCallback;
|
||||||
onError?: ErrorCallback;
|
onError?: ErrorCallback;
|
||||||
onStateChange?: WsStateListener;
|
onStateChange?: WsStateListener;
|
||||||
}
|
}
|
||||||
@@ -52,6 +63,7 @@ export class RealtimeSession {
|
|||||||
private queryClient: QueryClient;
|
private queryClient: QueryClient;
|
||||||
private onStreamingText?: StreamingTextCallback;
|
private onStreamingText?: StreamingTextCallback;
|
||||||
private onTtsSegment?: (payload: TtsSegmentPayload) => void;
|
private onTtsSegment?: (payload: TtsSegmentPayload) => void;
|
||||||
|
private onTopicSuggestions?: TopicSuggestionsCallback;
|
||||||
private onError?: ErrorCallback;
|
private onError?: ErrorCallback;
|
||||||
private unsubEvent: (() => void) | null = null;
|
private unsubEvent: (() => void) | null = null;
|
||||||
private unsubState: (() => void) | null = null;
|
private unsubState: (() => void) | null = null;
|
||||||
@@ -66,6 +78,7 @@ export class RealtimeSession {
|
|||||||
this.queryClient = options.queryClient;
|
this.queryClient = options.queryClient;
|
||||||
this.onStreamingText = options.onStreamingText;
|
this.onStreamingText = options.onStreamingText;
|
||||||
this.onTtsSegment = options.onTtsSegment;
|
this.onTtsSegment = options.onTtsSegment;
|
||||||
|
this.onTopicSuggestions = options.onTopicSuggestions;
|
||||||
this.onError = options.onError;
|
this.onError = options.onError;
|
||||||
|
|
||||||
this.unsubEvent = this.client.onEvent(this.handleEvent);
|
this.unsubEvent = this.client.onEvent(this.handleEvent);
|
||||||
@@ -154,6 +167,15 @@ export class RealtimeSession {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.kind === 'topic_suggestions') {
|
||||||
|
this.onTopicSuggestions?.({
|
||||||
|
reason: event.reason,
|
||||||
|
stage: event.stage,
|
||||||
|
suggestions: event.suggestions,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
handleWsEvent(this.queryClient, event);
|
handleWsEvent(this.queryClient, event);
|
||||||
|
|
||||||
if (event.kind === 'session_error') {
|
if (event.kind === 'session_error') {
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ interface Resources {
|
|||||||
switchToVoice: 'Switch to voice input';
|
switchToVoice: 'Switch to voice input';
|
||||||
tapToEndRecording: 'Tap to end';
|
tapToEndRecording: 'Tap to end';
|
||||||
tapToStartRecording: 'Tap to start recording';
|
tapToStartRecording: 'Tap to start recording';
|
||||||
|
topicSuggestionsDismiss: 'Hide';
|
||||||
timeDaysAgo_one: '{{count}} day ago';
|
timeDaysAgo_one: '{{count}} day ago';
|
||||||
timeDaysAgo_other: '{{count}} days ago';
|
timeDaysAgo_other: '{{count}} days ago';
|
||||||
timeHoursAgo_one: '{{count}} hour ago';
|
timeHoursAgo_one: '{{count}} hour ago';
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"switchToVoice": "Switch to voice input",
|
"switchToVoice": "Switch to voice input",
|
||||||
"tapToEndRecording": "Tap to end",
|
"tapToEndRecording": "Tap to end",
|
||||||
"tapToStartRecording": "Tap to start recording",
|
"tapToStartRecording": "Tap to start recording",
|
||||||
|
"topicSuggestionsDismiss": "Hide",
|
||||||
"viewAll": "View All",
|
"viewAll": "View All",
|
||||||
"voiceMessagePreview": "Voice message",
|
"voiceMessagePreview": "Voice message",
|
||||||
"timeJustNow": "Just now",
|
"timeJustNow": "Just now",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"switchToVoice": "切换到语音输入",
|
"switchToVoice": "切换到语音输入",
|
||||||
"tapToEndRecording": "点击结束",
|
"tapToEndRecording": "点击结束",
|
||||||
"tapToStartRecording": "点击开始录音",
|
"tapToStartRecording": "点击开始录音",
|
||||||
|
"topicSuggestionsDismiss": "收起",
|
||||||
"viewAll": "查看全部",
|
"viewAll": "查看全部",
|
||||||
"voiceMessagePreview": "语音消息",
|
"voiceMessagePreview": "语音消息",
|
||||||
"timeJustNow": "刚刚",
|
"timeJustNow": "刚刚",
|
||||||
|
|||||||
Reference in New Issue
Block a user