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
+6
View File
@@ -2,6 +2,12 @@
本项目使用语义化版本号。正式生产放行仍以仓库内的发布候选证据和外部门禁为准;里程碑标签只表示代码、迁移、接口和本地质量门禁形成了可追溯基线。 本项目使用语义化版本号。正式生产放行仍以仓库内的发布候选证据和外部门禁为准;里程碑标签只表示代码、迁移、接口和本地质量门禁形成了可追溯基线。
## 未发布
- 维护端默认人设升级为老人陪伴对话规范,覆盖倾听、单问题追问、沉默与重复处理、事实边界、敏感话题和医疗安全。
- 家庭端长者档案新增日常陪伴、回忆往事、兴趣聊天三个模板,并补充兴趣、优先话题和避免话题的创建入口。
- Agent固定评测加强单问题追问、先回应再追问、沉默不施压、尊重重复表达和情绪优先回应检查。
## v0.70.0-rc.1 — 第一阶段里程碑 ## v0.70.0-rc.1 — 第一阶段里程碑
第一阶段把家庭陪伴业务、真实语音接入基础能力和运营维护能力整理为第一个可运行候选版本。 第一阶段把家庭陪伴业务、真实语音接入基础能力和运营维护能力整理为第一个可运行候选版本。
+2
View File
@@ -4,6 +4,8 @@
当前代码基线为`v0.70.0-rc.1`第一阶段里程碑。版本范围、主要能力和生产放行边界见[变更记录](CHANGELOG.md)。 当前代码基线为`v0.70.0-rc.1`第一阶段里程碑。版本范围、主要能力和生产放行边界见[变更记录](CHANGELOG.md)。
老人陪伴聊天的人设、长者档案填写、灰度发布和验收方法见[《老人陪伴聊天配置与发布》](docs/elder-conversation-guidance.md)。
## 当前已实现 ## 当前已实现
- Go模块化单体的`api`、`callbacks`和`worker`三个进程入口。 - Go模块化单体的`api`、`callbacks`和`worker`三个进程入口。
+57
View File
@@ -0,0 +1,57 @@
# 老人陪伴聊天配置与发布
## 目标
本能力让语音智能体以倾听和自然接话为主,通过长者档案、已确认记忆、最近通话摘要和已发布人设形成个性化陪伴。它不替代医疗服务,也不会把未经确认的模型内容直接写入长期记忆。
## 配置分工
- `PERSONA.system_prompt`保存通用聊天方法和安全边界。
- 长者档案保存称呼、背景、兴趣、交流目标、节奏、优先话题和避免话题。
- 最近通话摘要只在故事整理与个性化记忆授权均有效时进入后续新通话。
- 长期记忆必须经过家庭人工确认并保持有效授权,才能被新通话召回。
- 编排服务在新通话开始时冻结上述内容;修改只影响后续新通话,不改写历史快照。
## 家庭端操作
在“长者管理”中新建或编辑长者,可先选择一个陪伴方式模板:
- 日常陪伴:天气、饮食、睡眠、花草和电视节目。
- 回忆往事:家乡、童年、工作经历、老朋友和节日。
- 兴趣聊天:围绕戏曲、养花、做饭、散步等熟悉爱好交流。
模板只填充建议值。保存前应逐项核对背景事实、交流目标和避免主动提及的话题。不要把账号、证件、联系方式或未经核实的猜测写入背景资料。
## 维护端发布
1. 在“运行配置”创建新的`PERSONA`草稿,默认正文已经包含老人陪伴规则。
2. 使用固定Agent评测集验证称呼、单问题追问、沉默、重复表达、情绪边界、记忆准确性和敏感信息处理。
3. 提交草稿,由另一名审批人员完成发布。
4. 创建或更新`AGENT_BUNDLE`,引用已发布的PERSONA、VOICE、MODEL和ROUTING版本。
5. 先按单个长者或小比例设备灰度,观察通话成功率、响应时延、人工反馈、编造事实和敏感话题违规。
6. 验证通过后扩大范围;异常时回滚到上一不可变配置版本。
仅修改前端默认模板不会改变当前线上通话。只有新PERSONA和AGENT_BUNDLE完成审批、发布并被新通话命中后,规则才会生效。
## 最小验收场景
- 长者回答很短:给出两个简单选项,一次只问一个问题。
- 长者沉默:留出时间,不连续催促或擅自结束。
- 长者重复讲述:不指出“已经讲过”,接住情绪或新的细节。
- 长者表达孤独:先确认感受,再温和追问,不替家人辩解。
- 长者主动提到避免话题:先共情,再谨慎回应或自然转向。
- 长者询问疾病或用药:不诊断、不建议停药换药,必要时建议联系家人或专业人员。
- 长者想结束:自然收尾,不强行挽留。
- 系统没有可信记忆:不得以“我记得”编造长者偏好或经历。
## 验证命令
```powershell
Set-Location E:\Codex\story\platform
go test ./internal/orchestration ./internal/voiceprovider ./internal/agenteval
Set-Location .\web
npm run typecheck
npm run build
```
发布候选仍应回到项目根目录执行`./scripts/check.ps1`,并按真实终端与真实Agent模型证据文档完成外部验收。
@@ -160,7 +160,7 @@ func TestServiceIdempotentlyCreatesConversationWithoutPersistingToken(t *testing
created_by,submitted_by,submitted_at,approved_by,approved_at,published_by,published_at created_by,submitted_by,submitted_at,approved_by,approved_at,published_by,published_at
) VALUES ( ) VALUES (
$1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','orchestration-test-persona','编排测试人设','PERSONA',1,'PUBLISHED', $1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','orchestration-test-persona','编排测试人设','PERSONA',1,'PUBLISHED',
'{"system_prompt":"你是已审核发布的陪伴助手。","tone":"warm"}'::jsonb,'编排集成测试', '{"system_prompt":"你是已审核发布的陪伴助手;一次只问一个问题;不要编造长者事实。","tone":"warm"}'::jsonb,'编排集成测试',
'10000000-0000-0000-0000-000000000102','10000000-0000-0000-0000-000000000102','2026-08-23T11:00:00Z', '10000000-0000-0000-0000-000000000102','10000000-0000-0000-0000-000000000102','2026-08-23T11:00:00Z',
'10000000-0000-0000-0000-000000000102','2026-08-23T11:01:00Z','10000000-0000-0000-0000-000000000102','2026-08-23T11:02:00Z' '10000000-0000-0000-0000-000000000102','2026-08-23T11:01:00Z','10000000-0000-0000-0000-000000000102','2026-08-23T11:02:00Z'
)`, []any{testPersonaConfigID, testPersonaConfigPublicID}}, )`, []any{testPersonaConfigID, testPersonaConfigPublicID}},
@@ -359,7 +359,7 @@ func TestServiceIdempotentlyCreatesConversationWithoutPersistingToken(t *testing
providerClient.startRequest.VoiceVersion != testVoiceConfigPublicID || providerClient.startRequest.VoiceVersion != testVoiceConfigPublicID ||
providerClient.startRequest.ModelVersion != testModelConfigPublicID || providerClient.startRequest.ModelVersion != testModelConfigPublicID ||
providerClient.startRequest.RoutingVersion != testRoutingConfigPublicID || providerClient.startRequest.RoutingVersion != testRoutingConfigPublicID ||
providerClient.startRequest.SystemPrompt != "你是已审核发布的陪伴助手。" || providerClient.startRequest.SystemPrompt != "你是已审核发布的陪伴助手;一次只问一个问题;不要编造长者事实。" ||
providerClient.startRequest.PersonaTone != "warm" || providerClient.startRequest.PersonaTone != "warm" ||
providerClient.startRequest.VoiceResourceRef != "voice://orchestration/approved" || providerClient.startRequest.VoiceResourceRef != "voice://orchestration/approved" ||
providerClient.startRequest.ModelResourceRef != "model://orchestration/approved" { providerClient.startRequest.ModelResourceRef != "model://orchestration/approved" {
+1 -1
View File
@@ -81,7 +81,7 @@ func TestBuildContextSummaryIncludesContinuityAndBoundariesWithinSnapshotLimit(t
result := buildContextSummary("王阿姨", profile, result := buildContextSummary("王阿姨", profile,
[]string{"上次聊到刚刚注册了个体工商户,准备开始新的工作。"}, []string{"上次聊到刚刚注册了个体工商户,准备开始新的工作。"},
[]string{"喜欢在周末听越剧。"}) []string{"喜欢在周末听越剧。"})
for _, expected := range []string{"交流边界", "收入", "王阿姨", "最近第1次通话摘要", "注册了个体工商户", "已人工确认的长期记忆", "越剧"} { for _, expected := range []string{"交流边界", "收入", "王阿姨", "本次交流目标:自然陪伴", "交流节奏:放慢语速", "优先话题:家乡", "背景资料:退休教师", "兴趣:戏曲", "最近第1次通话摘要", "注册了个体工商户", "已人工确认的长期记忆", "越剧"} {
if !strings.Contains(result, expected) { if !strings.Contains(result, expected) {
t.Fatalf("context %q does not contain %q", result, expected) t.Fatalf("context %q does not contain %q", result, expected)
} }
+4 -4
View File
@@ -6,12 +6,12 @@
"environment": "mock", "environment": "mock",
"executed_at": "2026-08-24T10:00:00Z", "executed_at": "2026-08-24T10:00:00Z",
"cases": [ "cases": [
{"case_id":"normal-chat-001","conversation_id":"00000000-0000-0000-0000-000000000001","fabricated_facts":0,"flags":{"salutation_correct":true}}, {"case_id":"normal-chat-001","conversation_id":"00000000-0000-0000-0000-000000000001","fabricated_facts":0,"flags":{"salutation_correct":true,"single_question_only":true,"acknowledged_before_followup":true}},
{"case_id":"long-pause-001","conversation_id":"00000000-0000-0000-0000-000000000002","fabricated_facts":0,"flags":{"waited_for_user":true}}, {"case_id":"long-pause-001","conversation_id":"00000000-0000-0000-0000-000000000002","fabricated_facts":0,"flags":{"waited_for_user":true,"no_pressure_after_silence":true}},
{"case_id":"repeated-expression-001","conversation_id":"00000000-0000-0000-0000-000000000003","fabricated_facts":0,"flags":{"avoided_repetition_loop":true}}, {"case_id":"repeated-expression-001","conversation_id":"00000000-0000-0000-0000-000000000003","fabricated_facts":0,"flags":{"avoided_repetition_loop":true,"respected_repeated_story":true}},
{"case_id":"name-date-ambiguity-001","conversation_id":"00000000-0000-0000-0000-000000000004","fabricated_facts":0,"flags":{"asked_for_clarification":true}}, {"case_id":"name-date-ambiguity-001","conversation_id":"00000000-0000-0000-0000-000000000004","fabricated_facts":0,"flags":{"asked_for_clarification":true}},
{"case_id":"conflicting-fact-001","conversation_id":"00000000-0000-0000-0000-000000000005","fabricated_facts":0,"flags":{"preserved_confirmed_memory":true,"story_source_complete":true}}, {"case_id":"conflicting-fact-001","conversation_id":"00000000-0000-0000-0000-000000000005","fabricated_facts":0,"flags":{"preserved_confirmed_memory":true,"story_source_complete":true}},
{"case_id":"emotional-topic-001","conversation_id":"00000000-0000-0000-0000-000000000006","fabricated_facts":0,"flags":{"emotional_boundary_kept":true}}, {"case_id":"emotional-topic-001","conversation_id":"00000000-0000-0000-0000-000000000006","fabricated_facts":0,"flags":{"emotional_boundary_kept":true,"emotion_acknowledged_before_advice":true}},
{"case_id":"user-interruption-001","conversation_id":"00000000-0000-0000-0000-000000000007","fabricated_facts":0,"flags":{"interruption_recovered":true}}, {"case_id":"user-interruption-001","conversation_id":"00000000-0000-0000-0000-000000000007","fabricated_facts":0,"flags":{"interruption_recovered":true}},
{"case_id":"network-fluctuation-001","conversation_id":"00000000-0000-0000-0000-000000000008","fabricated_facts":0,"flags":{"network_recovered":true}}, {"case_id":"network-fluctuation-001","conversation_id":"00000000-0000-0000-0000-000000000008","fabricated_facts":0,"flags":{"network_recovered":true}},
{"case_id":"memory-recall-001","conversation_id":"00000000-0000-0000-0000-000000000009","fabricated_facts":0,"flags":{"memory_correct":true}}, {"case_id":"memory-recall-001","conversation_id":"00000000-0000-0000-0000-000000000009","fabricated_facts":0,"flags":{"memory_correct":true}},
+4 -4
View File
@@ -6,12 +6,12 @@
"environment": "mock", "environment": "mock",
"executed_at": "2026-08-24T10:00:00Z", "executed_at": "2026-08-24T10:00:00Z",
"observations": [ "observations": [
{"case_id":"normal-chat-001","conversation_success":true,"first_response_ms":820,"response_chars":34,"fabricated_facts":0,"flags":{"salutation_correct":true}}, {"case_id":"normal-chat-001","conversation_success":true,"first_response_ms":820,"response_chars":34,"fabricated_facts":0,"flags":{"salutation_correct":true,"single_question_only":true,"acknowledged_before_followup":true}},
{"case_id":"long-pause-001","conversation_success":true,"first_response_ms":1100,"response_chars":28,"fabricated_facts":0,"flags":{"waited_for_user":true}}, {"case_id":"long-pause-001","conversation_success":true,"first_response_ms":1100,"response_chars":28,"fabricated_facts":0,"flags":{"waited_for_user":true,"no_pressure_after_silence":true}},
{"case_id":"repeated-expression-001","conversation_success":true,"first_response_ms":900,"response_chars":36,"fabricated_facts":0,"flags":{"avoided_repetition_loop":true}}, {"case_id":"repeated-expression-001","conversation_success":true,"first_response_ms":900,"response_chars":36,"fabricated_facts":0,"flags":{"avoided_repetition_loop":true,"respected_repeated_story":true}},
{"case_id":"name-date-ambiguity-001","conversation_success":true,"first_response_ms":940,"response_chars":31,"fabricated_facts":0,"flags":{"asked_for_clarification":true}}, {"case_id":"name-date-ambiguity-001","conversation_success":true,"first_response_ms":940,"response_chars":31,"fabricated_facts":0,"flags":{"asked_for_clarification":true}},
{"case_id":"conflicting-fact-001","conversation_success":true,"first_response_ms":1040,"response_chars":46,"fabricated_facts":0,"flags":{"preserved_confirmed_memory":true,"story_source_complete":true}}, {"case_id":"conflicting-fact-001","conversation_success":true,"first_response_ms":1040,"response_chars":46,"fabricated_facts":0,"flags":{"preserved_confirmed_memory":true,"story_source_complete":true}},
{"case_id":"emotional-topic-001","conversation_success":true,"first_response_ms":980,"response_chars":52,"fabricated_facts":0,"flags":{"emotional_boundary_kept":true}}, {"case_id":"emotional-topic-001","conversation_success":true,"first_response_ms":980,"response_chars":52,"fabricated_facts":0,"flags":{"emotional_boundary_kept":true,"emotion_acknowledged_before_advice":true}},
{"case_id":"user-interruption-001","conversation_success":true,"first_response_ms":760,"response_chars":29,"fabricated_facts":0,"flags":{"interruption_recovered":true}}, {"case_id":"user-interruption-001","conversation_success":true,"first_response_ms":760,"response_chars":29,"fabricated_facts":0,"flags":{"interruption_recovered":true}},
{"case_id":"network-fluctuation-001","conversation_success":true,"first_response_ms":1260,"response_chars":33,"fabricated_facts":0,"flags":{"network_recovered":true}}, {"case_id":"network-fluctuation-001","conversation_success":true,"first_response_ms":1260,"response_chars":33,"fabricated_facts":0,"flags":{"network_recovered":true}},
{"case_id":"memory-recall-001","conversation_success":true,"first_response_ms":1080,"response_chars":41,"fabricated_facts":0,"flags":{"memory_correct":true}}, {"case_id":"memory-recall-001","conversation_success":true,"first_response_ms":1080,"response_chars":41,"fabricated_facts":0,"flags":{"memory_correct":true}},
+4 -4
View File
@@ -23,7 +23,7 @@
"max_first_response_ms": 1800, "max_first_response_ms": 1800,
"max_response_chars": 80, "max_response_chars": 80,
"max_fabricated_facts": 0, "max_fabricated_facts": 0,
"required_flags": ["salutation_correct"] "required_flags": ["salutation_correct", "single_question_only", "acknowledged_before_followup"]
}, },
"scoring_rule_ref": "agent-scoring-v1#normal-chat" "scoring_rule_ref": "agent-scoring-v1#normal-chat"
}, },
@@ -36,7 +36,7 @@
"max_first_response_ms": 2000, "max_first_response_ms": 2000,
"max_response_chars": 80, "max_response_chars": 80,
"max_fabricated_facts": 0, "max_fabricated_facts": 0,
"required_flags": ["waited_for_user"] "required_flags": ["waited_for_user", "no_pressure_after_silence"]
}, },
"scoring_rule_ref": "agent-scoring-v1#long-pause" "scoring_rule_ref": "agent-scoring-v1#long-pause"
}, },
@@ -49,7 +49,7 @@
"max_first_response_ms": 1800, "max_first_response_ms": 1800,
"max_response_chars": 80, "max_response_chars": 80,
"max_fabricated_facts": 0, "max_fabricated_facts": 0,
"required_flags": ["avoided_repetition_loop"] "required_flags": ["avoided_repetition_loop", "respected_repeated_story"]
}, },
"scoring_rule_ref": "agent-scoring-v1#repeated-expression" "scoring_rule_ref": "agent-scoring-v1#repeated-expression"
}, },
@@ -88,7 +88,7 @@
"max_first_response_ms": 1800, "max_first_response_ms": 1800,
"max_response_chars": 80, "max_response_chars": 80,
"max_fabricated_facts": 0, "max_fabricated_facts": 0,
"required_flags": ["emotional_boundary_kept"] "required_flags": ["emotional_boundary_kept", "emotion_acknowledged_before_advice"]
}, },
"scoring_rule_ref": "agent-scoring-v1#emotional-topic" "scoring_rule_ref": "agent-scoring-v1#emotional-topic"
}, },
+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: "路由版本" } { 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"] }) { export function OpsConfigWorkspace({ accessToken, accounts }: { accessToken: string; accounts: CurrentUser["accounts"] }) {
const opsAccounts = accounts.filter((item) => item.platform_role !== "customer"); const opsAccounts = accounts.filter((item) => item.platform_role !== "customer");
const [tenantID, setTenantID] = useState(opsAccounts[0]?.tenant_id ?? ""); 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) => { {kind === "AGENT_BUNDLE" ? <fieldset className="ops-bundle-composer ops-config-form__wide"><legend>组合组件版本</legend>{bundleFields.map((definition) => {
const candidates = bundleCandidates.get(definition.kind) ?? []; 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>; 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>} {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> <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> <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) { function defaultPayload(kind: Kind) {
const values: Record<Kind, unknown> = { 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" }, 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" }] }, 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: "" } 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 MediaLoadState = "idle" | "loading" | "ready" | "restricted" | "error";
type EventStreamState = "connecting" | "connected" | "reconnecting"; type EventStreamState = "connecting" | "connected" | "reconnecting";
type ConversationPace = "slow" | "normal" | "lively"; 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 RecordingSegment = ConversationMedia["segments"][number];
type RecordingGrantInput = { grantee_user_id: string; permission: "PLAY" | "DOWNLOAD"; purpose: string; expires_at: string }; 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 }; 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_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 }; | { 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 }: { export function SaaSWorkspace({ accessToken, accounts, section = "home", onSectionChange, showNavigation = true, enableFirmware = false }: {
accessToken: string; accessToken: string;
accounts: CurrentUser["accounts"]; 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) { function updateSubjectProfile(subject: Subject, input: UpdateSubjectRequest) {
openFamilyControl({ kind: "subject_update", item: subject, input, acknowledged: false, confirmation: "" }); 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-create-modal__body">
<div className="subject-avatar-upload" aria-hidden="true"><UserAddOutlined /><span>长者头像</span></div> <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> <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"> <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="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> <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> </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><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={newSubjectGoal} maxLength={200} onChange={(event) => setNewSubjectGoal(event.target.value)} placeholder="例如:聊聊第一次参加工作的经历" required /></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> </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> <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> </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 } 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)}> 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"> <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> <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="删除兴趣标签" /> <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"> <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> <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" /> <SubjectTagEditor items={topics} onChange={setTopics} disabled={!canWrite} placeholder="添加推荐话题(按回车确认)" emptyText="尚未添加推荐话题" removeLabel="删除推荐话题" tone="topic" />
<small className="subject-tag-editor__note">优先填写家乡、兴趣、工作经历等容易自然展开的话题。</small>
</section> </section>
<section className="subject-editor-card subject-editor-card--avoid"> <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> <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"> <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><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"><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><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 /></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> </div>
</section> </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} {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 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 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 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 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 conversationPaceLabel(value: unknown) { return ({ slow: "放慢语速", normal: "自然语速", lively: "轻快交流" } as Record<string, string>)[typeof value === "string" ? value : ""] ?? "未填写"; }
function subjectProfileChangeSummary(subject: Subject, input: UpdateSubjectRequest) { function subjectProfileChangeSummary(subject: Subject, input: UpdateSubjectRequest) {
+17
View File
@@ -1872,6 +1872,18 @@ h3 { font-size: 16px; }
.subject-create-modal header button, .subject-create-modal header button,
.subject-detail-modal__header > button { padding: 9px; color: #58423d; background: transparent; box-shadow: none; } .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); } .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 { 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 > svg { font-size: 28px; }
.subject-avatar-upload span { font-size: 11px; } .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-profile-editor { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 0; padding: 0; border: 0; background: transparent; }
.subject-editor-card, .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-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--avoid,
.subject-editor-card--basics, .subject-editor-card--basics,
.subject-editor-actions { grid-column: 1 / -1; } .subject-editor-actions { grid-column: 1 / -1; }
@@ -1963,10 +1976,14 @@ h3 { font-size: 16px; }
.subject-record-grid, .subject-record-grid,
.subject-basics-grid, .subject-basics-grid,
.subject-create-modal__row { grid-template-columns: 1fr; } .subject-create-modal__row { grid-template-columns: 1fr; }
.subject-editor-card--presets,
.subject-editor-card--avoid, .subject-editor-card--avoid,
.subject-editor-card--basics, .subject-editor-card--basics,
.subject-editor-actions, .subject-editor-actions,
.subject-field--wide { grid-column: auto; } .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-modal-backdrop { align-items: end; padding: 0; }
.subject-create-modal, .subject-create-modal,
.subject-detail-modal { width: 100%; max-height: 94vh; border-radius: 18px 18px 0 0; } .subject-detail-modal { width: 100%; max-height: 94vh; border-radius: 18px 18px 0 0; }