Storvia

聊天

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 表示零内容未入库
})
参数类型说明
messagestring(必填)用户消息内容
topicstring(可选)话题标识,隔离对话历史
sceneKeystring(可选)场景 key,引用创作台预设的场景设定
extract{ start, end }(可选)内容过滤规则,详见「内容过滤」章节
streamboolean(可选)是否流式输出,默认 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() 免费补完;据此决定要不要显示「继续」按钮
  • deltafull 仅在 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": "隐蔽的据点"
        }
    ]
}

未发生变更的字段(worldplayer、对应数组)在 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_updatingAI 正在写 state 标签(updating:true)/ 写完(updating:false
statestate 解析完成,携带本轮的增量(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错误终态:携带 codemessage,SDK 收到后抛出对应 codeStorviaError

流式模式下 send() 不返回 ChatResponse,AI 内容通过 onChunk 回调逐步传递。state 事件在整轮生成完成后一次性推送。


storvia.chat.save()

只保存消息,不调 AI。用于先保存用户消息,再分别请求不同 角色回复的场景(如群聊)。

参数

storvia.chat.save({
  role: 'user' | 'assistant',  // 必填:消息角色
  content: string,              // 必填:消息内容
  topic?: string,               // 可选:话题标识
})
参数类型说明
role'user' | 'assistant'(必填)消息角色
contentstring(必填)消息内容
topicstring(可选)话题标识

返回值

{
    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)
})
参数类型说明
topicstring(可选)话题标识
sceneKeystring(可选)场景 key,引用创作台预设的场景设定
extract{ start, end }(可选)内容过滤规则,详见「内容过滤」章节
streamboolean(可选)是否流式输出,默认 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)
})
参数类型说明
topicstring(可选)重试该 topic 下最后一条 AI 消息
sceneKeystring(可选)场景 key,引用创作台预设的场景设定
extract{ start, end }(可选)内容过滤规则,详见「内容过滤」章节
streamboolean(可选)是否流式输出,默认 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 表示零内容未入库,需把玩家输入放回输入框
})
只能流式调用,没有非流式用法。
参数类型说明
topicstring(可选)续写该 topic 下最新的 AI 消息;不传 = 续写整个会话里最新的那条 AI 消息
sceneKeystring(可选)场景 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 }

参数

参数类型说明
topicstring(可选)停哪条支线的在途生成;缺省 = 主线

返回值

字段说明
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 收到什么

字段说明
idAI 消息 uuid;persisted: false 时为空串
persisted半截内容是否已入库。false = 还没吐出任何内容就停了,未入库也未扣费

persisted: false 时建议把玩家刚发的输入放回输入框。 这种情况下玩家的消息没有入库,服务端无法代为恢复——你不放回去,玩家刚打的那段话就凭空消失了。

中断只要已经产生了内容就会计费:按量计费的模型按实际产生的 token 收费,按次计费的模型扣一整次。只有还没吐出任何内容就停(persisted: false)才不计费。

预览模式没有在途生成cancel() 恒返回 { active: false },不打后端。


断线续传(SDK 0.8 起)

流式生成跑在服务端,AI 输出过程与你的网络连接解耦:内容一边生成一边写进服务端,不依赖发起请求的那条连接活着。所以 AI 回复时刷新页面、切后台、关掉 App / 浏览器、断网或弱网抖动,都不会让回复中断或丢失。SDK 在两个层面利用这一点:

  1. 自动重连 —— 流式 send / generate / retry 进行中若连接断开,SDK 自动从断点续读、接着把剩下的内容喂给你的 onChunk,整轮无感。
  2. 接管在途 —— 刷新 / 换设备后,用 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>
参数类型说明
topicstring(可选)接管该 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 = 从新到旧(聊天框)
limitnumber(可选)单次最大返回条数,1–50,默认 20
topicstring(可选)按 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;
}>;
参数类型说明
uuidstring(必填)要编辑的消息 UUID(来自 messages()
contentstring(必填)新内容;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_REQUESTuuid 格式无效或 content 为空
CONTENT_VIOLATIONuser 消息违禁词命中
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;
}>;
参数类型说明
uuidstring(必填)要删除的消息 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_REQUESTuuid 格式无效
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;
}>;
参数类型说明
uuidstring(必填)回溯到的目标消息 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_REQUEST400uuid 格式无效
MESSAGE_TOO_OLD400目标消息超过 30 天
NO_MESSAGES_TO_DELETE400目标消息之后没有消息可删
DAISY_CARD_REQUIRED403当前用户没有有效的雏菊卡订阅
MESSAGE_NOT_FOUND404消息不存在或无权回溯

该方法在预览模式下无法正常使用,需要发布后在 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 字符
startend不能相同
数量单次调用只支持一条规则(一对 start/end)

错误码

错误码含义
INVALID_EXTRACT_RULEextract 入参格式不合法(缺字段 / 类型错 / 长度超限 / 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你该做什么
① 传输 / 调用层请求根本没成功(断网、超时、扣费失败、被拦截)❌ 没有✅ 会,带明确 codecode 给准确提示,可重试
② 模型层请求成功但模型拒答 / 被安全策略过滤⚠️ 没有有效正文✅ 会(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_LOOPGENERATION_TIMEOUTSAVE_FAILED),便于你按原因区分提示(如重复循环提示「换个说法」、SAVE_FAILED 明确「本次不扣费」)。不认识的码按 default 兜底即可,SDK 也会带上后端返回的提示文案(message)。

不要把所有 catch 到的错误都显示成「网络异常」。 MODEL_REFUSALCONTENT_FILTEREDINSUFFICIENT_CREDITS 这些和网络毫无关系,笼统报「网络错误」会让用户反复重连却永远好不了。StorviaError 已经给了精确 code,照着用。

第 ③类:解析失败 —— 黄金法则

这是沉浸创作最容易踩、也最该重视的一类:AI 已经把内容返回给你了(send/generate/retry 正常 resolve,没有抛任何 StorviaError),但你写的正则 / JSON.parse 没能从 reply.content 里提取出期望的结构 —— 可能是模型漏写了 <title> 标签、内容被截断、把 JSON 写成了非法格式、或者格式整体跑偏。

这种情况 SDK 完全不知情,也不会替你报错。 解析 reply.content 是你自己业务层的事。如果你的代码写成「解析不到就 return」,结果就是:用户花了花园币、AI 也确实回复了,但屏幕上什么都没有,或者弹出一个莫名其妙的「网络错误」。这是最糟糕的体验。

解析失败三条铁律:

  1. 绝不静默吞掉 —— AI 已经产出的内容是用户付费换来的,哪怕格式不对,也必须让用户看见,至少能看到原文。
  2. 绝不误报网络错误 —— 内容明明回来了,报「网络异常 / 请检查连接」是错的、会误导用户去重连。要报准确的原因:「内容解析失败」
  3. 给出原文 + 准确提示 + 可重试 —— 展示拿到的原始 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 但解析不出来 → 内容是好的,错的是格式:展示原文 + 报「解析失败」+ 给重试,永远不要静默吞掉,也永远不要报网络错误。

On this page