聊天
SDK 对话模块的完整 API —— send、save、generate、retry、resume、messages、edit、delete、rollback,含断线续传
SDK 提供以下对话方法,覆盖不同场景:
| 方法 | 用途 | 保存消息 | 调 AI | 扣费 |
|---|---|---|---|---|
storvia.chat.send() | 用户发消息 → AI 回复 | 自动保存用户+AI消息 | 是 | 是 |
storvia.chat.save() | 只保存一条消息 | 保存指定消息 | 否 | 否 |
storvia.chat.generate() | 只请求 AI 生成 | 可选保存AI回复 | 是 | 是 |
storvia.chat.retry() | 重试最后一条 AI 消息 | 覆盖原 AI 消息 | 是 | 是 |
storvia.chat.continue() | 续写最后一条 AI 消息(仅流式) | 覆盖原 AI 消息(追加内容) | 是 | 截断续写免费,否则扣费 |
storvia.chat.cancel() | 中断在途生成(停止按钮) | 保留已生成的半截内容 | 否 | 已产生的内容计费 |
storvia.chat.resume() | 接管在途生成并续读 | 不保存 | 否 | 否 |
storvia.chat.messages() | 获取历史消息列表 | 不保存 | 否 | 否 |
storvia.chat.edit() | 编辑指定消息文本 | 覆写原消息内容 | 否 | 否 |
storvia.chat.delete() | 删除指定消息 | 删除该条消息 | 否 | 否 |
storvia.chat.rollback() | 回溯到指定消息(仅雏菊卡用户可用) | 删除其后所有消息 + 恢复 state | 否 | 否 |
resume() 是 SDK 0.8 新增的断线续传能力——AI
回复时刷新、断网、换设备都不丢内容。详见下方
断线续传。
storvia.chat.send()
一体化对话:发送用户消息 → AI 回复 → 自动保存双方消息。适合 1v1 对话场景。
参数
storvia.chat.send({
message: string, // 必填:用户消息内容
topic?: string, // 可选:话题标识,隔离对话历史
sceneKey?: string, // 可选:场景 key,引用创作台预设的场景设定
extract?: { start, end }, // 可选:内容过滤规则,详见「内容过滤」章节
stream?: boolean, // 可选:是否流式输出(默认 false)
onChunk?: (text) => void, // stream=true 时:收到文本片段的回调
onDone?: (id, info) => void, // stream=true 时:生成完成的回调,id 为消息UUID,info.truncated 表示回复是否被截断
onState?: (delta, full) => void, // stream=true 时:AI 本轮有属性变更时触发;非流式通过返回值的 delta/full 字段获取
onThinking?: (status) => void, // stream=true 时:模型 reasoning 状态变更('start' = 开始思考,'end' = 开始输出正文),仅支持思考型模型
onCreatedCharacters?: (names) => void, // 本轮出现「真·新角色」时触发;非流式通过返回值的 createdNames 字段获取
onReconnecting?: ({ attempt }) => void, // 仅流式:生成中途断线、SDK 自动重连时触发,attempt 为第几次重连(1 起,最多 10 次)
onAborted?: ({ id, persisted }) => void, // 仅流式:生成被 cancel() 中断时触发(提供后不再触发 onDone);persisted:false 表示零内容未入库
})| 参数 | 类型 | 说明 |
|---|---|---|
message | string(必填) | 用户消息内容 |
topic | string(可选) | 话题标识,隔离对话历史 |
sceneKey | string(可选) | 场景 key,引用创作台预设的场景设定 |
extract | { start, end }(可选) | 内容过滤规则,详见「内容过滤」章节 |
stream | boolean(可选) | 是否流式输出,默认 false |
onChunk | (text) => void(可选) | 仅流式:收到文本片段的回调;text 是增量内容 |
onDone | (id, info) => void(可选) | 仅流式:生成完成的回调,id 为消息 UUID,info.truncated 表示回复是否被模型截断 |
onState | (delta, full) => void(可选) | AI 本轮有属性变更时触发;非流式通过返回值的 delta/full 字段获取 |
onThinking | (status) => void(可选) | 仅流式:模型 reasoning 状态变更('start' = 开始思考,'end' = 开始输出正文),仅思考型模型 |
onCreatedCharacters | (names) => void(可选) | 本轮出现「真·新角色」时触发;names 为新角色名字数组;非流式通过返回值的 createdNames 字段获取 |
onReconnecting | ({ attempt }) => void(可选) | 仅流式:生成中途断线、SDK 自动重连时触发;attempt 为第几次重连(1 起,最多 10 次),用于提示「正在重新连接」 |
onAborted | ({ id, persisted }) => void(可选) | 仅流式:生成被 cancel() 中断时触发(提供后不再触发 onDone);persisted: false 表示零内容未入库,需把玩家输入放回输入框 |
非流式用法
const reply = await storvia.chat.send({
message: "你好",
topic: "dm_npc1",
sceneKey: "dm_scene",
});
console.log(reply.id); // 消息 UUID
console.log(reply.content); // AI 回复内容非流式响应格式
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"content": "干嘛,这么晚找我?",
"delta": { ... },
"full": { ... },
"truncated": false
}truncated恒返回:true表示这条 AI 回复被模型截断(没写完),可调chat.continue()免费补完;据此决定要不要显示「继续」按钮delta和full仅在 AI 本轮产生了属性变更时才出现;无变更的回合这两个字段缺省- 本轮出现「真·新角色」时额外携带
createdNames(新角色名字数组),无新角色时缺省 - 生成被用户中断但已有内容入库时额外携带
aborted: true(零内容中断则直接抛StorviaError,code:GENERATION_ABORTED)
delta — 本轮的增量
只包含 AI 这一轮实际改动过的字段,便于快速读取发生了什么变化:
{
"world": {
"time": "晚上 22:00",
"location": "秦照野的直播间"
},
"player": {
"经验": 180
},
"characters": [
{
"id": "秦照野",
"op": "update",
"attrs": { "好感度": 50, "心情": "开心" }
},
{
"id": "路人甲",
"op": "add",
"attrs": { "好感度": 0 }
}
],
"items": [
{ "name": "玫瑰花", "op": "add", "count": 3 },
{ "name": "旧钥匙", "op": "remove" }
],
"relations": [
{
"from": "玩家",
"to": "秦照野",
"op": "update",
"好感度": 50,
"关系阶段": "朋友"
}
],
"locations": [
{
"name": "秘密基地",
"op": "add",
"region": "城东",
"description": "隐蔽的据点"
}
]
}未发生变更的字段(world、player、对应数组)在 delta 里为 null 或空数组,不会出现。
full — 合并后的完整状态快照
full 是当前会话所有模块合并后的完整快照,结构与 storvia.state.get() 的返回值一致,包含 current / previous / dormant 三个字段。详见状态模块文档。
通常推荐根据业务需要选择使用哪个字段: -
只需要知道「变了什么」(如触发动画、播放音效)→ 读 delta,结构简洁 -
需要渲染完整 UI(如角色属性面板、物品栏)→ 读
full.current,直接映射即可 - 不需要每轮自动接收完整状态时 → 不读
full,需要时再调用 storvia.state.get() 主动拉取
流式用法
await storvia.chat.send({
message: "你好",
topic: "dm_npc1",
sceneKey: "dm_scene",
stream: true,
onChunk: (text) => {
// 每收到一个文本片段就会调用
// text 是增量内容,不是完整内容
chatBubble.textContent += text;
},
onDone: (id) => {
// 流式输出完成
console.log("消息ID:", id);
},
onState: (delta, full) => {
// AI 本轮有属性变更时触发(仅在确实改动了世界/玩家/角色等字段时)
// delta 是本轮的变更内容(增量),full 是合并后的完整快照
console.log("好感度变了:", delta.characters);
updateUI(full.current);
},
});onState
是可选回调;不需要监听变更时不传即可。回调在整轮生成完成后一次性推送(即
onChunk 全部触发完之后),所以可以在 onState 里直接用最终内容做联动。
流式 SSE 事件格式
流式模式下服务端通过 SSE 推送以下几类事件:
data: {"type":"start"}
data: {"type":"thinking","status":"start"}
data: {"type":"thinking","status":"end"}
data: {"content":"干"}
data: {"content":"嘛"}
data: {"type":"state_updating","updating":true}
data: {"type":"state_updating","updating":false}
data: {"type":"state","delta":{...},"full":{...}}
data: {"type":"done","id":"550e8400-...","truncated":false}
data: [DONE]| 事件类型 | 说明 |
|---|---|
start | 流开始。只有 {"type":"start"},不携带其他字段 |
thinking | 思考型模型 reasoning 状态变更(status:'start' = 开始思考、status:'end' = 开始输出正文),通过 onThinking 回调传递;非思考模型不会出现此事件 |
(无 type 字段) | 文本片段,形如 {"content":"干"}——注意没有 type 字段,SDK 靠 content 字段识别,通过 onChunk 回调传递给开发者 |
state_updating | AI 正在写 state 标签(updating:true)/ 写完(updating:false) |
state | state 解析完成,携带本轮的增量(delta)和完整快照(full);本轮出现「真·新角色」时附带 createdNames(对应 onCreatedCharacters 回调) |
done | 正常终态:id 为消息 UUID,truncated 表示回复是否被模型截断,通过 onDone(id, info) 回调传递。done 之后紧跟 [DONE] 哨兵 |
aborted | 中断终态(被 cancel() 停止,不是失败):id 为消息 UUID(persisted:false 时为空串),persisted 表示半截内容是否已入库,truncated 恒为 false。通过 onAborted 回调传递,同样紧跟 [DONE] 哨兵 |
error | 错误终态:携带 code 与 message,SDK 收到后抛出对应 code 的 StorviaError |
流式模式下 send() 不返回 ChatResponse,AI 内容通过 onChunk
回调逐步传递。state 事件在整轮生成完成后一次性推送。
storvia.chat.save()
只保存消息,不调 AI。用于先保存用户消息,再分别请求不同 角色回复的场景(如群聊)。
参数
storvia.chat.save({
role: 'user' | 'assistant', // 必填:消息角色
content: string, // 必填:消息内容
topic?: string, // 可选:话题标识
})| 参数 | 类型 | 说明 |
|---|---|---|
role | 'user' | 'assistant'(必填) | 消息角色 |
content | string(必填) | 消息内容 |
topic | string(可选) | 话题标识 |
返回值
{
id: string;
} // 保存的消息 UUID用法
// 保存一条用户消息到群聊话题
await storvia.chat.save({
role: "user",
content: "大家好!",
topic: "group_chat",
});
// 也可以保存 AI 消息(比如本地生成的内容)
await storvia.chat.save({
role: "assistant",
content: "欢迎回来~",
topic: "group_chat",
});save() 不会调用 AI、不会扣费。它只是把消息写入对应的 topic 下,后续
send() 或 generate() 调用同一 topic 时,AI 能看到这条消息。
save() + send() 会导致用户消息被写入两次:save() 写一条,send()
内部还会再写一条。需要用户消息 + AI 回复的场景请直接用
send();需要先保存用户消息再分别请求多个角色回复的场景,使用 save() +
generate()。
storvia.chat.generate()
只请求 AI 生成,不需要用户消息。AI 基于该 topic 的历史消息 + 场景上下文生成回复。
参数
storvia.chat.generate({
topic?: string, // 可选:话题标识
sceneKey?: string, // 可选:场景 key
extract?: { start, end }, // 可选:内容过滤规则,详见「内容过滤」章节
stream?: boolean, // 可选:是否流式(默认 false)
onChunk?: (text) => void,
onDone?: (id, info) => void,
onState?: (delta, full) => void,
onThinking?: (status) => void, // 同 send(),仅思考型模型触发
onCreatedCharacters?: (names) => void, // 同 send():本轮出现「真·新角色」时触发
onReconnecting?: ({ attempt }) => void, // 同 send():断线自动重连时触发
onAborted?: ({ id, persisted }) => void, // 同 send():被 cancel() 中断时触发(提供后不再触发 onDone)
})| 参数 | 类型 | 说明 |
|---|---|---|
topic | string(可选) | 话题标识 |
sceneKey | string(可选) | 场景 key,引用创作台预设的场景设定 |
extract | { start, end }(可选) | 内容过滤规则,详见「内容过滤」章节 |
stream | boolean(可选) | 是否流式输出,默认 false |
onChunk | (text) => void(可选) | 仅流式:收到文本片段的回调;text 是增量内容 |
onDone | (id, info) => void(可选) | 仅流式:生成完成的回调,id 为消息 UUID,info.truncated 表示回复是否被模型截断 |
onState | (delta, full) => void(可选) | AI 本轮有属性变更时触发;非流式通过返回值的 delta/full 字段获取 |
onThinking | (status) => void(可选) | 仅流式:模型 reasoning 状态变更('start' = 开始思考,'end' = 开始输出正文),仅思考型模型 |
onCreatedCharacters | (names) => void(可选) | 本轮出现「真·新角色」时触发;names 为新角色名字数组;非流式通过返回值的 createdNames 字段获取 |
onReconnecting | ({ attempt }) => void(可选) | 仅流式:生成中途断线、SDK 自动重连时触发;attempt 为第几次重连(1 起,最多 10 次),用于提示「正在重新连接」 |
onAborted | ({ id, persisted }) => void(可选) | 仅流式:生成被 cancel() 中断时触发(提供后不再触发 onDone);persisted: false 表示零内容未入库,需把玩家输入放回输入框 |
返回值
与 send() 相同结构(id / content / delta / full / truncated)。流式事件格式也完全一致。
用法
// 角色在群聊中回复(AI 能看到 group_chat 的历史,包括之前 save 的消息)
const reply = await storvia.chat.generate({
topic: "group_chat",
sceneKey: "group_chat_scene",
});
console.log(reply.content); // "哟,稀客"storvia.chat.retry()
重试最后一条 AI 消息:覆盖原 AI 消息重新生成。适用于回复出错、不满意或中断后恢复的场景。
参数
storvia.chat.retry({
topic?: string, // 可选:重试指定 topic 下的最后一条 AI 消息
sceneKey?: string, // 可选:场景 key
extract?: { start, end }, // 可选:内容过滤规则,详见「内容过滤」章节
stream?: boolean, // 可选:是否流式(默认 false)
onChunk?: (text) => void,
onDone?: (id, info) => void,
onState?: (delta, full) => void,
onThinking?: (status) => void, // 同 send(),仅思考型模型触发
onCreatedCharacters?: (names) => void, // 同 send():本轮出现「真·新角色」时触发
onReconnecting?: ({ attempt }) => void, // 同 send():断线自动重连时触发
onAborted?: ({ id, persisted }) => void, // 同 send():被 cancel() 中断时触发(提供后不再触发 onDone)
})| 参数 | 类型 | 说明 |
|---|---|---|
topic | string(可选) | 重试该 topic 下最后一条 AI 消息 |
sceneKey | string(可选) | 场景 key,引用创作台预设的场景设定 |
extract | { start, end }(可选) | 内容过滤规则,详见「内容过滤」章节 |
stream | boolean(可选) | 是否流式输出,默认 false |
onChunk | (text) => void(可选) | 仅流式:收到文本片段的回调;text 是增量内容 |
onDone | (id, info) => void(可选) | 仅流式:生成完成的回调,id 为消息 UUID,info.truncated 表示回复是否被模型截断 |
onState | (delta, full) => void(可选) | AI 本轮有属性变更时触发;非流式通过返回值的 delta/full 字段获取 |
onThinking | (status) => void(可选) | 仅流式:模型 reasoning 状态变更('start' = 开始思考,'end' = 开始输出正文),仅思考型模型 |
onCreatedCharacters | (names) => void(可选) | 本轮出现「真·新角色」时触发;names 为新角色名字数组;非流式通过返回值的 createdNames 字段获取 |
onReconnecting | ({ attempt }) => void(可选) | 仅流式:生成中途断线、SDK 自动重连时触发;attempt 为第几次重连(1 起,最多 10 次),用于提示「正在重新连接」 |
onAborted | ({ id, persisted }) => void(可选) | 仅流式:生成被 cancel() 中断时触发(提供后不再触发 onDone);persisted: false 表示零内容未入库,需把玩家输入放回输入框 |
返回值
与 send() 相同结构(id / content / delta / full / truncated)。流式事件格式也完全一致。
非流式用法
const reply = await storvia.chat.retry({
topic: "dm_npc1",
sceneKey: "dm_scene",
});
console.log(reply.content); // 新生成的回复流式用法
await storvia.chat.retry({
topic: "dm_npc1",
sceneKey: "dm_scene",
stream: true,
onChunk: (text) => {
chatBubble.textContent += text;
},
onDone: (id) => {
console.log("重新生成完成,消息ID:", id);
},
});retry() 会覆盖该 topic 下最后一条 AI 消息(保留原
UUID,替换内容)。用户消息不受影响,AI 会基于原用户消息重新生成。
该方法在预览模式下无法正常使用,需要发布后在 Storvia 平台测试。
预览对话不会写入历史消息,没有"最后一条 AI 消息"可供 retry 操作;调用会抛出
StorviaError(code: NOT_SUPPORTED_IN_PREVIEW)。
storvia.chat.continue()(SDK 0.9 起)
续写最后一条 AI 消息:把没写完的回复补完。与 retry() 不同——retry() 是推翻重写,continue() 是接着往下写,原内容保留。
AI 回复有时会被截断,留下一句写到一半的话。这时用 continue() 补完。
参数
storvia.chat.continue({
topic?: string, // 可选:续写该 topic 下最新的 AI 消息;不传 = 续写整个会话里最新的那条 AI 消息
sceneKey?: string, // 可选:场景 key;续写场景消息时须带上原样 key,详见「场景设定」章节
extract?: { start, end }, // 可选:内容过滤规则;续写 extract 模式的消息时须带上原样规则,详见「内容过滤」章节
onChunk?: (text) => void, // 收到文本片段的回调(增量,不是完整内容)
onDone?: (id, info) => void, // 续写完成的回调;id 为消息 UUID,info.truncated 表示补完后是否仍被截断
onState?: (delta, full) => void, // AI 本轮有属性变更时触发;delta 是增量,full 是合并后的完整快照
onThinking?: (status) => void, // 模型 reasoning 状态变更('start' = 开始思考,'end' = 开始输出正文),仅思考型模型
onReconnecting?: ({ attempt }) => void, // 生成中途断线、SDK 自动重连时触发;attempt 为第几次重连(1 起,最多 10 次),用于提示「正在重新连接」
onAborted?: ({ id, persisted }) => void, // 被停止按钮中断时触发(提供后不再触发 onDone);persisted:false 表示零内容未入库,需把玩家输入放回输入框
})| 参数 | 类型 | 说明 |
|---|---|---|
topic | string(可选) | 续写该 topic 下最新的 AI 消息;不传 = 续写整个会话里最新的那条 AI 消息 |
sceneKey | string(可选) | 场景 key;被续写的消息是带场景发出的,就须带上原样 key,详见「场景设定」章节 |
extract | { start, end }(可选) | 内容过滤规则;续写 extract 模式的消息时须带上原样规则,详见「内容过滤」章节 |
onChunk | (text) => void(可选) | 收到文本片段的回调;text 是增量内容,不是完整内容 |
onDone | (id, info) => void(可选) | 续写完成的回调;id 为消息 UUID,info.truncated 表示补完后是否仍被截断(还没写完,可再次续写) |
onState | (delta, full) => void(可选) | AI 本轮有属性变更时触发;delta 是增量,full 是合并后的完整快照 |
onThinking | (status) => void(可选) | 模型 reasoning 状态变更('start' = 开始思考,'end' = 开始输出正文),仅思考型模型触发 |
onReconnecting | ({ attempt }) => void(可选) | 生成中途断线、SDK 自动重连时触发;attempt 为第几次重连(1 起,最多 10 次),用于提示「正在重新连接」 |
onAborted | ({ id, persisted }) => void(可选) | 被停止按钮中断时触发(提供后不再触发 onDone);persisted: false 表示零内容未入库,需把玩家输入放回输入框 |
什么时候该显示「继续」按钮
看 truncated——它告诉你这条回复是不是被模型截断了。两个地方能拿到:
// 1. 生成刚结束时:onDone 的第二个参数
await storvia.chat.send({
message: playerInput,
stream: true,
onChunk: (text) => appendToBubble(text),
onDone: (id, info) => {
if (info?.truncated) showContinueButton(id); // 没写完 → 给个「继续」按钮
},
});
// 2. 刷新 / 重进游戏后:chat.messages() 每条消息都带 truncated
const { data } = await storvia.chat.messages({ order: "desc", limit: 20 });
for (const m of data) {
renderMessage(m);
if (m.role === "assistant" && m.truncated) showContinueButton(m.uuid);
}用法
await storvia.chat.continue({
onChunk: (text) => {
lastBubble.textContent += text; // 追加到原气泡,不是新建一条
},
onDone: (id, info) => {
hideContinueButton();
if (info?.truncated) showContinueButton(id); // 补完后又截断了 → 可以再续
},
});原回复被截断时续写免费——不扣费、也不检查余额。原回复没被截断时你依然可以调
continue() 让 AI
接着往下写,但那属于正常生成,会扣费。所以「继续」按钮建议只在
truncated: true 时显示。
continue() 是覆盖式更新原消息(保留原 UUID,内容变成「原文 +
续写」),不会新增一条消息。所以 UI
上要把新内容追加到原气泡,别新建气泡。
预览模式下 continue() 静默返回、不做任何事。
预览对话不写入历史消息,没有"最后一条 AI 消息"可供续写。需要发布后在 Storvia
平台测试。
storvia.chat.cancel()(SDK 0.9 起)
中断在途生成(停止按钮):让 AI 立刻停下,已经写出来的半截内容保留。
参数与返回值
const result = await storvia.chat.cancel({
topic?: string, // 可选:停哪条支线的生成;缺省 = 主线
});
// result: { active: boolean, generationId?: string }参数
| 参数 | 类型 | 说明 |
|---|---|---|
topic | string(可选) | 停哪条支线的在途生成;缺省 = 主线 |
返回值
| 字段 | 说明 |
|---|---|
active | 是否真的有在途生成被停下。false = 生成已经跑完了,取消来晚了(竞态) |
generationId | 被取消的生成 id;active: false 时为 undefined |
用法
停止按钮的完整写法——关键是别自己去停本地的流,等 onAborted 统一收尾:
// 发起生成时挂上 onAborted
await storvia.chat.send({
message: playerInput,
stream: true,
onChunk: (text) => appendToBubble(text),
onDone: (id, info) => resetUI(),
onAborted: ({ persisted }) => {
resetUI();
if (!persisted) {
inputBox.value = playerInput; // 零内容中断:把玩家的话放回输入框
}
},
});
// 停止按钮
stopBtn.onclick = async () => {
stopBtn.disabled = true;
const { active } = await storvia.chat.cancel();
if (!active) {
// 生成已经跑完了,不会再有 aborted 事件,正常的 done 已经在流里
stopBtn.disabled = false;
}
// active: true → 什么都不用做,等 onAborted 收尾
};onAborted 收到什么
| 字段 | 说明 |
|---|---|
id | AI 消息 uuid;persisted: false 时为空串 |
persisted | 半截内容是否已入库。false = 还没吐出任何内容就停了,未入库也未扣费 |
persisted: false 时建议把玩家刚发的输入放回输入框。
这种情况下玩家的消息没有入库,服务端无法代为恢复——你不放回去,玩家刚打的那段话就凭空消失了。
中断只要已经产生了内容就会计费:按量计费的模型按实际产生的 token
收费,按次计费的模型扣一整次。只有还没吐出任何内容就停(persisted: false)才不计费。
预览模式没有在途生成,cancel() 恒返回 { active: false },不打后端。
断线续传(SDK 0.8 起)
流式生成跑在服务端,AI 输出过程与你的网络连接解耦:内容一边生成一边写进服务端,不依赖发起请求的那条连接活着。所以 AI 回复时刷新页面、切后台、关掉 App / 浏览器、断网或弱网抖动,都不会让回复中断或丢失。SDK 在两个层面利用这一点:
- 自动重连 —— 流式
send/generate/retry进行中若连接断开,SDK 自动从断点续读、接着把剩下的内容喂给你的onChunk,整轮无感。 - 接管在途 —— 刷新 / 换设备后,用
chat.resume()实时接上正在输出的内容;若接管时已无在途生成,resume()直接静默返回,无需先查询。
续传只对持久会话(正式发布的游戏)有效。预览模式没有真实会话、不写库,resume()
直接静默返回。
自动重连(无需额外代码)
流式调用中途断网时,SDK 自动经服务端续读重连,默认就开着,不用你做任何事。只有想给用户一个「正在重新连接…」提示时,才传 onReconnecting:
await storvia.chat.send({
message: "你好",
topic: "dm_npc1",
stream: true,
onChunk: (text) => {
chatBubble.textContent += text;
},
onDone: (id) => {
hideReconnectingTip();
},
onReconnecting: ({ attempt }) => {
// 每次发起重连前触发,attempt 从 1 递增(最多 10 次)
showReconnectingTip(`连接中断,正在重新连接… (${attempt}/10)`);
},
});- 退避重连,最多 10 次;期间生成在服务端继续跑,重连成功后从断点接着推
onChunk,不会重复也不会丢字。 - 10 次都失败才抛
StorviaError(code:STREAM_INTERRUPTED)。 - 0.8.2 起:重连后发现回复已在服务端完成时,保证
onDone一定触发——极端情况下id可能为空字符串,此时如需最终内容用chat.messages()拉取最新消息即可。
storvia.chat.resume() —— 接管在途生成
storvia.chat.resume({
topic?: string,
onChunk?: (text) => void,
onDone?: (id, info) => void,
onState?: (delta, full) => void,
onThinking?: (status) => void,
onReconnecting?: ({ attempt }) => void,
onAborted?: ({ id, persisted }) => void, // SDK 0.9:接管的生成被中断
}): Promise<void>| 参数 | 类型 | 说明 |
|---|---|---|
topic | string(可选) | 接管该 topic 下的在途生成;不传 = 主线 |
onChunk | (text) => void(可选) | 收到文本片段的回调;text 是增量内容 |
onDone | (id, info) => void(可选) | 生成完成的回调;id 为消息 UUID,info.truncated 表示是否被截断 |
onState | (delta, full) => void(可选) | AI 本轮有属性变更时触发;delta 是增量,full 是合并后的完整快照 |
onThinking | (status) => void(可选) | 模型 reasoning 状态变更('start' = 开始思考,'end' = 开始输出正文),仅思考型模型 |
onReconnecting | ({ attempt }) => void(可选) | 续读中途断线、SDK 自动重连时触发;attempt 为第几次重连(1 起,最多 10 次) |
onAborted | ({ id, persisted }) => void(可选) | 接管的生成被中断时触发(提供后不再触发 onDone);persisted: false 表示零内容未入库 |
进入对话 / 刷新页面 / 切换设备后调用它,从头实时接上正在输出的内容(回调语义与 send 完全一致)。续读中途再断会继续自动重连;若服务端已无在途生成(生成在你接管前就跑完了)则静默返回——此时正文已落库,用 chat.messages() 取最终结果即可。
「静默返回」指 onChunk / onDone / onAborted
一个都不会触发。所以别在调用前无条件创建占位气泡——没有在途生成时它不会被任何回调清理,会一直留在界面上(见下方典型用法的惰性建气泡写法)。
不需要先查询「有没有在途生成」再决定是否接管:resume()
内部已处理「无在途」的情况(静默返回),进对话时直接调用即可。
典型用法:进对话自动接管
把 resume() 放在渲染完历史消息后跑一次,就能覆盖刷新和换设备——有在途就实时接上,没有就静默跳过:
async function enterConversation(topic) {
// 1. 先渲染历史消息
const history = await storvia.chat.messages({ order: "desc", topic });
renderMessages(history.data);
// 2. 直接接管在途生成:有就实时续读,没有则静默返回
// ⚠️ 气泡要「等第一个 chunk 到了再建」,别无条件先建:
// 没有在途时 onChunk / onDone 都不会触发,提前建的空气泡会永远留在界面上。
let bubble = null;
await storvia.chat.resume({
topic,
onChunk: (text) => {
if (!bubble) bubble = appendStreamingBubble(); // 首个 chunk 到达才建气泡
bubble.textContent += text;
},
onDone: () => {
if (bubble) markBubbleDone(bubble);
},
});
}多设备接力:A 设备 send() 发出消息后,B 设备打开同一会话调
resume(),会实时看到 AI
正在输出的同一段内容——因为生成和具体设备无关,谁都能接管同一条在途流。
预设(SDK 0.9.2 起)
玩家可以在 Storvia 的模型选择器里挑「预设」——一套预设对应一种文风。目前平台内置两种:
| 预设 | 说明 |
|---|---|
| 平时叙事 | 文风自然均衡,适用于大多数题材与剧情 |
| 剧情 | 更强的叙事张力与场景描写,偏小说笔触 |
这项能力跟模型选择一样由平台统一管理,接入方无需写任何代码:玩家在 Storvia 里选好之后,你调用的 send / generate / retry / continue(含预览)会自动使用玩家当前的选择。把依赖升级到 SDK 0.9.2 及以上,重新构建并重新上传资源包即可生效。
预设是玩家的选择,SDK 不提供在代码里覆盖它的参数——这样能保证玩家的选择始终生效,不会被游戏逻辑意外改写。
替代了原来的「剧情模式」开关(0.8.3–0.9.1)。 原来那个开关已经下线,「剧情」现在是预设列表里的一项。 升级后无需改动代码,玩家侧从「开关」变成了「在列表里选一个」。
预设列表由平台维护,可能随时增减,因此不要在游戏里硬编码预设名或做分支判断。
storvia.chat.messages()
获取历史消息列表:支持按 topic 过滤、游标分页,两种遍历方向(从新到旧 / 从旧到新)。适合渲染聊天历史消息、做消息回放。
参数
storvia.chat.messages({
order: 'asc' | 'desc', // 必填:asc=从旧到新(历史展示),desc=从新到旧(聊天框)
limit?: number, // 可选:单次最大返回条数,1-50,默认 20
topic?: string, // 可选:按 topic 过滤,不传则返回全部
cursor?: { // 可选:翻页游标,首次不传
uuid: string,
createdAt: string,
},
})| 参数 | 类型 | 说明 |
|---|---|---|
order | 'asc' | 'desc'(必填) | asc = 从旧到新(历史展示);desc = 从新到旧(聊天框) |
limit | number(可选) | 单次最大返回条数,1–50,默认 20 |
topic | string(可选) | 按 topic 过滤;不传则返回全部 |
cursor | { uuid, createdAt }(可选) | 翻页游标,首次不传;取上一页返回的 nextCursor |
返回值
{
data: Array<{
uuid: string,
role: 'user' | 'assistant',
content: string,
topic: string | null,
status: string,
model: string | null,
createdAt: string,
truncated: boolean, // SDK 0.9 起:能否免费续写。AI 回复被截断没写完=true;正常写完 / user 消息 / 用户主动停止=false
}>,
hasMore: boolean, // 是否还有更多消息
nextCursor: { uuid, createdAt } | null, // 下一页游标;直接传给下次调用的 cursor 字段
}truncated: true 表示这条 AI 回复被模型截断了(没写完)。刷新 /
重进游戏后据此决定要不要给这条消息显示「继续」按钮——调
chat.continue() 补完是免费的。
返回的 data 数组始终按时间正序(旧 → 新),不受 order 影响。order
只决定首次加载的起点和翻页方向。
场景 A:游戏内聊天框(从新到旧,向上滚动加载更旧)
// 首次加载:最新 20 条
let result = await storvia.chat.messages({
order: "desc",
topic: "dm_npc1",
});
renderMessages(result.data);
// 用户向上滚动,加载更旧的消息
if (result.hasMore) {
result = await storvia.chat.messages({
order: "desc",
topic: "dm_npc1",
cursor: result.nextCursor,
});
prependMessages(result.data);
}场景 B:历史消息展示页(从旧到新,向下滚动加载更新)
// 首次加载:最旧 20 条
let result = await storvia.chat.messages({
order: "asc",
topic: "dm_npc1",
});
renderMessages(result.data);
// 用户向下滚动,加载更新的消息
if (result.hasMore) {
result = await storvia.chat.messages({
order: "asc",
topic: "dm_npc1",
cursor: result.nextCursor,
});
appendMessages(result.data);
}预览模式下 messages() 始终返回 { data: [], hasMore: false, nextCursor: null },因为预览对话不会写入历史消息。
storvia.chat.edit()
编辑指定消息的文本内容:覆写已存在消息(user 或 assistant 都可)的 content 字段。不重新调用 AI、不重算 state、不扣费。适用场景:
- 用户输入了错别字想改正
- 修订 AI 回复中的不当措辞,再继续后续对话
- 在游戏里实现「悔棋」类编辑型 UI
调用签名
storvia.chat.edit({
uuid: string;
content: string;
}): Promise<{
uuid: string;
role: string;
content: string;
updatedAt: string;
}>;| 参数 | 类型 | 说明 |
|---|---|---|
uuid | string(必填) | 要编辑的消息 UUID(来自 messages()) |
content | string(必填) | 新内容;trim 后不能为空 |
用法
// 1. 通过 messages() 拿到目标消息的 uuid
const list = await storvia.chat.messages({ order: "desc", limit: 10 });
const target = list.data[0];
// 2. 编辑文本
const updated = await storvia.chat.edit({
uuid: target.uuid,
content: "修改后的内容",
});
console.log(updated.content); // "修改后的内容"
console.log(updated.updatedAt); // 新的 ISO 时间戳行为说明
- 范围:只更新
content字段;role/topic/status/ 关联的状态变更(state)均保持原样 - 不重算 state:编辑 AI 消息不会重新解析
<state>标签,状态系统仍以原回复时记录的为准
错误码
| 错误码 | 含义 |
|---|---|
INVALID_REQUEST | uuid 格式无效或 content 为空 |
CONTENT_VIOLATION | user 消息违禁词命中 |
MESSAGE_NOT_FOUND | 消息不存在或无权编辑 |
该方法在预览模式下无法正常使用,需要发布后在 Storvia 平台测试。
预览对话不会写入历史消息,没有 uuid 可供编辑;调用会抛出
StorviaError(code: NOT_SUPPORTED_IN_PREVIEW)。
storvia.chat.delete()
删除指定消息:移除已存在的消息(user 或 assistant 都可)。不重算 state、不回溯后续消息、不调 AI、不扣费。适用场景:
- 在游戏里实现「撤回」按钮
- 清理用户误发的消息
- 配合编辑流程做「弃稿重写」
调用签名
storvia.chat.delete({
uuid: string;
}): Promise<{
uuid: string;
}>;| 参数 | 类型 | 说明 |
|---|---|---|
uuid | string(必填) | 要删除的消息 UUID(来自 messages()) |
用法
// 1. 通过 messages() 拿到目标消息的 uuid
const list = await storvia.chat.messages({ order: "desc", limit: 10 });
const target = list.data[0];
// 2. 删除该消息
const { uuid } = await storvia.chat.delete({ uuid: target.uuid });
console.log("已删除:", uuid);行为说明
- 不可恢复:消息会从历史记录中彻底移除,无法撤销
- 不级联:只删除该条消息本身,不会自动删除之后的其他消息(如需级联回溯请在自家业务层多次调用)
- 不重算 state:删除 AI 消息不会回滚关联的状态变更,状态系统保持原值
错误码
| 错误码 | 含义 |
|---|---|
INVALID_REQUEST | uuid 格式无效 |
MESSAGE_NOT_FOUND | 消息不存在或无权删除 |
该方法在预览模式下无法正常使用,需要发布后在 Storvia 平台测试。
预览对话不会写入历史消息,没有 uuid 可供删除;调用会抛出
StorviaError(code: NOT_SUPPORTED_IN_PREVIEW)。
storvia.chat.rollback()
回溯到指定消息(仅雏菊卡用户可用):删除目标消息之后的所有消息(目标消息本身保留),并把会话的 game_state 从该消息保存时的快照恢复回来。不调 AI、不扣费。适用场景:
- 让玩家「悔棋」回到某一关键剧情节点重新发展
- 错误回复 / 状态紊乱后整体回滚
- 长会话清场(在某个里程碑消息处一键截断)
该方法仅限「雏菊卡」(sub_tier1)订阅用户调用。非订阅用户会收到 StorviaError(code: DAISY_CARD_REQUIRED,HTTP 403)。
SDK 会强制弹出引导弹窗(与「花园币不足」同款样式),点「去开通」会通过 bus 通知宿主跳转钱包页 —— 与 INSUFFICIENT_CREDITS 体验完全一致。该弹窗是平台统一的钱包跳转 CTA,不受 setToastEnabled(false) 控制、无法被作者抑制。作者可以正常 catch (err) 后继续业务流程,但不要试图覆盖默认引导。
调用签名
storvia.chat.rollback({
uuid: string;
}): Promise<{
deletedCount: number;
deletedUuids: string[];
restoredGameState: { current: unknown; dormant: unknown } | null;
}>;| 参数 | 类型 | 说明 |
|---|---|---|
uuid | string(必填) | 回溯到的目标消息 UUID;该消息保留,其后所有消息被删除并恢复 state |
用法
// 1. 通过 messages() 拿到目标消息的 uuid
const list = await storvia.chat.messages({ order: "asc", limit: 100 });
const checkpoint = list.data[10]; // 想回到第 11 条消息
// 2. 回溯
try {
const r = await storvia.chat.rollback({ uuid: checkpoint.uuid });
console.log(`已删除 ${r.deletedCount} 条后续消息`);
if (r.restoredGameState) {
console.log("状态已从快照恢复:", r.restoredGameState);
}
} catch (err) {
if (err.code === "DAISY_CARD_REQUIRED") {
showUpgradePrompt("回溯功能仅限雏菊卡会员使用");
} else if (err.code === "MESSAGE_TOO_OLD") {
showToast("该消息已超过 30 天,无法回溯");
} else {
throw err;
}
}行为说明
- 目标消息保留:仅删除
created_at > 目标消息.created_at的所有消息 - 不可恢复:被删除的消息会从历史记录中彻底移除,无法撤销
- 30 天上限:仅可回溯
created_at在 30 天内的消息 - state 恢复:若目标消息存在
game_state_snapshot,会用其覆写会话当前的current/dormant,并把previous清空;若快照不存在则跳过(restoredGameState返回null)
错误码
| 错误码 | HTTP | 含义 |
|---|---|---|
INVALID_REQUEST | 400 | uuid 格式无效 |
MESSAGE_TOO_OLD | 400 | 目标消息超过 30 天 |
NO_MESSAGES_TO_DELETE | 400 | 目标消息之后没有消息可删 |
DAISY_CARD_REQUIRED | 403 | 当前用户没有有效的雏菊卡订阅 |
MESSAGE_NOT_FOUND | 404 | 消息不存在或无权回溯 |
该方法在预览模式下无法正常使用,需要发布后在 Storvia 平台测试。
预览对话不会写入历史消息,没有 uuid 可供回溯;调用会抛出
StorviaError(code: NOT_SUPPORTED_IN_PREVIEW)。
群聊场景完整示例
群聊是 save() + generate() 配合使用的典型场景:
// 1. 玩家发消息 → 保存到 group_chat topic
await storvia.chat.save({
role: "user",
content: "大家晚上好!",
topic: "group_chat",
});
// 2. 请求秦照野回复
// AI 能看到 group_chat 历史中的「大家晚上好!」
const reply1 = await storvia.chat.generate({
topic: "group_chat",
sceneKey: "group_chat_scene",
});
showMessage("秦照野", reply1.content);
// 3. 请求宋亦回复
// AI 能看到玩家的消息 + 秦照野的回复
const reply2 = await storvia.chat.generate({
topic: "group_chat",
sceneKey: "group_chat_scene",
});
showMessage("宋亦", reply2.content);每个 generate() 调用时,AI 都能看到该 topic
下最新的历史消息(包括前面刚保存的)。所以宋亦回复时,能看到玩家说了什么、秦照野回了什么,从而产生自然的群聊效果。
Topic
Topic 决定了每次 AI 调用时加载哪些历史消息:
- 传入 topic 字符串 — 只加载该 topic 下的历史消息作为上下文
- 不传或传
null— 加载该会话下的全部历史消息
持续对话
需要 AI 记住上下文的场景,使用固定的 topic 字符串。同一 topic 下的消息会持续积累,每次调用时 AI 都能看到完整历史。
// 和秦照野的私聊,始终传同一个 topic
await storvia.chat.send({ message: "你好", topic: "dm_qin_zhaoye" });
await storvia.chat.send({ message: "昨天的事情…", topic: "dm_qin_zhaoye" }); // AI 能看到上一条一次性对话
不需要参考任何历史消息的场景(如弹幕生成、系统通知、独立事件),不需要给每次调用都生成随机 topic。
反例:不要用 Date.now() 之类的方式生成随机 topic
// ❌ 错误用法:每次都创建新 topic
const reply = await storvia.chat.generate({
topic: `danmaku_${Date.now()}`,
sceneKey: "danmaku_scene",
});这样每次调用都会在历史消息里写入一条永远不会被复用的消息,长期累积会产生大量垃圾消息,且无法清理。
推荐做法:一次性生成、且可能会被多次重复触发的场景(例如玩家点"换一条"重新生成弹幕、系统反复刷新事件文案),用固定 topic + chat.retry()。retry() 会覆盖该 topic 下最后一条 AI 消息,既不会产生新数据,也不会让历史无限累积。
// ✅ 推荐:固定 topic,首次用 generate,后续重复生成走 retry
const first = await storvia.chat.generate({
topic: "danmaku",
sceneKey: "danmaku_scene",
});
// 用户点击"换一条" —— 覆盖上一条,不产生新消息
const next = await storvia.chat.retry({
topic: "danmaku",
sceneKey: "danmaku_scene",
});常用 Topic 命名参考
| 场景 | topic 示例 |
|---|---|
| 角色私聊 | dm_qin_zhaoye |
| 群聊 | group_chat |
| 直播互动 | live_qin_zhaoye |
| PK 对战 | pk_qin_zhaoye |
| 角色主动发言 | proactive_npc1 |
| 一次性生成(可重复触发) | danmaku / system_notify(配合 retry()) |
场景上下文(Scene Key)
Scene Key 是在创作台中预设的场景设定,用于告诉 AI 当前的场景信息。SDK 调用时只需传入对应的 key。
在创作台中配置
在创作台的「场景上下文」区域,添加 key-value 对:
| Key | 值(场景设定) |
|---|---|
dm_scene | 你正在和玩家私聊,语气亲密自然 |
live_scene | 你正在直播,需要回应观众的互动和弹幕 |
group_chat_scene | 你在主播群聊天,保持角色性格,注意其他人的发言 |
单个场景设定没有字数硬上限,但所有场景设定会按最长那个的字数计入「世界观/剧情设定」的 30000 字总上限。
在 SDK 中使用
const reply = await storvia.chat.send({
message: "你好",
topic: "dm_npc1",
sceneKey: "dm_scene", // 引用创作台预设的场景设定
});**续写(chat.continue)也要带 sceneKey(SDK 0.9.4
起)。**被续写的那条消息当初是带场景发出的,续写时不带原样 key,补写出来的后半段就拿不到场景设定里的
输出形式与格式要求,会和前半段对不上(比如前半段是一句台词、后半段变成大段叙述)。retry 同理。
await storvia.chat.continue({
topic: "dm_npc1",
sceneKey: "dm_scene", // 与被续写消息发送时保持一致
onChunk: (text) => appendText(text),
});定义输出形式
沉浸创作下,每一轮 AI 输出的形式和长度由当前场景设定决定 —— 可能是一句台词、一段叙述、一封信、一份资料。
| Key | 值(场景设定) |
|---|---|
直播间 | 你正在直播,只输出一句直播话术,回应弹幕或抛出话题 |
私聊 | 这是私聊场景,只输出一句当前角色的发言,不要叙述旁白 |
世界观 | 玩家在查阅资料,输出一段背景介绍,包含历史、势力、风俗 |
信件 | 输出一封信的正文,开头称呼、落款署名都要有 |
作者可以在场景设定里自由指定每轮输出的形式与长度,AI 会优先遵循场景中的要求。
指定输出格式
如果你需要 AI 按结构化格式返回内容(在 SDK 里解析后做后续处理),强烈推荐使用 XML 标签 + 正则提取,不要让 AI 输出 JSON。
| 对比项 | XML 标签 | JSON |
|---|---|---|
| 解析稳定性 | ✅ 高,少量字符错误也能容忍 | ❌ 低,少一个引号或逗号就整体解析失败 |
| 模型能力要求 | 低,几乎所有模型都能稳定输出 | 高,弱模型经常输出非法 JSON |
| 流式渲染 | ✅ 可以边生成边显示 | ❌ 必须等完整结果到达再解析 |
推荐写法(写在场景设定里):
请严格按以下格式输出,不要有任何额外文字:
<title>标题文本</title>
<summary>一段摘要</summary>
<tags>标签1,标签2,标签3</tags>SDK 端用正则提取即可:
const reply = await storvia.chat.send({ message, sceneKey: "card_scene" });
const title = reply.content.match(/<title>([\s\S]*?)<\/title>/)?.[1];
const summary = reply.content.match(/<summary>([\s\S]*?)<\/summary>/)?.[1];
const tags = reply.content.match(/<tags>([\s\S]*?)<\/tags>/)?.[1]?.split(",");自定义标签名不要和系统保留标签冲突(<state> / <world> / <player> / <character> / <item> / <relation> / <custom>),否则会被状态解析器吞掉。
解析失败时,绝对不要静默丢弃 AI 已经返回的内容。
上面的正则可能因为模型漏写标签、截断、格式跑偏而匹配不到 —— 这种情况 SDK
不会抛错(内容已经成功返回了,只是不符合你期望的格式)。务必按下面「错误处理」章节的规则兜底:把原始
reply.content
展示出来,给一条准确的「解析失败」提示,而不是吞掉内容或误报网络错误。
不推荐使用 JSON:让 AI 输出 { "title": "...", "summary": "..." } 这种结构,弱模型频繁出现漏引号、多逗号、转义错误等问题,导致 JSON.parse 失败;即使是强模型,遇到 token 截断、内容里含中文引号或换行时也容易解析出错。XML 标签的容错性远高于 JSON。
与扩展属性联动
在创作台的「扩展属性」中,开启 AI 追踪的字段可以关联一个或多个场景 key。关联后,该字段只在对应场景被激活时才注入 AI:
- 未关联任何场景(默认)→ 任何对话都注入
- 关联了特定场景 → 只有
sceneKey包含对应 key 时才注入
例如:字段 viewer_count 关联了 live_scene,那么只有传入 sceneKey: 'live_scene' 时 AI 才能看到这个字段,私聊场景不会注入,减少无关信息干扰。
Scene Key 和状态模块是互补的。状态模块中的数据(角色属性、好感度、心情等)会自动注入给 AI,sceneKey 用于补充当前场景的指令信息,并控制哪些扩展属性字段参与注入。不传 sceneKey 时,AI 仍然能看到所有状态数据和全局扩展属性。
内容过滤(extract)
chat.send / chat.generate / chat.retry / chat.continue 都支持传入 extract 选项:服务端只保留 AI 回复中 start..end 起止标记之间的内容,流式推送 + 入库都按过滤后版本进行。其余部分(思考、旁白、元数据)会被丢弃。
续写(chat.continue)extract 模式下必须带上原样的 extract 规则。
被续写的截断消息是 extract
生成的,续写不带规则则标记外的内容会流给玩家并入库。续写是纯追加:已存的正文不会被改写,只在其后接上新抽取的可见内容——即使原消息在标记出现前就被截断(此时已存的是过滤前原文),续写也只追加、不回改,刷新前后一致。
适用场景
- AI 用
<thinking>...</thinking>写思考、<reply>...</reply>写正式回复,作者只想给玩家看 reply 部分 - AI 输出结构化内容(卡片、信件、UI 数据),作者只想把指定区段写入历史
调用示例
const reply = await storvia.chat.send({
message: "你好",
topic: "main",
extract: { start: "<reply>", end: "</reply>" },
});
// reply.content 包含首尾标记 + 中间内容
// 例如 AI 输出 "让我想想...<reply>干嘛,这么晚找我?</reply>"
// reply.content → "<reply>干嘛,这么晚找我?</reply>"
console.log(reply.content);在指令 / 场景设定中告诉 AI:
请按以下格式输出:先用
<thinking>...</thinking>写你的思考过程,然后用<reply>...</reply>包裹给玩家看的回复。
之后调用 send / generate / retry 时传 extract: { start: '<reply>', end: '</reply>' }。
行为说明
返回内容保留首尾标记,方便开发者用正则精确解析:
| 情况 | 推送 / 入库结果 |
|---|---|
AI 输出 <reply>X</reply> | 推送 / 入库 = <reply>X</reply> |
AI 输出多段 <reply>A</reply>B<reply>C</reply> | 推送 / 入库 = <reply>A</reply><reply>C</reply>(自动拼接,中间的 B 被丢弃) |
AI 输出 <reply>未闭合的内容 | 推送 / 入库 = <reply>未闭合的内容(流末尾兜底吐出) |
AI 完全没输出 <reply> | fallback:推送 / 入库原始干净文本(避免空消息),便于排查 prompt 问题 |
流式行为
- SSE 推送的内容也只是
start..end之间的部分;AI 在写"开头思考"时前端不会收到任何 chunk,等<reply>标签出现后才开始流式推送 - 跨 chunk 拆分的标签(如
<re+ply>)会正确处理,不会把不完整标签当作正文 emit - 没有
<reply>命中时,整段原始内容会在流末尾作为一次性 chunk 推送给前端
约束
| 项目 | 限制 |
|---|---|
start / end | 字面量字符串(非正则),支持中文 / emoji 等任意 UTF-8 |
| 长度 | 每个标记 1-64 字符 |
start 与 end | 不能相同 |
| 数量 | 单次调用只支持一条规则(一对 start/end) |
错误码
| 错误码 | 含义 |
|---|---|
INVALID_EXTRACT_RULE | extract 入参格式不合法(缺字段 / 类型错 / 长度超限 / start 与 end 相同);send / generate / retry / continue 统一返回此错误码 |
如何选择 extract 还是 sceneKey 限定输出格式:sceneKey 用来告诉 AI 「当前场景的输出形式」(一句话 / 一封信 / 一段叙述等),是 prompt 注入;extract 是后端兜底过滤,确保入库的是干净的、解析后的内容。两者通常配合使用:sceneKey 引导 AI 输出固定格式,extract 把范围内的内容捞出来。
extract 不会也不应当替代系统保留标签的解析。<state> 等系统标签在 extract 之前就已经被剥离,不会出现在最终入库内容里。详见 系统保留标签。
超时保护
SDK 内置三级超时机制,覆盖对话请求的完整生命周期。超时后会自动抛出 StorviaError 并弹出 Toast 提示,开发者无需额外配置。
| 阶段 | 超时时间 | 触发条件 | 错误码 |
|---|---|---|---|
| 请求超时 | 60 秒 | 发出请求后,服务器未在 60 秒内响应 | REQUEST_TIMEOUT |
| 模型加载超时 | 180 秒 | 服务器已响应,但 180 秒内未收到第一个 AI 文本片段 | MODEL_LOADING_TIMEOUT |
| 流式无活动超时 | 60 秒 | 流式输出过程中,60 秒内未收到任何数据 | STREAM_INACTIVITY |
超时保护对 send() / generate() / retry()
的流式和非流式调用均生效。save() / messages() / edit()
等普通请求仅受请求超时(60 秒)保护。
错误处理
沉浸创作里对话出问题,根因分三类,处理方式完全不同。把它们混为一谈(尤其是把「解析失败」误报成「网络错误」)是最常见、也是体验最差的坑。
| 类别 | 发生位置 | AI 内容拿到了吗 | SDK 会抛 StorviaError 吗 | 你该做什么 |
|---|---|---|---|---|
| ① 传输 / 调用层 | 请求根本没成功(断网、超时、扣费失败、被拦截) | ❌ 没有 | ✅ 会,带明确 code | 按 code 给准确提示,可重试 |
| ② 模型层 | 请求成功但模型拒答 / 被安全策略过滤 | ⚠️ 没有有效正文 | ✅ 会(MODEL_REFUSAL / CONTENT_FILTERED 等) | 提示换个说法重试,不要当成网络问题 |
| ③ 解析层 | 内容成功返回了,但你的正则 / JSON.parse 没解析出来 | ✅ 拿到了 | ❌ 不会抛错 | 见下方「黄金法则」——绝不能静默吞掉 |
第 ①②类:StorviaError —— 按 code 精确提示
调用层和模型层的失败都会以 StorviaError 抛出,带一个准确的 code。一律 try/catch 后按 code 分支,不要笼统地写「网络异常」。
try {
const reply = await storvia.chat.send({
message: "你好",
stream: true,
onChunk: (text) => {
chatBubble.textContent += text;
},
onDone: (id) => {
console.log("完成:", id);
},
});
} catch (err) {
// err 是 StorviaError,err.code 是机器可读的错误码
switch (err.code) {
case "NETWORK_ERROR":
showError("网络异常,请检查连接");
break;
case "REQUEST_TIMEOUT":
showError("请求超时,请稍后重试");
break;
case "MODEL_LOADING_TIMEOUT":
showError("模型响应超时,请稍后重试");
break;
case "STREAM_INACTIVITY":
showError("连接已中断,请重试");
break;
case "STREAM_INTERRUPTED":
showError("回复已中断,请重试");
break;
case "GENERATION_TIMEOUT":
showError("生成超时,请重试");
break;
case "REPETITION_LOOP":
showError("回复出现重复,请重试或换个说法");
break;
case "SAVE_FAILED":
showError("消息保存失败,本次不扣费,请重试");
break;
case "MODEL_REFUSAL":
showError("模型拒绝响应,换个说法试试");
break;
case "CONTENT_FILTERED":
showError("内容被安全策略拦截,请调整后重试");
break;
case "VIOLATION_WORD_DETECTED":
showError("内容含有违禁词,请修改后重试");
break;
case "INSUFFICIENT_CREDITS":
/* SDK 已自动弹钱包引导,通常无需额外处理 */ break;
case "RATE_LIMITED":
showError("操作过于频繁,请稍后再试");
break;
default:
showError("出了点小问题,请稍后重试");
break;
}
}常见错误码一览(完整定义见 SDK 的 StorviaErrorCode 类型):
| 错误码 | 含义 | 是不是「网络问题」 |
|---|---|---|
NETWORK_ERROR | 真正的网络层失败(fetch 抛错、断网) | ✅ 是 |
REQUEST_TIMEOUT / MODEL_LOADING_TIMEOUT / STREAM_INACTIVITY | 各阶段超时 | ⚠️ 算连接问题,但提示要区分阶段 |
STREAM_INTERRUPTED | 流式输出中途断开(重连耗尽后) | ⚠️ 连接问题 |
GENERATION_TIMEOUT | 生成超时 | ⚠️ 可重试 |
REPETITION_LOOP | 模型陷入重复循环被中断 | ❌ 不是,可重试或换说法 |
SAVE_FAILED | 消息入库失败(本次不扣费) | ❌ 不是,可重试 |
FINALIZE_FAILED | 回复收尾处理失败 | ❌ 不是,可重试 |
MODEL_REFUSAL | 模型拒答 | ❌ 不是,别报网络错误 |
CONTENT_FILTERED | 内容被安全策略过滤 | ❌ 不是 |
CONVERSATION_NOT_FOUND | 会话不存在 | ❌ 不是 |
EMPTY_MESSAGE | 消息内容为空 | ❌ 不是 |
VIOLATION_WORD_DETECTED | 命中违禁词 | ❌ 不是 |
INSUFFICIENT_CREDITS | 花园币不足 | ❌ 不是(SDK 自动弹充值引导) |
RATE_LIMITED | 触发频率限制 | ❌ 不是 |
MODEL_NOT_FOUND | 所选模型不可用 | ❌ 不是 |
GENERATION_ABORTED | 玩家自己按了停止 | ❌ 不是,也不是失败——别报错 |
GENERATION_ABORTED 不是失败。
这是玩家自己按停止按钮的结果,弹「操作失败,请重试」会让玩家以为游戏出了
bug。SDK 自己不会为这个码弹提示;如果你在 catch
里统一弹错,记得把它排除掉。正常情况下你应该用
onAborted 回调处理中断,而不是在 catch
里等它。
SDK 0.8 起,错误码更精细。 部分原本归类为 CONTENT_FILTERED /
STREAM_INTERRUPTED 的失败,现在有了更具体的错误码(如
REPETITION_LOOP、GENERATION_TIMEOUT、SAVE_FAILED),便于你按原因区分提示(如重复循环提示「换个说法」、SAVE_FAILED
明确「本次不扣费」)。不认识的码按 default 兜底即可,SDK
也会带上后端返回的提示文案(message)。
不要把所有 catch 到的错误都显示成「网络异常」。
MODEL_REFUSAL、CONTENT_FILTERED、INSUFFICIENT_CREDITS
这些和网络毫无关系,笼统报「网络错误」会让用户反复重连却永远好不了。StorviaError
已经给了精确 code,照着用。
第 ③类:解析失败 —— 黄金法则
这是沉浸创作最容易踩、也最该重视的一类:AI 已经把内容返回给你了(send/generate/retry 正常 resolve,没有抛任何 StorviaError),但你写的正则 / JSON.parse 没能从 reply.content 里提取出期望的结构 —— 可能是模型漏写了 <title> 标签、内容被截断、把 JSON 写成了非法格式、或者格式整体跑偏。
这种情况 SDK 完全不知情,也不会替你报错。 解析 reply.content 是你自己业务层的事。如果你的代码写成「解析不到就 return」,结果就是:用户花了花园币、AI 也确实回复了,但屏幕上什么都没有,或者弹出一个莫名其妙的「网络错误」。这是最糟糕的体验。
解析失败三条铁律:
- 绝不静默吞掉 —— AI 已经产出的内容是用户付费换来的,哪怕格式不对,也必须让用户看见,至少能看到原文。
- 绝不误报网络错误 —— 内容明明回来了,报「网络异常 / 请检查连接」是错的、会误导用户去重连。要报准确的原因:「内容解析失败」。
- 给出原文 + 准确提示 + 可重试 —— 展示拿到的原始
reply.content,配一条「本次回复格式异常,已为你显示原文」的说明,并提供「重试」入口。
正确的兜底写法:
let reply;
try {
reply = await storvia.chat.send({ message, sceneKey: "card_scene" });
} catch (err) {
// 第①②类:内容根本没回来 —— 按 code 提示(见上一节)
showError(mapErrorByCode(err.code));
return;
}
// 内容已经成功返回(reply.content 一定有值)。开始尝试解析:
const title = reply.content.match(/<title>([\s\S]*?)<\/title>/)?.[1];
const summary = reply.content.match(/<summary>([\s\S]*?)<\/summary>/)?.[1];
if (!title || !summary) {
// ❗解析失败:不要 return、不要报网络错误
console.warn("[解析失败] 原始内容:", reply.content);
// ✅ 1) 把 AI 原文显示出来,让用户至少能读到内容
renderRawFallback(reply.content);
// ✅ 2) 给一条准确的提示(不是「网络错误」)
showWarning("本次回复格式异常,已为你显示原文,可点「重试」重新生成");
// ✅ 3) 暴露重试入口
showRetryButton();
return;
}
// 解析成功,正常渲染卡片
renderCard({ title, summary });❌ 反面教材(务必避免):
const title = reply.content.match(/<title>([\s\S]*?)<\/title>/)?.[1];
if (!title) {
showError("网络异常,请检查连接"); // ❌ 内容明明回来了,却误报网络错误
return; // ❌ AI 已产出的内容被静默丢弃,用户白扣费
}JSON 解析同理。 如果你坚持让 AI 输出 JSON(不推荐),JSON.parse 一定要包 try/catch:失败时不要让异常冒泡成「未知错误」,而是 catch 后展示原始文本 + 「内容解析失败」提示。本质和正则失败一样 —— 内容拿到了,只是结构不对。
let data;
try {
data = JSON.parse(reply.content);
} catch {
renderRawFallback(reply.content); // 展示原文
showWarning("本次回复格式异常,已为你显示原文"); // 准确提示,不是网络错误
return;
}配合 extract 时注意空内容并不等于解析失败。 当 AI 完全没输出 extract
指定的标签时,SDK
会兜底推送原始干净文本而不是空字符串。所以你拿到的
reply.content 始终有内容可展示 ——
解析不到结构时直接把它显示出来即可,不要因为「没匹配到标签」就判定成空回复。
一句话总结
拿到
StorviaError→ 按code给准确提示(区分网络 / 超时 / 拒答 / 拦截 / 余额)。 没拿到StorviaError但解析不出来 → 内容是好的,错的是格式:展示原文 + 报「解析失败」+ 给重试,永远不要静默吞掉,也永远不要报网络错误。