|
|
@@ -2,11 +2,28 @@ import { ApplicationRef, Injectable, NgZone } from '@angular/core';
|
|
|
import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
|
|
|
import { map } from 'rxjs/operators';
|
|
|
import { LlmService, ChatMessage } from './llm.service';
|
|
|
+import { CloudSessionStorageService } from './cloud-session-storage.service';
|
|
|
+import { StorageGovernanceService } from './storage-governance.service';
|
|
|
|
|
|
export interface AssistantSuggestion {
|
|
|
tab: string;
|
|
|
label: string;
|
|
|
payload?: Record<string, any>;
|
|
|
+ risk?: 'low' | 'medium' | 'blocked';
|
|
|
+ confirmMessage?: string;
|
|
|
+}
|
|
|
+
|
|
|
+export interface IpOperatorAssistantContext {
|
|
|
+ page?: string;
|
|
|
+ pageContext?: string;
|
|
|
+ account?: unknown;
|
|
|
+ snapshot?: unknown;
|
|
|
+ positioning?: unknown;
|
|
|
+ directions?: unknown[];
|
|
|
+ tasks?: unknown[];
|
|
|
+ selectedTopic?: unknown;
|
|
|
+ selectedScript?: unknown;
|
|
|
+ selectedPublishPackage?: unknown;
|
|
|
}
|
|
|
|
|
|
export interface AssistantMessage {
|
|
|
@@ -29,28 +46,44 @@ export interface Conversation {
|
|
|
const STORAGE_KEY = 'tiktok.assistant.conversations.v2';
|
|
|
const LEGACY_KEY = 'tiktok.assistant.history';
|
|
|
const ACTIVE_KEY = 'tiktok.assistant.activeId';
|
|
|
+const ACTIVE_CACHE_KEY = 'tiktok.assistant.activeId.cache.v1';
|
|
|
+const ASSISTANT_THREAD_ENTITY_TYPE = 'assistantThread';
|
|
|
const MAX_CONVS = 40;
|
|
|
const MAX_MSGS_PER_CONV = 80;
|
|
|
|
|
|
-const SYSTEM_PROMPT = `你是「Tik-Tok 视频生成系统」的内置创作助手,专注于短视频内容创作。
|
|
|
+const SYSTEM_PROMPT = `你是「证据驱动 IP 操盘工作台」的内置运营副驾,重点帮助用户做账号诊断、定位迭代、爆款适配、内容方向、选题、分镜脚本、发布准备和复盘。
|
|
|
|
|
|
## 你的能力范围
|
|
|
-1. 帮用户写视频脚本、镜头描述、中文提示词
|
|
|
-2. 给出风格、节奏、分镜、配乐方面的建议
|
|
|
-3. 解答系统使用问题(系统包含以下生成模式:主题生视频 / 文生视频 / 图生视频 / 数字人合成 / 动作迁移 / 素材合成视频)
|
|
|
-4. 当用户的需求显然适合直接进入某条生成流水线时,在回答**最末尾**用一个独立的 JSON 代码块给出建议:
|
|
|
+1. 基于当前账号、作品、评论、定位版本和任务,解释账号为什么不涨粉、播放不稳定或内容跑偏。
|
|
|
+2. 判断对标爆款能否迁移到当前账号,明确可借鉴点、不可照搬点和改造方式。
|
|
|
+3. 生成或打磨内容方向、选题、详细分镜脚本、内容日历草稿、发布包和复盘建议。
|
|
|
+4. 当用户需要执行低风险运营动作时,在回答**最末尾**用独立 JSON 代码块给出建议:
|
|
|
|
|
|
\`\`\`json
|
|
|
-{ "suggest": [{ "tab": "topic-to-video", "label": "进入主题生视频", "payload": { "topic": "盛夏海边少女回眸微笑" } }] }
|
|
|
+{ "suggest": [{ "tab": "ip-operator", "label": "打开账号运营工作台", "risk": "low", "payload": { "action": "open_workbench" } }] }
|
|
|
\`\`\`
|
|
|
|
|
|
5. 当你已经帮用户整理出明确选题时,可以建议保存到选题池:
|
|
|
|
|
|
\`\`\`json
|
|
|
-{ "suggest": [{ "tab": "topic-pool", "label": "保存到选题池", "payload": { "saveTopic": true, "title": "盛夏海边少女回眸微笑", "angle": "用电影感回眸制造氛围和情绪记忆点", "tags": ["AI助手", "氛围感"] } }] }
|
|
|
+{ "suggest": [{ "tab": "topic-pool", "label": "保存到选题池", "risk": "low", "payload": { "saveTopic": true, "title": "老板号为什么越发越像广告", "angle": "结合当前账号定位解释信任问题", "tags": ["AI助手", "IP操盘"] } }] }
|
|
|
+\`\`\`
|
|
|
+
|
|
|
+6. 当用户要求你“直接安排下一步”时,可以建议低风险 IP 操盘动作:
|
|
|
+
|
|
|
+\`\`\`json
|
|
|
+{ "suggest": [{ "tab": "ip-operator", "label": "创建今日运营任务", "risk": "low", "payload": { "action": "create_operation_task", "title": "复核高互动作品评论痛点", "reason": "评论问题能反推互动型选题", "type": "pain", "column": "today", "growthImpactScore": 82 } }] }
|
|
|
\`\`\`
|
|
|
|
|
|
-合法的 tab 取值:home / topic-to-video / image-to-video / digital-human / action-transfer / asset-remix / video-generation / search / monitor / topic-pool / analysis-history / tasks / history / results / videos / voice-synthesis。
|
|
|
+常用低风险 payload.action:open_workbench / create_operation_task / draft_calendar / generate_script / organize_comment_pain。
|
|
|
+
|
|
|
+合法的 tab 取值:home / ip-operator / topic-pool / analysis-history / tasks / history / results / videos / topic-to-video / digital-human。
|
|
|
+
|
|
|
+## 动作权限
|
|
|
+- 低风险,可直接执行:生成选题、生成脚本、创建任务、加入日历草稿、整理评论痛点、生成方向建议、打开相关页面。建议项 risk 使用 low。
|
|
|
+- 中风险,必须二次确认:确认定位版本、覆盖已有脚本、批量调整日历、批准发布包、把选题标为本周主推。建议项 risk 使用 medium,并填写 confirmMessage。
|
|
|
+- 高风险,当前版本阻断:自动发布、自动评论、自动私信、Cookie 池、代理池、批量互动。不得提供执行入口;如需提示,建议项 risk 使用 blocked。
|
|
|
+- 不得把推断写成事实;证据不足时明确标注样本不足。
|
|
|
|
|
|
## 输出格式要求(重要)
|
|
|
- **使用 Markdown 排版**:合理使用 **加粗** / *斜体* / 二级标题 \`##\` / 列表 / 表格 / 代码块 提升可读性
|
|
|
@@ -93,6 +126,8 @@ export class AssistantService {
|
|
|
* true 时浮窗 FAB / panel 全部隐藏,避免与主区对话功能重复。
|
|
|
*/
|
|
|
readonly inlineActive$ = new BehaviorSubject<boolean>(false);
|
|
|
+ private ipOperatorContext: IpOperatorAssistantContext = {};
|
|
|
+ private mirrorTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
|
/** 当前会话的消息流(派生 Observable) */
|
|
|
readonly activeMessages$: Observable<AssistantMessage[]> = combineLatest([
|
|
|
@@ -110,6 +145,8 @@ export class AssistantService {
|
|
|
private llm: LlmService,
|
|
|
private zone: NgZone,
|
|
|
private appRef: ApplicationRef, // 用于 LLM 响应后强制 CD(绕开 fetch + zone 的兼容性坑)
|
|
|
+ private cloudStorage: CloudSessionStorageService,
|
|
|
+ private storageGovernance: StorageGovernanceService,
|
|
|
) {
|
|
|
this.restore();
|
|
|
}
|
|
|
@@ -158,6 +195,10 @@ export class AssistantService {
|
|
|
if (text.trim()) this.send(text.trim());
|
|
|
}
|
|
|
|
|
|
+ setIpOperatorContext(context: IpOperatorAssistantContext): void {
|
|
|
+ this.ipOperatorContext = { ...context };
|
|
|
+ }
|
|
|
+
|
|
|
/** 新建空会话并切到它 */
|
|
|
newConversation(): string {
|
|
|
const id = this.genId('c');
|
|
|
@@ -194,6 +235,7 @@ export class AssistantService {
|
|
|
this.activeId$.next(fallback);
|
|
|
}
|
|
|
this.persist();
|
|
|
+ this.mirrorDelete(id);
|
|
|
}
|
|
|
|
|
|
/** 重命名会话(暂未在 UI 暴露,预留) */
|
|
|
@@ -247,7 +289,10 @@ export class AssistantService {
|
|
|
.map((m) => ({ role: m.role, content: m.content }));
|
|
|
|
|
|
const messages: ChatMessage[] = [
|
|
|
- { role: 'system', content: SYSTEM_PROMPT },
|
|
|
+ {
|
|
|
+ role: 'system',
|
|
|
+ content: `${SYSTEM_PROMPT}\n\n## 当前页面与运营上下文\n${JSON.stringify(this.ipOperatorContext, null, 2)}`,
|
|
|
+ },
|
|
|
...history,
|
|
|
];
|
|
|
|
|
|
@@ -360,6 +405,10 @@ export class AssistantService {
|
|
|
const arr: AssistantSuggestion[] = Array.isArray(parsed.suggest) ? parsed.suggest : [];
|
|
|
const cleaned = arr
|
|
|
.filter((s) => s && typeof s.tab === 'string' && typeof s.label === 'string')
|
|
|
+ .map((s) => ({
|
|
|
+ ...s,
|
|
|
+ risk: ['low', 'medium', 'blocked'].includes(String(s.risk)) ? s.risk : 'low',
|
|
|
+ }))
|
|
|
.slice(0, 3);
|
|
|
const stripped = raw.slice(0, lastMatch.index).trim();
|
|
|
return { text: stripped, suggestions: cleaned.length ? cleaned : undefined };
|
|
|
@@ -370,61 +419,162 @@ export class AssistantService {
|
|
|
|
|
|
// ============== 持久化 ==============
|
|
|
|
|
|
+ async refreshConversationsFromCloud(): Promise<Conversation[]> {
|
|
|
+ try {
|
|
|
+ const entities = await this.cloudStorage.list<Conversation>(ASSISTANT_THREAD_ENTITY_TYPE, 'active', MAX_CONVS);
|
|
|
+ const cloudConversations = entities
|
|
|
+ .map((entity) => this.normalizeConversation(entity.data))
|
|
|
+ .filter((item): item is Conversation => !!item);
|
|
|
+ if (!cloudConversations.length) return this.conversations$.value;
|
|
|
+
|
|
|
+ const merged = new Map(this.conversations$.value.map((item) => [item.id, item]));
|
|
|
+ for (const item of cloudConversations) {
|
|
|
+ const local = merged.get(item.id);
|
|
|
+ const keepLocal = local && Number(local.updated || 0) > Number(item.updated || 0);
|
|
|
+ merged.set(item.id, keepLocal ? local : item);
|
|
|
+ }
|
|
|
+ const next = this.sortConversations(Array.from(merged.values())).slice(0, MAX_CONVS);
|
|
|
+ this.conversations$.next(next);
|
|
|
+ const activeId = this.activeId$.value;
|
|
|
+ if (!activeId || !next.some((item) => item.id === activeId)) {
|
|
|
+ this.activeId$.next(next[0]?.id || null);
|
|
|
+ }
|
|
|
+ this.persistActive();
|
|
|
+ return next;
|
|
|
+ } catch (err: any) {
|
|
|
+ console.warn('[assistant] 云端会话刷新失败,继续使用当前内存/旧只读数据:', err?.message || err);
|
|
|
+ return this.conversations$.value;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
private restore(): void {
|
|
|
try {
|
|
|
- // 1. 先尝试新格式
|
|
|
+ const governedActive = this.storageGovernance.readJson<string>(ACTIVE_CACHE_KEY);
|
|
|
+ if (governedActive) this.activeId$.next(governedActive);
|
|
|
+
|
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
|
if (raw) {
|
|
|
const arr = JSON.parse(raw);
|
|
|
if (Array.isArray(arr)) {
|
|
|
- this.conversations$.next(arr.slice(0, MAX_CONVS));
|
|
|
- const savedActive = localStorage.getItem(ACTIVE_KEY);
|
|
|
- if (savedActive && arr.find((c: Conversation) => c.id === savedActive)) {
|
|
|
+ const conversations = this.sortConversations(
|
|
|
+ arr.map((item) => this.normalizeConversation(item)).filter(Boolean) as Conversation[],
|
|
|
+ ).slice(0, MAX_CONVS);
|
|
|
+ this.conversations$.next(conversations);
|
|
|
+ const savedActive = governedActive || localStorage.getItem(ACTIVE_KEY);
|
|
|
+ if (savedActive && conversations.find((c) => c.id === savedActive)) {
|
|
|
this.activeId$.next(savedActive);
|
|
|
- } else if (arr.length > 0) {
|
|
|
- this.activeId$.next(arr[0].id);
|
|
|
+ } else if (conversations.length > 0) {
|
|
|
+ this.activeId$.next(conversations[0].id);
|
|
|
}
|
|
|
+ this.refreshConversationsFromCloud();
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
- // 2. 兼容 v1 的扁平消息数组:升级为单一会话
|
|
|
+
|
|
|
const legacy = localStorage.getItem(LEGACY_KEY);
|
|
|
if (legacy) {
|
|
|
const arr = JSON.parse(legacy);
|
|
|
if (Array.isArray(arr) && arr.length > 0) {
|
|
|
- const conv: Conversation = {
|
|
|
+ const conv = this.normalizeConversation({
|
|
|
id: this.genId('c'),
|
|
|
title: this.summarizeTitle(arr.find((m: any) => m?.role === 'user')?.content || '历史对话'),
|
|
|
messages: arr,
|
|
|
created: arr[0]?.ts || Date.now(),
|
|
|
updated: arr[arr.length - 1]?.ts || Date.now(),
|
|
|
- };
|
|
|
- this.conversations$.next([conv]);
|
|
|
- this.activeId$.next(conv.id);
|
|
|
- this.persist();
|
|
|
- localStorage.removeItem(LEGACY_KEY);
|
|
|
+ });
|
|
|
+ if (conv) {
|
|
|
+ this.conversations$.next([conv]);
|
|
|
+ this.activeId$.next(conv.id);
|
|
|
+ this.persist();
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
- } catch {}
|
|
|
+ this.refreshConversationsFromCloud();
|
|
|
+ } catch {
|
|
|
+ this.refreshConversationsFromCloud();
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
private persist(): void {
|
|
|
try {
|
|
|
- // 过滤 pending 后再存
|
|
|
- const arr = this.conversations$.value.map((c) => ({
|
|
|
- ...c,
|
|
|
- messages: c.messages.filter((m) => !m.pending),
|
|
|
- }));
|
|
|
- localStorage.setItem(STORAGE_KEY, JSON.stringify(arr));
|
|
|
+ const arr = this.conversations$.value.map((c) => this.sanitizedConversation(c));
|
|
|
+ this.conversations$.next(this.sortConversations(arr).slice(0, MAX_CONVS));
|
|
|
this.persistActive();
|
|
|
+ this.scheduleMirror();
|
|
|
} catch {}
|
|
|
}
|
|
|
|
|
|
private persistActive(): void {
|
|
|
try {
|
|
|
const id = this.activeId$.value;
|
|
|
- if (id) localStorage.setItem(ACTIVE_KEY, id);
|
|
|
- else localStorage.removeItem(ACTIVE_KEY);
|
|
|
+ if (id) this.storageGovernance.writeJson(ACTIVE_CACHE_KEY, id, 'uiPreference');
|
|
|
+ else this.storageGovernance.remove(ACTIVE_CACHE_KEY);
|
|
|
} catch {}
|
|
|
}
|
|
|
+
|
|
|
+ private scheduleMirror(): void {
|
|
|
+ if (this.mirrorTimer) clearTimeout(this.mirrorTimer);
|
|
|
+ this.mirrorTimer = setTimeout(() => {
|
|
|
+ this.mirrorTimer = null;
|
|
|
+ this.mirrorConversations();
|
|
|
+ }, 800);
|
|
|
+ }
|
|
|
+
|
|
|
+ private mirrorConversations(): void {
|
|
|
+ const conversations = this.conversations$.value.map((item) => this.sanitizedConversation(item));
|
|
|
+ for (const conversation of conversations) {
|
|
|
+ this.cloudStorage.upsert(ASSISTANT_THREAD_ENTITY_TYPE, conversation.id, conversation).catch((err) => {
|
|
|
+ console.warn('[assistant] 云端同步会话失败,当前会话仅保留在内存或临时存储:', err?.message || err);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private mirrorDelete(id: string): void {
|
|
|
+ this.cloudStorage.delete(ASSISTANT_THREAD_ENTITY_TYPE, id).catch((err) => {
|
|
|
+ console.warn('[assistant] 云端删除会话失败:', err?.message || err);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private normalizeConversation(value: unknown): Conversation | null {
|
|
|
+ const row = value as Partial<Conversation> | null;
|
|
|
+ if (!row || !row.id) return null;
|
|
|
+ const now = Date.now();
|
|
|
+ const messages = Array.isArray(row.messages)
|
|
|
+ ? row.messages
|
|
|
+ .map((message) => this.normalizeMessage(message))
|
|
|
+ .filter((message): message is AssistantMessage => !!message)
|
|
|
+ .slice(-MAX_MSGS_PER_CONV)
|
|
|
+ : [];
|
|
|
+ return {
|
|
|
+ id: String(row.id),
|
|
|
+ title: String(row.title || this.summarizeTitle(messages.find((item) => item.role === 'user')?.content || '历史对话')),
|
|
|
+ messages,
|
|
|
+ created: Number(row.created || messages[0]?.ts || now),
|
|
|
+ updated: Number(row.updated || messages[messages.length - 1]?.ts || now),
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ private normalizeMessage(value: unknown): AssistantMessage | null {
|
|
|
+ const row = value as Partial<AssistantMessage> | null;
|
|
|
+ if (!row || !row.id || !row.role) return null;
|
|
|
+ return {
|
|
|
+ id: String(row.id),
|
|
|
+ role: row.role === 'assistant' ? 'assistant' : 'user',
|
|
|
+ content: String(row.content || ''),
|
|
|
+ suggestions: Array.isArray(row.suggestions) ? row.suggestions : undefined,
|
|
|
+ ts: Number(row.ts || Date.now()),
|
|
|
+ pending: false,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ private sanitizedConversation(conversation: Conversation): Conversation {
|
|
|
+ return {
|
|
|
+ ...conversation,
|
|
|
+ messages: conversation.messages.filter((message) => !message.pending).slice(-MAX_MSGS_PER_CONV),
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ private sortConversations(conversations: Conversation[]): Conversation[] {
|
|
|
+ return [...conversations].sort((a, b) => Number(b.updated || 0) - Number(a.updated || 0));
|
|
|
+ }
|
|
|
}
|