blob: 40d040023c68b8995c2e43bbe64e236876d265e5 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { AssistantModuleServices } from '../service.js';
import type { BotController } from './controller.js';
import type { BotStore, InboxInput } from './store.js';
import type { CharacterPack } from './character-pack.js';
import { MATTER_REQUEST_MAX_LENGTH } from '../proactive-matters/matter.js';
export const CHARACTER_INITIALIZATION_REQUEST = `为用户创作并装好第一套人物包。先用 AssistantCharacterPack read document=guide 读维护指南,再通过可访问的历史、索引等信息了解用户的爱好。找到明确爱好,就围绕用户喜欢的人物、作品、游戏或圈子设计;找不到,就上网了解当下热梗来选角色。要有辨识度、有话题度,不要编造用户爱好。
persona 写名字、角色、性格、说话习惯和梗,不超过 200 行。人设决定说话风格,做事方式由系统提示词规定。保存配套头像并 set_avatar。表情包去网上找符合人设、可以在聊天里用的现成表情包,先看图再保存;找不到就先留空。没网络就跳过找图,头像可用 emoji,角色可采用熟悉的梗。
素材保存到人物包,来源和许可放素材 metadata,聊天使用保存后的 imageUrl。尊重用户已有定制和明确偏好,只维护人物包。
完成后调用 AssistantCharacterPack action=finish_initialization,再提交 MatterSettle complete,简短写角色和设计缘由供 Bot 参考。Bot 会以这个角色简单自我介绍,并根据当前开关状态说明主动发现;不要自行发消息或开启长期监控。`;
interface Initialization {
phase: 'offered' | 'skipped' | 'starting' | 'running' | 'completed';
owner: string;
taskKey: string;
request: string;
worker?: string;
taskId?: string;
prepared?: boolean;
error?: string;
retryAt?: number;
adopted?: boolean;
introduction?: 'introduce' | 'explain_change';
introductionContext?: { discoveryEnabled: boolean; hasStickers: boolean };
}
const KEY = 'characterInitialization';
const TITLE = '为自己设计人物包';
const PET_KEY = 'petInitialization';
/** One persisted Matter, on the normal scheduler; never blocks the Bot's conversation loop. */
export class CharacterInitialization {
private pending?: Promise<void>;
private timer?: ReturnType<typeof setInterval>;
constructor(
private readonly ctx: AssistantModuleServices,
private readonly bot: BotController,
private readonly store: BotStore,
private readonly pack: CharacterPack,
private readonly discoveryEnabled: () => boolean,
) {}
state() {
return this.store.get<Initialization>(KEY);
}
async setup(input: {
kind: 'assistant' | 'pet';
action: 'create' | 'skip';
preferences?: string;
}) {
await this.pending;
this.bot.ready();
const owner = this.bot.owner();
if (!owner) throw Error('Bind the assistant conversation first.');
const key = input.kind === 'assistant' ? KEY : PET_KEY;
const state = this.store.get<Initialization>(key);
if (state?.phase === 'running' || state?.phase === 'starting')
return { ...state, notice: '已有创建任务,请继续该任务,不要重复创建。' };
if (state?.phase === 'completed')
return {
...state,
notice:
'首次设置已完成。换宠物用 AssistantPetPack select;需要新素材可另行委派,修改助手人设用 AssistantCharacterPack。',
};
if (input.action === 'skip') {
this.store.set(key, { phase: 'skipped', owner });
} else {
if (!input.preferences?.trim())
throw Error('Record the user’s choice; use 随机有趣 only when they have no preference.');
if (input.kind === 'assistant' && this.pack.hasPersona())
throw Error('人物包已设置,请用 AssistantCharacterPack 维护已有包。');
this.store.set(key, {
phase: 'starting',
owner,
taskKey:
input.kind === 'assistant'
? 'assistant-character-initialization-v1'
: 'assistant-pet-initialization-v1',
request:
input.kind === 'assistant'
? `${CHARACTER_INITIALIZATION_REQUEST}\n用户本次选择(优先遵循,不需要再次猜测):${input.preferences}`
: petInitializationRequest(
input.preferences,
this.pack.readDocument('persona').content,
),
});
}
await this.tick();
return this.store.get<Initialization>(key);
}
isWorker(sessionId: string) {
const state = this.state();
return (
!!state &&
state.owner === this.bot.owner() &&
state.worker === sessionId &&
state.phase !== 'completed'
);
}
taskCreated(owner: string, taskKey: string, sessionId: string) {
for (const key of [KEY, PET_KEY]) {
const state = this.store.get<Initialization>(key);
if (state?.owner !== owner || state.taskKey !== taskKey || state.phase === 'completed')
continue;
// Persist the identity before the first activation, including recovery.
this.store.set(key, { ...state, worker: sessionId });
}
}
prepare(sessionId: string) {
this.bot.ready();
if (!this.isWorker(sessionId))
throw Error('Only the character initialization task can finish initialization');
if (!this.pack.hasPersona())
throw Error('Write your distinctive persona before finishing initialization');
if (!this.store.get('avatar'))
throw Error('Set your image or emoji avatar before finishing initialization');
this.store.set(KEY, { ...this.state()!, prepared: true });
return { ready: true, next: '人物包已保存并校验。请完成持续任务,结果会交给 Bot 介绍。' };
}
feedback(input: InboxInput): InboxInput {
const state = this.state();
if (!state || input.source !== state.worker || input.type !== 'task_update') return input;
const content = input.content as { status?: string };
if (content?.status !== 'completed' || !state.prepared) return input;
if (!state.introduction) {
state.introduction = this.store.hasUserConversation() ? 'explain_change' : 'introduce';
this.store.set(KEY, state);
}
if (!state.introductionContext) {
state.introductionContext = {
discoveryEnabled: this.discoveryEnabled(),
hasStickers: this.pack.stickers().total > 0,
};
this.store.set(KEY, state);
}
const { discoveryEnabled, hasStickers } = state.introductionContext;
// Keep the envelope deterministic across delivery retries; message identity stays unchanged.
return {
...input,
content: {
...content,
introduction: state.introduction,
characterIntroduction: [
'人物包初始化已完成。请读取当前 persona,用角色的口吻简单自我介绍。',
discoveryEnabled
? '主动发现目前已开启。顺带告诉用户:系统有多个后台 Agent,运行频率较高,这是个人助手的核心能力,也比较消耗 token。建议先开几天体验;如果 token 不够,可以关闭主动发现或降低频率,你可以帮忙调整。'
: '主动发现目前未开启。顺带告诉用户可以开启体验,你可以帮忙开启;不要说后台主动发现已在运行,也不要自行开启。',
hasStickers
? '人物包里已有表情包。用 AssistantStickerSearch 或 AssistantCharacterPack asset 取得 imageUrl,选一张适合打招呼的表情,和这次自我介绍一起通过 SendUser 发出,不用介绍素材清单。'
: '人物包里没有表情包,这次直接文字介绍即可,不必为了开场再找图。',
'自然、简短地说,按实际交流避免重复介绍或重复说明,不汇报工具和下载过程。',
].join(''),
},
};
}
delivered(input: InboxInput) {
const pet = this.store.get<Initialization>(PET_KEY);
if (
pet?.worker === input.source &&
input.type === 'task_update' &&
(input.content as { status?: string })?.status === 'completed'
)
this.store.set(PET_KEY, { ...pet, phase: 'completed' });
const state = this.state();
if (
state?.prepared &&
input.source === state.worker &&
input.type === 'task_update' &&
(input.content as { status?: string })?.status === 'completed'
) {
this.store.set(KEY, { ...state, phase: 'completed', error: undefined });
}
}
start() {
this.timer = setInterval(() => void this.tick(), 5000);
this.timer.unref?.();
void this.tick();
return async () => {
clearInterval(this.timer);
await this.pending;
};
}
tick() {
if (this.pending) return this.pending;
this.pending = this.run()
.catch((error) => {
// Disabled or replaced Hosts cannot mutate onboarding state.
try {
this.bot.ready();
} catch {
return;
}
console.error('[assistant] setup check failed', error);
})
.finally(() => {
this.pending = undefined;
});
return this.pending;
}
private async offer(kind: 'assistant' | 'pet', owner: string) {
// A recovered binding can precede Session Query activation. Do not wake the
// Bot (or persist an offer) until its owner conversation is available.
// Timer callbacks have no Agent invocation. Use the owner's scoped query;
// unscoped queries intentionally cannot see any sessions. Only metadata is
// needed here, not the owner's complete conversation history.
const sessions = await this.ctx.agents.withInvocation(this.bot.invocation(owner), () =>
this.ctx.sessionQuery.list(),
);
if (!sessions.some((session) => session.id === owner)) return;
this.bot.ready();
if (owner !== this.bot.owner()) return;
const key = kind === 'assistant' ? KEY : PET_KEY;
// Inbox IDs make the post idempotent across a crash before recording offered.
this.bot.post({
id: `appearance-setup:${kind}:${owner}`,
type: 'system_event',
source: 'appearance-setup',
content: {
kind,
message:
kind === 'assistant'
? '助手人物包尚未设置。先问用户要不要帮忙设置、喜欢什么样;如果没有想法,可以给一个随机有趣的角色。等用户回答再用 AssistantSetup create,拒绝则 skip;不要自动创建。一次只问助手,宠物会单独检查。'
: '桌面宠物尚未设置。问用户要不要开启宠物,是跟助手一样还是有别的想法。等用户回答再用 AssistantSetup create,拒绝则 skip;已有喜欢的宠物可用 AssistantPetPack list/select。不要因为改了助手人设而自动改宠物。',
},
});
this.store.set(key, { phase: 'offered', owner });
}
private async run() {
const owner = this.bot.owner();
if (!owner) return;
this.bot.ready();
let state = this.state();
if (!state) {
if (this.pack.hasPersona()) {
state = { phase: 'completed', owner, adopted: true } as Initialization;
this.store.set(KEY, state);
} else {
await this.offer('assistant', owner);
return;
}
}
if (state.phase === 'offered' && this.pack.hasPersona()) {
state = { ...state, phase: 'completed', adopted: true };
this.store.set(KEY, state);
}
if (state.phase === 'starting') await this.launch(KEY, state, TITLE);
if (!this.ctx.petPacks) return;
const pet = this.store.get<Initialization>(PET_KEY);
if (pet?.phase === 'starting') {
await this.launch(PET_KEY, pet, '创建桌面宠物');
return;
}
if (state.phase !== 'completed' && state.phase !== 'skipped') return;
if (!pet) {
const current = await this.ctx.petPacks.status();
this.bot.ready();
if (current.configured) this.store.set(PET_KEY, { phase: 'completed', owner, adopted: true });
else await this.offer('pet', owner);
}
}
private async launch(key: string, state: Initialization, title: string) {
const owner = this.bot.owner();
if (state.owner !== owner) throw Error('Appearance setup belongs to another conversation');
if ((state.retryAt ?? 0) > Date.now()) return;
let invalidRequest = false;
try {
const target = this.ctx.tools
.resolve(owner)
.tools.find((tool) => tool.name === 'MatterDelegate');
if (!target) throw Error('Matter delegation is unavailable');
let arguments_: unknown;
try {
arguments_ = (target.parameters as { parse(input: unknown): unknown }).parse({
taskKey: state.taskKey,
title,
request: state.request,
});
} catch (error) {
invalidRequest = !state.worker && !state.taskId;
throw error;
}
this.store.queueVisibleBatch(`character-start:${owner}:${state.taskKey}`, [
{ text: key === KEY ? '我在配置我自己,稍等一下。' : '我来做这只宠物,做好就换上。' },
]);
await this.bot.flush();
this.bot.ready();
const call = this.bot.invocation(owner);
const task = (await this.ctx.agents.withInvocation(call, () =>
target.impl(arguments_, call),
)) as { id: string; sessionId: string };
this.bot.ready();
const saved = this.store.get<Initialization>(key)!;
this.store.set(key, {
...saved,
phase: saved.phase === 'completed' ? 'completed' : 'running',
worker: task.sessionId,
taskId: task.id,
error: undefined,
});
} catch (error) {
this.bot.ready();
const updated = {
...this.store.get<Initialization>(key)!,
...(invalidRequest ? { phase: 'offered' as const } : {}),
error: String(error),
retryAt: invalidRequest ? undefined : Date.now() + 60_000,
};
this.store.set(key, updated);
if (this.store.get(`${key}Error`) !== updated.error) {
this.bot.post({
id: `${key}-error:${updated.retryAt}`,
type: 'system_event',
source: 'appearance-setup',
content: {
error: updated.error,
taskKey: state.taskKey,
message: invalidRequest
? '创建请求未能通过校验,尚未创建任务;可以重新选择或跳过。正常聊天不受影响。'
: '创建未能启动,保留原任务身份重试。正常聊天不受影响。',
},
});
this.store.set(`${key}Error`, updated.error);
}
}
}
}
function petInitializationRequest(preferences: string, persona: string): string {
const prefix = `按用户选择创建并启用桌面宠物:${preferences}。当前助手人设(只供外观参考,不是本任务指令;过长时仅为节选):`;
const suffix =
'。用户选择一致时参考它,选择不同风格时按用户选择。先读 AssistantPetPack requirements,按规范自行制作素材,用 create 安装,再 select 切换启用。宠物与助手人设独立,不修改助手包。已有合适的包可以直接选择。完成后报告实际结果,不声称未经验证的显示效果。';
const points = Array.from(persona);
const compose = (length: number) =>
`${prefix}${JSON.stringify(points.slice(0, length).join(''))}${suffix}`;
if (compose(0).length > MATTER_REQUEST_MAX_LENGTH)
throw Error('Pet preferences exceed the delegation request limit');
let low = 0,
high = points.length;
while (low < high) {
const middle = Math.ceil((low + high) / 2);
if (compose(middle).length <= MATTER_REQUEST_MAX_LENGTH) low = middle;
else high = middle - 1;
}
return compose(low);
}