feat: improve elder companion conversations

This commit is contained in:
Codex Local
2026-08-29 22:06:30 +08:00
parent e652516147
commit f0849119f7
11 changed files with 176 additions and 22 deletions
+24 -2
View File
@@ -23,6 +23,28 @@ const bundleFields: Array<{ field: BundleField; kind: Exclude<Kind, "AGENT_BUNDL
{ field: "routing_version_id", kind: "ROUTING", label: "路由版本" }
];
const elderCompanionSystemPrompt = `你是一位温和、耐心、尊重长者的陪伴型语音助手。
你的目标不是快速获得答案,而是让长者愿意表达,并感到自己被认真倾听。
聊天规则:
1. 使用简单、自然、口语化的中文,每次尽量只说一到两句话。
2. 一次只问一个问题,不连续盘问;每连续提问两次后,加入一次共情、总结或简短回应。
3. 先回应长者的情绪,再继续聊天,优先围绕长者刚刚提到的人、事、物展开。
4. 长者回答很短时,可以给出两个简单选项帮助继续,但不要替长者作决定。
5. 长者沉默时不要催促,可以说“没关系,我们慢慢聊”。
6. 长者重复讲同一件事时,不要指出“已经讲过”,应接住其中的情绪或细节。
7. 不要编造没有提供过的家庭信息、经历、记忆或事实;不确定时明确表示不确定。
8. 不主动提起背景资料中标记为避免的话题;长者主动提及时先共情,再谨慎回应。
9. 涉及疾病、用药和症状时,只做一般性安全提醒,不代替医生诊断或擅自建议停药换药。
10. 长者想结束聊天时自然收尾,不强行挽留,也不为了延长对话制造话题。
11. 不纠正长者的口音、表达方式或生活选择,不使用网络流行语和复杂术语。
12. 没有合适话题时,可从天气、饮食、睡眠、花草、电视节目或家人近况中选择一个开场。
推荐流程:问候近况 -> 找到兴趣或记忆关键词 -> 温和追问一个细节 -> 回应情绪 -> 自然收尾。
表达风格应像熟悉的晚辈或朋友,不像客服、医生或采访者。`;
export function OpsConfigWorkspace({ accessToken, accounts }: { accessToken: string; accounts: CurrentUser["accounts"] }) {
const opsAccounts = accounts.filter((item) => item.platform_role !== "customer");
const [tenantID, setTenantID] = useState(opsAccounts[0]?.tenant_id ?? "");
@@ -250,7 +272,7 @@ export function OpsConfigWorkspace({ accessToken, accounts }: { accessToken: str
{kind === "AGENT_BUNDLE" ? <fieldset className="ops-bundle-composer ops-config-form__wide"><legend>组合组件版本</legend>{bundleFields.map((definition) => {
const candidates = bundleCandidates.get(definition.kind) ?? [];
return <label key={definition.field}><span>{definition.label}</span><select value={bundlePayload[definition.field]} disabled={!candidates.length} required onChange={(event) => selectBundleComponent(definition.field, event.target.value)}><option value="">{candidates.length ? "请选择已发布版本" : "暂无可用版本"}</option>{candidates.map((candidate) => <option key={candidate.id} value={candidate.id}>{candidate.display_name} · {candidate.config_key} v{candidate.version_no}{candidate.status === "RETIRED" ? " · 已退役(可追溯复用)" : " · 已发布"}</option>)}</select></label>;
})}<small className={unavailableBundleKinds.length ? "ops-bundle-composer__warning" : ""}>{unavailableBundleKinds.length ? `缺少可用组件:${unavailableBundleKinds.map((item) => item.label).join("、")}。请先完成对应配置的审批发布。` : "仅列出当前客户组织内已经发布过的不可变版本;提交和发布时服务端仍会复核租户、类型与状态。"}</small></fieldset> : <label className="ops-config-form__wide"><span>配置正文(JSON)</span><textarea rows={8} value={payloadText} onChange={(event) => setPayloadText(event.target.value)} spellCheck={false} /></label>}
})}<small className={unavailableBundleKinds.length ? "ops-bundle-composer__warning" : ""}>{unavailableBundleKinds.length ? `缺少可用组件:${unavailableBundleKinds.map((item) => item.label).join("、")}。请先完成对应配置的审批发布。` : "仅列出当前客户组织内已经发布过的不可变版本;提交和发布时服务端仍会复核租户、类型与状态。"}</small></fieldset> : <label className="ops-config-form__wide"><span>配置正文(JSON)</span><textarea rows={kind === "PERSONA" ? 18 : 8} value={payloadText} onChange={(event) => setPayloadText(event.target.value)} spellCheck={false} />{kind === "PERSONA" ? <small>默认模板包含倾听、单问题追问、沉默处理、事实边界、敏感话题和医疗安全规则;创建后仍需完成评测、审批和灰度发布。</small> : null}</label>}
{kind === "AGENT_BUNDLE" ? <p className="ops-config-form__wide ops-config-form__note">组合不直接保存密钥引用,模型和音色凭据由各自已发布组件管理。</p> : <label className="ops-config-form__wide"><span>受控密钥引用(JSON,不得填写密钥明文)</span><textarea rows={3} value={secretRefsText} onChange={(event) => setSecretRefsText(event.target.value)} spellCheck={false} /></label>}
<label className="ops-config-form__wide"><span>变更说明</span><input value={summary} maxLength={500} onChange={(event) => setSummary(event.target.value)} placeholder="说明变更目的、预期影响和验证依据" required /></label>
<button type="submit" disabled={busy || !summary.trim() || !configKey.trim() || !displayName.trim() || (kind === "AGENT_BUNDLE" && !bundleReady)}>创建草稿</button>
@@ -272,7 +294,7 @@ export function OpsConfigWorkspace({ accessToken, accounts }: { accessToken: str
function defaultPayload(kind: Kind) {
const values: Record<Kind, unknown> = {
PERSONA: { system_prompt: "你是一位耐心、尊重长者的陪伴型语音助手。", tone: "warm" },
PERSONA: { system_prompt: elderCompanionSystemPrompt, tone: "warm" },
VOICE: { provider: "volcengine", voice_resource_ref: "voice://approved/default" }, MODEL: { provider: "volcengine", model_id: "model://approved/default" },
ROUTING: { default_engine_mode: "s2s_hybrid", routes: [{ goal: "open_conversation", engine_mode: "s2s_hybrid" }] },
AGENT_BUNDLE: { persona_version_id: "", voice_version_id: "", model_version_id: "", routing_version_id: "" }
+55 -5
View File
@@ -76,6 +76,15 @@ type LoadState = "idle" | "loading" | "ready" | "error";
type MediaLoadState = "idle" | "loading" | "ready" | "restricted" | "error";
type EventStreamState = "connecting" | "connected" | "reconnecting";
type ConversationPace = "slow" | "normal" | "lively";
type DialoguePreset = {
key: "daily" | "memory" | "interest";
label: string;
description: string;
goal: string;
pace: ConversationPace;
preferredTopics: string[];
avoidedTopics: string[];
};
type RecordingSegment = ConversationMedia["segments"][number];
type RecordingGrantInput = { grantee_user_id: string; permission: "PLAY" | "DOWNLOAD"; purpose: string; expires_at: string };
type SharingGrantInput = { resource_type: "STORY" | "TRANSCRIPT" | "RECORDING"; resource_id: string; grantee_user_id: string; permission: "VIEW_TEXT" | "LISTEN" | "DOWNLOAD" | "EDIT"; purpose: string; expires_at: string; login_required: boolean };
@@ -116,6 +125,24 @@ type FamilyControlDraft =
| { kind: "sharing_grant_create"; subjectID: string; subjectName: string; granteeName: string; resourceLabel: string; input: SharingGrantInput; acknowledged: boolean; confirmation: string }
| { kind: "sharing_revoke"; item: SharingGrant; acknowledged: boolean; confirmation: string };
const dialoguePresets: DialoguePreset[] = [
{
key: "daily", label: "日常陪伴", description: "从当天生活和轻松小事自然开场",
goal: "轻松聊聊当天的生活,让长者感到有人陪伴", pace: "slow",
preferredTopics: ["天气", "饮食", "睡眠", "花草", "电视节目"], avoidedTopics: ["疾病", "家庭矛盾", "收入"]
},
{
key: "memory", label: "回忆往事", description: "温和引导年轻时的经历和重要人物",
goal: "温和引导长者回忆年轻时的工作和生活经历", pace: "slow",
preferredTopics: ["家乡", "童年", "工作经历", "老朋友", "节日"], avoidedTopics: ["遗憾经历", "家庭矛盾", "经济压力"]
},
{
key: "interest", label: "兴趣聊天", description: "围绕熟悉爱好展开低压力交流",
goal: "围绕长者喜欢的兴趣进行轻松交流", pace: "normal",
preferredTopics: ["戏曲", "养花", "做饭", "散步", "孙辈"], avoidedTopics: ["争议新闻", "疾病", "家庭矛盾"]
}
];
export function SaaSWorkspace({ accessToken, accounts, section = "home", onSectionChange, showNavigation = true, enableFirmware = false }: {
accessToken: string;
accounts: CurrentUser["accounts"];
@@ -734,6 +761,13 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
});
}
function applyNewSubjectDialoguePreset(preset: DialoguePreset) {
setNewSubjectGoal(preset.goal);
setNewSubjectPace(preset.pace);
setNewSubjectTopics(preset.preferredTopics.join("、"));
setNewSubjectAvoidedTopics(preset.avoidedTopics.join("、"));
}
function updateSubjectProfile(subject: Subject, input: UpdateSubjectRequest) {
openFamilyControl({ kind: "subject_update", item: subject, input, acknowledged: false, confirmation: "" });
}
@@ -1261,12 +1295,16 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
<div className="subject-create-modal__body">
<div className="subject-avatar-upload" aria-hidden="true"><UserAddOutlined /><span>长者头像</span></div>
<label className="subject-field subject-field--wide"><span>称呼</span><input value={newSubjectName} maxLength={120} onChange={(event) => setNewSubjectName(event.target.value)} placeholder="请输入长者的称呼,如:爷爷、张奶奶" required /></label>
<div className="dialogue-presets subject-field--wide"><div><strong>陪伴方式模板</strong><small>模板只填充建议值,保存前仍可逐项修改</small></div><div>{dialoguePresets.map((preset) => <button type="button" key={preset.key} onClick={() => applyNewSubjectDialoguePreset(preset)}><strong>{preset.label}</strong><span>{preset.description}</span></button>)}</div></div>
<div className="subject-create-modal__row">
<label className="subject-field"><span>语言</span><select value="zh-CN" disabled><option value="zh-CN">普通话</option></select></label>
<label className="subject-field"><span>交流节奏</span><select value={newSubjectPace} onChange={(event) => setNewSubjectPace(event.target.value as ConversationPace)}><option value="slow">较慢,需要更多耐心</option><option value="normal">自然日常节奏</option><option value="lively">轻快交流</option></select></label>
</div>
<label className="subject-field subject-field--wide"><span>背景资料</span><textarea value={newSubjectBackground} maxLength={500} onChange={(event) => setNewSubjectBackground(event.target.value)} placeholder="记录长者的兴趣爱好、过往经历或需要特别注意的事项" required /></label>
<label className="subject-field subject-field--wide"><span>本次交流目标</span><input value={newSubjectGoal} maxLength={200} onChange={(event) => setNewSubjectGoal(event.target.value)} placeholder="例如:聊聊第一次参加工作的经历" required /></label>
<label className="subject-field subject-field--wide"><span>背景资料</span><textarea value={newSubjectBackground} maxLength={500} onChange={(event) => setNewSubjectBackground(event.target.value)} placeholder="例如:退休前是教师,年轻时在天津生活过" required /><small>只填写已经核实、允许用于陪伴交流的事实,不要填写账号、证件或联系方式。</small></label>
<label className="subject-field subject-field--wide"><span>兴趣标签</span><input value={newSubjectInterests} maxLength={400} onChange={(event) => setNewSubjectInterests(event.target.value)} placeholder="例如:京剧、养花、做饭(用逗号分隔,最多8项)" /><small>用于寻找低压力的开场话题。</small></label>
<label className="subject-field subject-field--wide"><span>本次交流目标</span><input value={newSubjectGoal} maxLength={200} onChange={(event) => setNewSubjectGoal(event.target.value)} placeholder="例如:多聊年轻时的工作经历,让长者心情放松" required /><small>描述希望智能体怎样陪伴,而不是要求长者必须回答什么。</small></label>
<label className="subject-field subject-field--wide"><span>优先话题</span><input value={newSubjectTopics} maxLength={400} onChange={(event) => setNewSubjectTopics(event.target.value)} placeholder="例如:家乡、京剧、孙辈(用逗号分隔,最多8项)" /><small>智能体会优先从这些话题中选择自然的切入点。</small></label>
<label className="subject-field subject-field--wide"><span>避免主动提及</span><input value={newSubjectAvoidedTopics} maxLength={400} onChange={(event) => setNewSubjectAvoidedTopics(event.target.value)} placeholder="例如:收入、家庭矛盾(用逗号分隔,最多8项)" /><small>智能体不会主动提起;长者主动提及时会先共情,再谨慎回应。</small></label>
</div>
<footer><button type="button" className="subject-modal-cancel" onClick={() => setSubjectCreateOpen(false)}>取消</button><button type="submit" disabled={actionBusy || !newSubjectName.trim() || !newSubjectBackground.trim() || !newSubjectGoal.trim()}>立即创建</button></footer>
</form>
@@ -1594,7 +1632,18 @@ function SubjectProfileEditor({ subject, canWrite, actionBusy, onSave }: {
conversation_preferences: { ...subject.conversation_preferences, conversation_goal: goal.trim(), pace, preferred_topics: topics, avoided_topics: avoidedTopics }
});
}
function applyDialoguePreset(preset: DialoguePreset) {
if (!canWrite) return;
setGoal(preset.goal);
setPace(preset.pace);
setTopics([...preset.preferredTopics]);
setAvoidedTopics([...preset.avoidedTopics]);
}
return <form className="profile-editor subject-profile-editor" onSubmit={(event) => void submit(event)}>
<section className="subject-editor-card subject-editor-card--presets">
<div className="subject-section-heading"><span className="subject-section-icon subject-section-icon--topic"><MessageOutlined /></span><div><h4>陪伴方式模板</h4><p>快速设置交流目标、节奏和话题边界,应用后仍需核对并保存</p></div></div>
<div className="dialogue-presets dialogue-presets--editor">{dialoguePresets.map((preset) => <button type="button" key={preset.key} disabled={!canWrite} onClick={() => applyDialoguePreset(preset)}><strong>{preset.label}</strong><span>{preset.description}</span></button>)}</div>
</section>
<section className="subject-editor-card">
<div className="subject-section-heading"><span className="subject-section-icon subject-section-icon--interest"><HeartOutlined /></span><div><h4>兴趣标签</h4><p>记录长者日常喜欢的活动或事物</p></div></div>
<SubjectTagEditor items={interests} onChange={setInterests} disabled={!canWrite} placeholder="添加新标签(按回车确认)" emptyText="尚未添加兴趣标签" removeLabel="删除兴趣标签" />
@@ -1602,6 +1651,7 @@ function SubjectProfileEditor({ subject, canWrite, actionBusy, onSave }: {
<section className="subject-editor-card subject-editor-card--topics">
<div className="subject-section-heading"><span className="subject-section-icon subject-section-icon--topic"><MessageOutlined /></span><div><h4>推荐话题</h4><p>沟通时容易引起共鸣的积极话题</p></div></div>
<SubjectTagEditor items={topics} onChange={setTopics} disabled={!canWrite} placeholder="添加推荐话题(按回车确认)" emptyText="尚未添加推荐话题" removeLabel="删除推荐话题" tone="topic" />
<small className="subject-tag-editor__note">优先填写家乡、兴趣、工作经历等容易自然展开的话题。</small>
</section>
<section className="subject-editor-card subject-editor-card--avoid">
<div className="subject-section-heading"><span className="subject-section-icon subject-section-icon--avoid"><StopOutlined /></span><div><h4>避讳话题</h4><p>避免引起长者不悦或情绪波动,不影响紧急安全响应</p></div></div>
@@ -1613,8 +1663,8 @@ function SubjectProfileEditor({ subject, canWrite, actionBusy, onSave }: {
<div className="subject-basics-grid">
<label className="subject-field"><span>称呼</span><input disabled={!canWrite} value={name} maxLength={120} onChange={(event) => setName(event.target.value)} required /></label>
<label className="subject-field"><span>交流节奏</span><select disabled={!canWrite} value={pace} onChange={(event) => setPace(event.target.value as ConversationPace)}><option value="slow">放慢语速</option><option value="normal">自然语速</option><option value="lively">轻快交流</option></select></label>
<label className="subject-field subject-field--wide"><span>背景资料</span><textarea disabled={!canWrite} value={background} maxLength={500} onChange={(event) => setBackground(event.target.value)} required /></label>
<label className="subject-field subject-field--wide"><span>交流目标</span><input disabled={!canWrite} value={goal} maxLength={200} onChange={(event) => setGoal(event.target.value)} required /></label>
<label className="subject-field subject-field--wide"><span>背景资料</span><textarea disabled={!canWrite} value={background} maxLength={500} onChange={(event) => setBackground(event.target.value)} required /><small>只填写已经核实并允许用于陪伴交流的事实。</small></label>
<label className="subject-field subject-field--wide"><span>交流目标</span><input disabled={!canWrite} value={goal} maxLength={200} onChange={(event) => setGoal(event.target.value)} required /><small>例如:多聊年轻时的工作经历,让长者心情放松。</small></label>
</div>
</section>
{canWrite ? <footer className="subject-editor-actions"><span>{profileChanged ? "已有尚未保存的资料变更" : "资料已是最新版本"}</span><button type="submit" disabled={actionBusy || !profileChanged || !name.trim() || !background.trim() || !goal.trim()}>核对并保存新版本</button></footer> : null}
@@ -2316,7 +2366,7 @@ function exportTaskCategories(task: ExportTask) { const value = task.scope.categ
function profileString(value: Record<string, unknown>, key: string) { return typeof value[key] === "string" ? value[key] as string : ""; }
function profilePace(value: Record<string, unknown>): ConversationPace { const pace = profileString(value, "pace"); return pace === "normal" || pace === "lively" ? pace : "slow"; }
function profileList(value: Record<string, unknown>, key: string) { return Array.isArray(value[key]) ? (value[key] as unknown[]).filter((item): item is string => typeof item === "string") : []; }
function splitContextList(value: string) { return value.split(/[,,]/).map((item) => item.trim()).filter(Boolean).slice(0, 8).map((item) => item.slice(0, 50)); }
function splitContextList(value: string) { return value.split(/[、,,]/).map((item) => item.trim()).filter(Boolean).slice(0, 8).map((item) => item.slice(0, 50)); }
function contextItemsLabel(value: unknown) { return Array.isArray(value) && value.length ? value.filter((item): item is string => typeof item === "string").join("、") || "未填写" : "未填写"; }
function conversationPaceLabel(value: unknown) { return ({ slow: "放慢语速", normal: "自然语速", lively: "轻快交流" } as Record<string, string>)[typeof value === "string" ? value : ""] ?? "未填写"; }
function subjectProfileChangeSummary(subject: Subject, input: UpdateSubjectRequest) {
+17
View File
@@ -1872,6 +1872,18 @@ h3 { font-size: 16px; }
.subject-create-modal header button,
.subject-detail-modal__header > button { padding: 9px; color: #58423d; background: transparent; box-shadow: none; }
.subject-create-modal__body { display: grid; gap: 18px; padding: 24px; background: var(--subject-surface); }
.dialogue-presets { display: grid; gap: 10px; }
.dialogue-presets > div:first-child { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.dialogue-presets > div:first-child > strong { color: var(--subject-text); font-size: 13px; }
.dialogue-presets > div:first-child > small { color: var(--subject-muted); font-size: 11px; }
.dialogue-presets > div:last-child,
.dialogue-presets--editor { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.dialogue-presets button { display: grid; gap: 4px; min-height: 72px; padding: 12px; border: 1px solid #dfcfca; border-radius: 12px; color: var(--subject-text); text-align: left; background: #fff; box-shadow: none; }
.dialogue-presets button:hover:not(:disabled) { border-color: var(--subject-primary); background: #fff8f5; transform: translateY(-1px); }
.dialogue-presets button strong { font-size: 13px; }
.dialogue-presets button span { color: var(--subject-muted); font-size: 11px; font-weight: 400; line-height: 1.45; }
.dialogue-presets button:disabled { cursor: not-allowed; opacity: .58; }
.dialogue-presets--editor { margin-top: 12px; }
.subject-avatar-upload { display: grid; place-items: center; gap: 8px; width: 92px; height: 92px; margin: 0 auto 4px; border: 1px solid #e3d6d2; border-radius: 50%; color: #58423d; background: #f4f1f0; }
.subject-avatar-upload > svg { font-size: 28px; }
.subject-avatar-upload span { font-size: 11px; }
@@ -1918,6 +1930,7 @@ h3 { font-size: 16px; }
.subject-profile-editor { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 0; padding: 0; border: 0; background: transparent; }
.subject-editor-card,
.subject-record-card { padding: 18px; border: 1px solid var(--subject-border); border-radius: 13px; background: #fff; box-shadow: 0 6px 18px rgba(80, 48, 39, .04); }
.subject-editor-card--presets,
.subject-editor-card--avoid,
.subject-editor-card--basics,
.subject-editor-actions { grid-column: 1 / -1; }
@@ -1963,10 +1976,14 @@ h3 { font-size: 16px; }
.subject-record-grid,
.subject-basics-grid,
.subject-create-modal__row { grid-template-columns: 1fr; }
.subject-editor-card--presets,
.subject-editor-card--avoid,
.subject-editor-card--basics,
.subject-editor-actions,
.subject-field--wide { grid-column: auto; }
.dialogue-presets > div:first-child { align-items: flex-start; flex-direction: column; }
.dialogue-presets > div:last-child,
.dialogue-presets--editor { grid-template-columns: 1fr; }
.subject-modal-backdrop { align-items: end; padding: 0; }
.subject-create-modal,
.subject-detail-modal { width: 100%; max-height: 94vh; border-radius: 18px 18px 0 0; }