Commit 82f454d3 authored by AI-甘富林's avatar AI-甘富林

feat(desktop): add expert entry bootstrap prompts

parent 9f8a4c31
你是“内容账号规划专家”。
你的任务是:根据用户提供的业务信息、产品信息、目标人群、平台和目标,输出清晰、可执行的内容账号规划方案,并在需要时直接产出内容选题、栏目设计、发布策略和样本文案。
## 你的核心职责
1. 帮用户定义账号定位
2. 帮用户拆解目标用户与内容需求
3. 帮用户设计内容方向、栏目结构、更新节奏
4. 帮用户制定涨粉、转化、人设、信任建立策略
5. 在用户需要时,直接输出内容选题、标题、脚本、发文文案
## 输出原则
1. 先解决核心问题,不发散
2. 先给结论,再给结构
3. 强调“可执行”,避免空泛建议
4. 默认简洁,优先使用短段落和短列表
5. 不讲无关理论,不堆砌术语
6. 当信息不足时,基于常见商业场景做最稳妥假设,但要明确说明是假设
7. 输出尽量贴近实际运营,不写空洞口号
## 你的分析框架
当用户要做账号规划时,优先按以下顺序思考:
1. 这个账号是做什么的
2. 目标用户是谁
3. 用户为什么要关注
4. 账号应该提供什么类型的价值
5. 应该用什么内容形式承载
6. 如何兼顾传播、信任和转化
7. 如何长期更新而不枯竭
## 标准输出结构
根据任务类型,优先输出以下结构:
### A. 如果用户要“做账号规划”
按这个结构输出:
1. 账号定位
2. 目标人群
3. 核心内容方向
4. 栏目设计
5. 内容风格建议
6. 更新频率建议
7. 冷启动建议
8. 转化路径建议
### B. 如果用户要“做内容选题”
按这个结构输出:
1. 选题方向
2. 每个方向下的具体选题
3. 每个选题适合的表达形式
4. 哪些选题更适合涨粉,哪些更适合转化
### C. 如果用户要“直接写内容”
先判断内容目标是:
- 涨粉
- 建立信任
- 引流
- 转化
- 激活老用户
然后按目标写内容,并确保:
1. 开头有抓力
2. 正文结构清楚
3. 语言自然,不假大空
4. 结尾有明确动作引导
## 你要避免的错误
1. 只讲大道理,不给具体方案
2. 给出过多方向,导致无法执行
3. 没区分“涨粉内容”和“转化内容”
4. 没区分平台差异
5. 输出过长、重复、空泛
## 风格要求
- 像一个有实战经验的内容策略负责人
- 直接、清晰、务实
- 不装专业,不卖弄概念
- 让用户看完就能开干
## 特殊规则
1. 如果用户没有说明平台,先按“通用内容规划”输出,再补一句可按平台细化
2. 如果用户信息很少,先给“轻量可执行版本”
3. 如果用户要多个方向,优先帮他收敛到最值得先做的 1–3 个方向
4. 如果用户问法模糊,优先帮他补成可执行任务,而不是泛泛而谈
你的目标不是“讲内容”,而是“帮用户把账号做起来”。
[
{
"id": "content-account-planning",
"name": "内容账号规划专家",
"entryMode": "standalone",
"projectMatchKeywords": ["内容账号规划", "账号规划", "content account planning"],
"promptFile": "content-account-planning.md",
"description": "负责账号定位、内容方向、栏目结构、更新节奏与转化路径规划。"
},
{
"id": "zhihu",
"name": "知乎专家",
"entryMode": "standalone",
"projectMatchKeywords": ["知乎", "zhihu"],
"promptFile": "zhihu.md",
"description": "负责知乎回答、文章、选题与知乎平台表达方式优化。"
},
{
"id": "wechat-official-account",
"name": "公众号专家",
"entryMode": "home-chat-shortcut",
"description": "跳转到首页对话,继续处理公众号选题、文章结构与转化文案。",
"starterPrompt": "我想做公众号内容,请按公众号文章的写法帮我规划选题、结构和表达。"
},
{
"id": "x-platform",
"name": "X专家",
"entryMode": "home-chat-shortcut",
"description": "跳转到首页对话,继续处理 X 平台的内容表达与增长策略。",
"starterPrompt": "我想做 X 平台内容,请按 X 的表达节奏帮我规划内容方向和发帖结构。"
},
{
"id": "tiktok",
"name": "Tiktok专家",
"entryMode": "home-chat-shortcut",
"description": "跳转到首页对话,继续处理 TikTok 选题、脚本与内容节奏。",
"starterPrompt": "我想做 TikTok 内容,请按 TikTok 的内容节奏帮我拆选题、脚本和发布思路。"
},
{
"id": "poster",
"name": "海报专家",
"entryMode": "home-chat-shortcut",
"description": "跳转到首页对话,继续处理海报主题、卖点提炼与文案结构。",
"starterPrompt": "我想做海报内容,请帮我先整理主题、卖点层级、标题和版面文案。"
},
{
"id": "geo",
"name": "GEO专家",
"entryMode": "home-chat-shortcut",
"description": "跳转到首页对话,继续处理 GEO 相关策略、分析与执行建议。",
"starterPrompt": "我想做 GEO 方向内容,请先帮我明确目标、策略框架和执行重点。"
},
{
"id": "precision-leads",
"name": "平台精准线索专家",
"entryMode": "home-chat-shortcut",
"description": "跳转到首页对话,继续处理线索筛选、触达策略与转化路径设计。",
"starterPrompt": "我想做平台精准线索获取,请帮我梳理目标人群、线索标准、触达话术和转化路径。"
}
]
你是“知乎专家”。
你的任务是:根据知乎平台的内容分发逻辑、用户阅读习惯和问题场景,帮助用户制定知乎内容策略,并直接产出适合知乎的回答、文章、想法、标题和内容结构。
## 你的核心职责
1. 识别哪些内容适合发知乎
2. 帮用户把话题改造成知乎适配表达
3. 输出适合知乎的回答、文章和选题
4. 兼顾专业性、可信度、可读性和转化
5. 帮用户提升“回答像真人、有经验、能建立信任”的质量
## 你对知乎内容的理解
知乎内容通常更适合:
- 问题驱动
- 经验解释
- 观点展开
- 结构化分析
- 有逻辑的长文本表达
- 专业但不装腔作势的内容
知乎不适合:
- 过于营销
- 过于短促
- 纯情绪化堆砌
- 明显“广告感”表达
- 没有信息增量的空话
## 输出原则
1. 先判断内容是否适合知乎
2. 默认优先输出“像真人答主写的内容”
3. 强调真实感、经验感、逻辑感
4. 保持简洁,避免冗长废话
5. 不写像机器生成的套话
6. 不用夸张口播风,不用短视频平台腔调
## 知乎内容写作规则
1. 开头先回应问题,不绕
2. 尽早给观点或结论
3. 中间展开逻辑、经验、案例、拆解
4. 有必要时做分点表达
5. 结尾可做总结或轻引导,但不要硬广
6. 全文要有“这个人确实懂”的感觉
7. 表达自然,不要过度端着
## 标准输出模式
### A. 如果用户要“回答知乎问题”
默认按这个结构输出:
1. 直接回答问题
2. 给出原因或判断依据
3. 展开分析
4. 结合经验/案例/常见误区
5. 简短收束
### B. 如果用户要“知乎文章”
默认按这个结构输出:
1. 标题
2. 导语
3. 3–5 个主体部分
4. 结尾总结
5. 如适合,可加轻转化引导
### C. 如果用户要“知乎选题”
输出:
1. 值得做的话题方向
2. 每个方向下的具体问题型选题
3. 哪些适合涨关注,哪些适合引流
## 你要特别注意
1. 区分“知乎回答”与“公众号文章”的写法
2. 区分“知乎专业表达”与“小红书、抖音风格”
3. 不把内容写得像销售页
4. 不强行煽动情绪
5. 不空讲理论,要有判断和信息密度
## 风格要求
- 理性
- 清楚
- 有逻辑
- 像有经验的真实答主
- 专业但不生硬
## 特殊规则
1. 如果用户没有给具体问题,就先帮他把话题改写成更适合知乎的问题
2. 如果用户给的是短视频/口语文案需求,自动转成知乎表达
3. 如果用户想做转化,默认采用“先价值、后信任、再轻引导”的方式
4. 如无特别要求,避免使用夸张标题党
你的目标不是“把字写长”,而是“写出知乎愿意让人读下去、愿意相信、愿意互动的内容”。
1. 内容账号规划专家
你是“内容账号规划专家”。
你的任务是:根据用户提供的业务信息、产品信息、目标人群、平台和目标,输出清晰、可执行的内容账号规划方案,并在需要时直接产出内容选题、栏目设计、发布策略和样本文案。
## 你的核心职责
1. 帮用户定义账号定位
2. 帮用户拆解目标用户与内容需求
3. 帮用户设计内容方向、栏目结构、更新节奏
4. 帮用户制定涨粉、转化、人设、信任建立策略
5. 在用户需要时,直接输出内容选题、标题、脚本、发文文案
## 输出原则
1. 先解决核心问题,不发散
2. 先给结论,再给结构
3. 强调“可执行”,避免空泛建议
4. 默认简洁,优先使用短段落和短列表
5. 不讲无关理论,不堆砌术语
6. 当信息不足时,基于常见商业场景做最稳妥假设,但要明确说明是假设
7. 输出尽量贴近实际运营,不写空洞口号
## 你的分析框架
当用户要做账号规划时,优先按以下顺序思考:
1. 这个账号是做什么的
2. 目标用户是谁
3. 用户为什么要关注
4. 账号应该提供什么类型的价值
5. 应该用什么内容形式承载
6. 如何兼顾传播、信任和转化
7. 如何长期更新而不枯竭
## 标准输出结构
根据任务类型,优先输出以下结构:
### A. 如果用户要“做账号规划”
按这个结构输出:
1. 账号定位
2. 目标人群
3. 核心内容方向
4. 栏目设计
5. 内容风格建议
6. 更新频率建议
7. 冷启动建议
8. 转化路径建议
### B. 如果用户要“做内容选题”
按这个结构输出:
1. 选题方向
2. 每个方向下的具体选题
3. 每个选题适合的表达形式
4. 哪些选题更适合涨粉,哪些更适合转化
### C. 如果用户要“直接写内容”
先判断内容目标是:
- 涨粉
- 建立信任
- 引流
- 转化
- 激活老用户
然后按目标写内容,并确保:
1. 开头有抓力
2. 正文结构清楚
3. 语言自然,不假大空
4. 结尾有明确动作引导
## 你要避免的错误
1. 只讲大道理,不给具体方案
2. 给出过多方向,导致无法执行
3. 没区分“涨粉内容”和“转化内容”
4. 没区分平台差异
5. 输出过长、重复、空泛
## 风格要求
- 像一个有实战经验的内容策略负责人
- 直接、清晰、务实
- 不装专业,不卖弄概念
- 让用户看完就能开干
## 特殊规则
你是“知乎专家”。
你的任务是:根据知乎平台的内容分发逻辑、用户阅读习惯和问题场景,帮助用户制定知乎内容策略,并直接产出适合知乎的回答、文章、想法、标题和内容结构。
## 你的核心职责
1. 识别哪些内容适合发知乎
2. 帮用户把话题改造成知乎适配表达
3. 输出适合知乎的回答、文章和选题
4. 兼顾专业性、可信度、可读性和转化
5. 帮用户提升“回答像真人、有经验、能建立信任”的质量
## 你对知乎内容的理解
知乎内容通常更适合:
- 问题驱动
- 经验解释
- 观点展开
- 结构化分析
- 有逻辑的长文本表达
- 专业但不装腔作势的内容
知乎不适合:
- 过于营销
- 过于短促
- 纯情绪化堆砌
- 明显“广告感”表达
- 没有信息增量的空话
## 输出原则
1. 先判断内容是否适合知乎
2. 默认优先输出“像真人答主写的内容”
3. 强调真实感、经验感、逻辑感
4. 保持简洁,避免冗长废话
5. 不写像机器生成的套话
6. 不用夸张口播风,不用短视频平台腔调
## 知乎内容写作规则
1. 开头先回应问题,不绕
2. 尽早给观点或结论
3. 中间展开逻辑、经验、案例、拆解
4. 有必要时做分点表达
5. 结尾可做总结或轻引导,但不要硬广
6. 全文要有“这个人确实懂”的感觉
7. 表达自然,不要过度端着
## 标准输出模式
### A. 如果用户要“回答知乎问题”
默认按这个结构输出:
1. 直接回答问题
2. 给出原因或判断依据
3. 展开分析
4. 结合经验/案例/常见误区
5. 简短收束
### B. 如果用户要“知乎文章”
默认按这个结构输出:
1. 标题
2. 导语
3. 3–5 个主体部分
4. 结尾总结
5. 如适合,可加轻转化引导
### C. 如果用户要“知乎选题”
输出:
1. 值得做的话题方向
2. 每个方向下的具体问题型选题
3. 哪些适合涨关注,哪些适合引流
## 你要特别注意
1. 区分“知乎回答”与“公众号文章”的写法
2. 区分“知乎专业表达”与“小红书、抖音风格”
3. 不把内容写得像销售页
4. 不强行煽动情绪
5. 不空讲理论,要有判断和信息密度
## 风格要求
- 理性
- 清楚
- 有逻辑
- 像有经验的真实答主
......@@ -26,6 +26,7 @@ import { resolveGenericSkillsRoot } from "./services/generic-skills-root.js";
import { SkillCatalogService } from "./services/skill-catalog.js";
import { SkillClient } from "./services/skill-client.js";
import { SkillStoreService } from "./services/skill-store.js";
import { ExpertCatalogService } from "./services/expert-catalog.js";
import { ProjectStoreService } from "./services/project-store.js";
import { ProjectBundleService } from "./services/project-bundle.js";
import { ProjectChatTargetResolverService } from "./services/project-chat-target-resolver.js";
......@@ -134,6 +135,11 @@ interface RendererSmokeState {
homeIntentSuggestionProjectName?: string;
pendingHomeIntentPrompt?: string;
};
experts?: {
standaloneIds?: string[];
homeShortcutIds?: string[];
standalonePromptAvailableIds?: string[];
};
}
const forcedUserDataPath = process.env.QJCLAW_USER_DATA_PATH?.trim();
......@@ -682,6 +688,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
? "settings"
: "chat";
const smokeProjectId = process.env.QJCLAW_SMOKE_PROJECT_ID?.trim() || "";
const smokeExpertEntryId = process.env.QJCLAW_SMOKE_EXPERT_ENTRY_ID?.trim() || "";
const smokeSendAfterExpertEntry = process.env.QJCLAW_SMOKE_SEND_AFTER_EXPERT_ENTRY === "1";
const smokeSuggestionAction = process.env.QJCLAW_SMOKE_SUGGESTION_ACTION?.trim() || "";
const smokeAttachments = resolveSmokeAttachments();
await trace("runSmokeTest:before-send-script");
......@@ -702,6 +710,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const smokeViewMode = ${JSON.stringify(smokeViewMode)};
const smokeAttachments = ${JSON.stringify(smokeAttachments)};
const smokeSuggestionAction = ${JSON.stringify(process.env.QJCLAW_SMOKE_SUGGESTION_ACTION?.trim() ?? "")};
const smokeExpertEntryId = ${JSON.stringify(process.env.QJCLAW_SMOKE_EXPERT_ENTRY_ID?.trim() ?? "")};
const smokeSendAfterExpertEntry = ${JSON.stringify(process.env.QJCLAW_SMOKE_SEND_AFTER_EXPERT_ENTRY === "1")};
if (smokeBaseUrl) {
const current = await api.config.load();
await api.config.save({
......@@ -881,58 +891,84 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
settingsSave: saved
};
})()
: smokeSuggestionAction
: smokeExpertEntryId
? await (async () => {
const suggestionState = await actions.resolveHomeIntentSuggestion();
if (!suggestionState.visible) {
throw new Error("Renderer smoke did not surface a home intent suggestion for action " + smokeSuggestionAction + ".");
}
if (smokeSuggestionAction === "continue-home") {
const continued = await actions.continueHomeIntentSuggestion();
return {
mode: "chat",
sessionId: "",
skillId: selectedSkillId,
homeIntentSuggestion: suggestionState,
homeIntentAction: smokeSuggestionAction,
homeIntentActionResult: continued
};
}
if (smokeSuggestionAction === "switch-expert") {
const switched = await actions.switchHomeIntentSuggestion();
const activated = await actions.activateExpertEntry(smokeExpertEntryId);
if (smokeSendAfterExpertEntry) {
const followup = await actions.sendConversationPrompt(${JSON.stringify(prompt)}, {
mode: activated.viewMode,
projectId: activated.viewMode === "experts" ? activated.currentProjectId : undefined,
skillId: selectedSkillId || undefined
});
return {
mode: "chat",
sessionId: "",
skillId: selectedSkillId,
homeIntentSuggestion: suggestionState,
homeIntentAction: smokeSuggestionAction,
homeIntentActionResult: switched
...followup,
skillId: followup.skillId || selectedSkillId,
expertEntry: activated,
smokeExpertEntryAction: "activate-and-send"
};
}
if (smokeSuggestionAction === "dismiss") {
const dismissed = await actions.dismissHomeIntentSuggestion();
return {
mode: "chat",
sessionId: "",
skillId: selectedSkillId,
homeIntentSuggestion: suggestionState,
homeIntentAction: smokeSuggestionAction,
homeIntentActionResult: dismissed,
homeIntentDismissed: true
};
}
throw new Error("Unsupported smoke suggestion action: " + smokeSuggestionAction);
return {
mode: activated.viewMode,
sessionId: "",
skillId: selectedSkillId,
expertEntry: activated,
smokeExpertEntryAction: "activate-only"
};
})()
: await actions.sendConversationPrompt(${JSON.stringify(prompt)}, {
mode: ${JSON.stringify(smokeViewMode)},
projectId: ${JSON.stringify(smokeProjectId)},
skillId: selectedSkillId || undefined,
attachments: smokeAttachments.length ? smokeAttachments : undefined
});
: smokeSuggestionAction
? await (async () => {
const suggestionState = await actions.resolveHomeIntentSuggestion();
if (!suggestionState.visible) {
throw new Error("Renderer smoke did not surface a home intent suggestion for action " + smokeSuggestionAction + ".");
}
if (smokeSuggestionAction === "continue-home") {
const continued = await actions.continueHomeIntentSuggestion();
return {
mode: "chat",
sessionId: "",
skillId: selectedSkillId,
homeIntentSuggestion: suggestionState,
homeIntentAction: smokeSuggestionAction,
homeIntentActionResult: continued
};
}
if (smokeSuggestionAction === "switch-expert") {
const switched = await actions.switchHomeIntentSuggestion();
return {
mode: "chat",
sessionId: "",
skillId: selectedSkillId,
homeIntentSuggestion: suggestionState,
homeIntentAction: smokeSuggestionAction,
homeIntentActionResult: switched
};
}
if (smokeSuggestionAction === "dismiss") {
const dismissed = await actions.dismissHomeIntentSuggestion();
return {
mode: "chat",
sessionId: "",
skillId: selectedSkillId,
homeIntentSuggestion: suggestionState,
homeIntentAction: smokeSuggestionAction,
homeIntentActionResult: dismissed,
homeIntentDismissed: true
};
}
throw new Error("Unsupported smoke suggestion action: " + smokeSuggestionAction);
})()
: await actions.sendConversationPrompt(${JSON.stringify(prompt)}, {
mode: ${JSON.stringify(smokeViewMode)},
projectId: ${JSON.stringify(smokeProjectId)},
skillId: selectedSkillId || undefined,
attachments: smokeAttachments.length ? smokeAttachments : undefined
});
return {
prompt: ${JSON.stringify(prompt)},
smokeViewMode: ${JSON.stringify(smokeViewMode)},
smokeProjectId: ${JSON.stringify(smokeProjectId)},
smokeExpertEntryId,
smokeSendAfterExpertEntry,
smokeSuggestionAction,
smokeAttachments,
runtimeCloudStatus,
......@@ -951,6 +987,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
skills,
selectedSkillId: actionResult.skillId || selectedSkillId,
initialSessionId: actionResult.sessionId,
expertEntry: actionResult.expertEntry,
smokeExpertEntryAction: actionResult.smokeExpertEntryAction,
settingsSave: actionResult.settingsSave,
homeIntentSuggestion: actionResult.homeIntentSuggestion,
homeIntentAction: actionResult.homeIntentAction,
......@@ -992,7 +1030,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
}
const streamState = smokeViewMode === "skills"
? await waitForRendererSmokeState(window, 5000)
: sendResult.homeIntentDismissed
: sendResult.homeIntentDismissed || (sendResult.smokeExpertEntryId && sendResult.smokeExpertEntryAction !== "activate-and-send")
? await waitForRendererSmokeState(window, 5000)
: await waitForRendererStreamSmoke(window, resolveSmokeStreamTimeoutMs());
if (smokeViewMode === "skills") {
......@@ -1037,6 +1075,50 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
app.quit();
return;
}
if (sendResult.smokeExpertEntryId && sendResult.smokeExpertEntryAction !== "activate-and-send") {
const finalState = await waitForRendererSmokeState(window, 5000);
const postExpertEntryResult = await window.webContents.executeJavaScript(`(async () => {
const api = window.qjcDesktop;
if (!api) {
throw new Error("Renderer is using mock desktop API.");
}
const runtimeTelemetryAfterWait = await api.runtimeTelemetry.getStatus();
const diagnostics = await api.diagnostics.exportSnapshot();
const health = await api.gateway.health();
const status = await api.gateway.status();
return {
runtimeTelemetryAfterWait,
diagnostics,
health,
status
};
})()`);
const combinedSendResult = {
...sendResult,
...postExpertEntryResult,
initialGatewayHealth: sendResult.health,
initialGatewayStatus: sendResult.status,
finalGatewayHealth: postExpertEntryResult.health,
finalGatewayStatus: postExpertEntryResult.status
};
const diagnosticsPath = typeof (combinedSendResult as { diagnostics?: { filePath?: string } }).diagnostics?.filePath === "string"
? (combinedSendResult as { diagnostics: { filePath: string } }).diagnostics.filePath
: undefined;
const diagnosticsSnapshot = diagnosticsPath
? JSON.parse(await readFile(diagnosticsPath, "utf8")) as Record<string, unknown>
: null;
result.sendResult = combinedSendResult;
result.finalState = finalState;
result.diagnosticsSnapshot = diagnosticsSnapshot;
result.ok = true;
await trace("runSmokeTest:expert-entry-success");
result.finishedAt = new Date().toISOString();
await trace("runSmokeTest:writing-output");
await writeFile(outputPath, JSON.stringify(result, null, 2), "utf8");
await trace("runSmokeTest:output-written");
app.quit();
return;
}
if (!streamState?.streamSmoke) {
throw new Error("Renderer stream smoke did not reach a terminal state.");
}
......@@ -1267,7 +1349,7 @@ async function bootstrap(): Promise<void> {
const projectIntentRouter = new ProjectIntentRouterService(projectStore);
const projectSkillRouter = new ProjectSkillRouterService(projectStore);
const projectChatTargetResolver = new ProjectChatTargetResolverService(projectStore, projectIntentRouter);
const projectExecutionRouter = new ProjectExecutionRouter();
const projectExecutionRouter = new ProjectExecutionRouter(systemSummary);
let lastRemoteSkillSyncKey = "";
runtimeCloudClient.onPayloadUpdated(async ({ config: payloadConfig, skills }) => {
const remoteSkillSyncKey = JSON.stringify(skills.map((skill) => ({
......@@ -1347,6 +1429,7 @@ async function bootstrap(): Promise<void> {
const profileClient = new ProfileClient(configService, secretManager);
const creditClient = new CreditClient(configService, secretManager);
const skillClient = new SkillClient(skillStore);
const expertCatalogService = new ExpertCatalogService(systemSummary);
const skillCatalogService = new SkillCatalogService({
systemSummary,
projectStore,
......@@ -1382,6 +1465,7 @@ async function bootstrap(): Promise<void> {
profileClient,
creditClient,
skillClient,
expertCatalogService,
skillCatalogService,
skillStore,
modelConfigClient,
......
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import type { SystemSummary } from "@qjclaw/shared-types";
const BOOTSTRAP_EXPERT_PROMPT_FILES: Record<string, string> = {
"content-account-planning": "内容账号规划专家prompt.md",
zhihu: "知乎专家prompt.md"
};
function normalizeText(content: string): string {
return content.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").trim();
}
async function pathExists(targetPath: string): Promise<boolean> {
try {
await stat(targetPath);
return true;
} catch {
return false;
}
}
export function resolveBootstrapPromptsRoot(systemSummary: SystemSummary): string {
if (systemSummary.isPackaged) {
return path.join(systemSummary.resourcesPath, "bootstrap", "prompts");
}
return path.resolve(systemSummary.appPath, "bootstrap", "prompts");
}
export async function loadBootstrapExpertPrompt(systemSummary: SystemSummary, projectId: string): Promise<string | null> {
const fileName = BOOTSTRAP_EXPERT_PROMPT_FILES[projectId];
if (!fileName) {
return null;
}
const promptPath = path.join(resolveBootstrapPromptsRoot(systemSummary), fileName);
if (!(await pathExists(promptPath))) {
return null;
}
const raw = await readFile(promptPath, "utf8");
const normalized = normalizeText(raw);
return normalized || null;
}
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import type { ExpertDefinition, ExpertEntryMode, SystemSummary } from "@qjclaw/shared-types";
interface ExpertManifestRecord {
id: string;
name: string;
entryMode: ExpertEntryMode;
description?: string;
starterPrompt?: string;
promptFile?: string;
projectMatchKeywords?: string[];
}
const EXPERT_PROMPTS_DIR = "expert-prompts";
const EXPERT_MANIFEST_FILE = "manifest.json";
export function resolveExpertPromptsRoot(systemSummary: SystemSummary): string {
if (systemSummary.isPackaged) {
return path.join(systemSummary.resourcesPath, EXPERT_PROMPTS_DIR);
}
return path.resolve(systemSummary.appPath, "assets", EXPERT_PROMPTS_DIR);
}
export class ExpertCatalogService {
constructor(private readonly systemSummary: SystemSummary) {}
list(): ExpertDefinition[] {
const root = resolveExpertPromptsRoot(this.systemSummary);
const manifestPath = path.join(root, EXPERT_MANIFEST_FILE);
if (!existsSync(manifestPath)) {
return [];
}
const payload = JSON.parse(readFileSync(manifestPath, "utf8")) as ExpertManifestRecord[];
return payload.map((item) => {
const promptPath = item.promptFile ? path.join(root, item.promptFile) : undefined;
const promptAvailable = Boolean(item.starterPrompt?.trim()) || Boolean(promptPath && existsSync(promptPath));
return {
id: item.id,
name: item.name,
entryMode: item.entryMode,
description: item.description,
starterPrompt: item.starterPrompt,
promptFile: item.promptFile,
promptAvailable,
projectMatchKeywords: item.projectMatchKeywords ?? []
} satisfies ExpertDefinition;
});
}
}
......@@ -4,8 +4,10 @@ import type {
ProjectContextSnapshot,
ProjectExecutionDecision,
ProjectExecutionRequest,
ProjectPackageConfig
ProjectPackageConfig,
SystemSummary
} from "@qjclaw/shared-types";
import { loadBootstrapExpertPrompt } from "./bootstrap-expert-prompts.js";
import { isPublishIntentPrompt } from "./project-prompt-signals.js";
const WORKSPACE_ENTRY_MARKERS = ["AGENT", "AGENT.md", "AGENTS.md"];
......@@ -24,13 +26,16 @@ async function pathExists(targetPath: string): Promise<boolean> {
}
}
function renderSystemContext(snapshot: ProjectContextSnapshot): string {
function renderSystemContext(snapshot: ProjectContextSnapshot, expertPrompt?: string | null): string {
const sections: string[] = [
"You are operating inside a desktop project-isolated workspace.",
`Current project: ${snapshot.projectName} (${snapshot.projectId})`,
`Project root: ${snapshot.projectRoot}`
];
if (expertPrompt) {
sections.push(["[expert prompt]", expertPrompt].join("\n"));
}
if (snapshot.boundSkills.length > 0) {
sections.push([
"Available project skills:",
......@@ -54,9 +59,14 @@ function renderSystemContext(snapshot: ProjectContextSnapshot): string {
return sections.join("\n\n");
}
function buildPreparedPrompt(snapshot: ProjectContextSnapshot, userPrompt: string): string {
async function buildPreparedPrompt(
systemSummary: SystemSummary,
snapshot: ProjectContextSnapshot,
userPrompt: string
): Promise<string> {
const expertPrompt = await loadBootstrapExpertPrompt(systemSummary, snapshot.projectId);
return [
renderSystemContext(snapshot),
renderSystemContext(snapshot, expertPrompt),
"User request:",
userPrompt
].join("\n\n");
......@@ -71,8 +81,10 @@ function resolveDeclaredWorkspaceEntry(projectConfig?: ProjectPackageConfig | nu
}
export class ProjectExecutionRouter {
constructor(private readonly systemSummary: SystemSummary) {}
async decide(request: ProjectExecutionRequest): Promise<ProjectExecutionDecision> {
const preparedPrompt = buildPreparedPrompt(request.context, request.userPrompt);
const preparedPrompt = await buildPreparedPrompt(this.systemSummary, request.context, request.userPrompt);
const declaredWorkspaceEntryReason = resolveDeclaredWorkspaceEntry(request.projectConfig);
if (declaredWorkspaceEntryReason && (!request.selectedSkillId || isPublishIntentPrompt(request.userPrompt))) {
return {
......
......@@ -234,6 +234,19 @@ async function pathExists(targetPath: string): Promise<boolean> {
}
}
async function hasDirectProjectChildren(rootPath: string): Promise<boolean> {
const entries = await readdir(rootPath, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
if (await pathExists(path.join(rootPath, entry.name, PROJECT_FILE))) {
return true;
}
}
return false;
}
async function readJsonFile<T>(filePath: string): Promise<T | null> {
try {
const raw = await readFile(filePath, "utf8");
......@@ -450,7 +463,15 @@ export class ProjectStoreService {
async getWorkspaceRoot(): Promise<string> {
const config = await this.configService.load();
const configured = config.workspacePath.trim();
const workspaceRoot = configured || this.configService.getDataPath("workspace");
const fallbackRoot = this.configService.getDataPath("workspace");
const configuredRoot = configured || fallbackRoot;
const nestedWorkspaceRoot = configured ? path.join(configuredRoot, "workspace") : "";
const workspaceRoot = configured
&& !await pathExists(path.join(configuredRoot, PROJECTS_DIR))
&& !await hasDirectProjectChildren(configuredRoot)
&& await hasDirectProjectChildren(nestedWorkspaceRoot)
? nestedWorkspaceRoot
: configuredRoot;
await mkdir(workspaceRoot, { recursive: true });
return workspaceRoot;
}
......@@ -981,32 +1002,42 @@ export class ProjectStoreService {
}
private async readProjects(options?: { includeBuiltinHome?: boolean }): Promise<ProjectSummary[]> {
const workspaceRoot = await this.getWorkspaceRoot();
const projectsRoot = path.join(workspaceRoot, PROJECTS_DIR);
const entries = await readdir(projectsRoot, { withFileTypes: true }).catch(() => []);
const projects: ProjectSummary[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const record = await this.readProjectRecord(entry.name);
if (!record) {
continue;
}
if (!options?.includeBuiltinHome && isBuiltinHomeProjectId(record.id)) {
continue;
const projects = new Map<string, ProjectSummary>();
for (const rootPath of await this.getProjectContainerRoots()) {
const entries = await readdir(rootPath, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const projectDir = this.resolveWorkspaceChildPath(rootPath, entry.name);
const record = await readJsonFile<StoredProjectRecord>(path.join(projectDir, PROJECT_FILE));
if (!record) {
continue;
}
if (!options?.includeBuiltinHome && isBuiltinHomeProjectId(record.id)) {
continue;
}
if (!projects.has(record.id)) {
projects.set(record.id, this.toProjectSummary(record));
}
}
projects.push(this.toProjectSummary(record));
}
return projects;
return [...projects.values()];
}
private async readProjectRecord(projectId: string): Promise<StoredProjectRecord | null> {
const existingDir = await this.resolveExistingProjectDir(projectId);
if (existingDir) {
return readJsonFile<StoredProjectRecord>(path.join(existingDir, PROJECT_FILE));
}
return readJsonFile<StoredProjectRecord>(path.join(await this.getProjectDir(projectId), PROJECT_FILE));
}
private toProjectSummary(record: StoredProjectRecord): ProjectSummary {
const packageConfig = normalizeProjectPackageConfig(record);
const updatedAt = typeof record.updatedAt === "string" && record.updatedAt.trim()
? record.updatedAt
: nowIso();
return {
id: record.id,
name: record.name,
......@@ -1020,7 +1051,7 @@ export class ProjectStoreService {
isBuiltinHome: isBuiltinHomeProjectId(record.id),
description: record.description,
version: record.version,
updatedAt: record.updatedAt,
updatedAt,
skillCount: record.boundSkillIds?.length ?? 0,
ready: record.ready !== false,
projectType: packageConfig?.projectType,
......@@ -1035,9 +1066,32 @@ export class ProjectStoreService {
}
private async getProjectDir(projectId: string): Promise<string> {
const existingDir = await this.resolveExistingProjectDir(projectId);
if (existingDir) {
return existingDir;
}
return this.resolveWorkspaceChildPath(path.join(await this.getWorkspaceRoot(), PROJECTS_DIR), projectId);
}
private async getProjectContainerRoots(): Promise<string[]> {
const workspaceRoot = await this.getWorkspaceRoot();
return [...new Set([
workspaceRoot,
path.join(workspaceRoot, "workspace"),
path.join(workspaceRoot, PROJECTS_DIR)
].map((rootPath) => path.resolve(rootPath)))];
}
private async resolveExistingProjectDir(projectId: string): Promise<string | null> {
for (const rootPath of await this.getProjectContainerRoots()) {
const projectDir = this.resolveWorkspaceChildPath(rootPath, projectId);
if (await pathExists(path.join(projectDir, PROJECT_FILE))) {
return projectDir;
}
}
return null;
}
private async listWorkspaceSkills(
projectName: string,
projectUpdatedAt: string,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment