Compare commits
6 Commits
c83c36baa6
...
ae46cedd37
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae46cedd37 | ||
|
|
f7fd53c9a2 | ||
|
|
4dde0e31b1 | ||
|
|
01e1083e5e | ||
|
|
f37357096e | ||
|
|
533375e8ca |
@@ -284,10 +284,24 @@ iconify-icon {
|
||||
}
|
||||
|
||||
.svg-content-wrapper {
|
||||
/* flex: 1; */
|
||||
/* margin: 1rem; */
|
||||
display: inline-block;
|
||||
/* text-align: center; */
|
||||
transform-origin: center top;
|
||||
}
|
||||
|
||||
.svg-content-wrapper--mermaid {
|
||||
flex: 1;
|
||||
margin: 1rem;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
|
||||
/* 小手摇摆动画 */
|
||||
@keyframes wave {
|
||||
0%, 100% {transform: translateX(0px) rotate(90deg);}
|
||||
@@ -329,6 +343,25 @@ iconify-icon {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.code-modal-content {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.code-viewer {
|
||||
background: #0f172a;
|
||||
color: #f8fafc;
|
||||
border: 2px solid #000;
|
||||
padding: 16px;
|
||||
font-family: 'Fira Code', 'Courier New', Courier, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
border-radius: 6px;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 表单输入框样式 */
|
||||
.config-input {
|
||||
width: 100%;
|
||||
@@ -414,3 +447,12 @@ iconify-icon {
|
||||
#send-button.terminate-mode {
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.flowchart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
59
index.html
59
index.html
@@ -4,12 +4,10 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>产品画布 / SWOT分析</title>
|
||||
<script src="https://cdn.tailwindcss.com/3.4.1"></script>
|
||||
<script src="https://code.iconify.design/iconify-icon/2.1.0/iconify-icon.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap" rel="stylesheet">
|
||||
<script src="libs/css/tailwind.css"></script>
|
||||
<script src="libs/js/iconify-icon.min.js"></script>
|
||||
<script src="libs/js/marked.min.js"></script>
|
||||
<link rel="stylesheet" href="libs/css/inter-font.css">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body class="bg-gray-100 h-screen flex flex-col">
|
||||
@@ -29,17 +27,9 @@
|
||||
<iconify-icon icon="ph:gear-six-fill" class="text-2xl"></iconify-icon>
|
||||
</button>
|
||||
|
||||
<span class="text-white font-bold text-sm">点击切换模式</span>
|
||||
<span class="text-white font-bold text-sm">点击切换模块</span>
|
||||
<iconify-icon icon="ph:hand-pointing-fill" class="text-2xl text-yellow-300 wave-hand"></iconify-icon>
|
||||
|
||||
<button id="canvas-mode-btn" class="mode-btn-active bg-white text-orange-600 px-4 py-2 font-bold border-2 border-black hover:bg-orange-50 transition-all duration-200">
|
||||
<iconify-icon icon="ph:pen-nib-duotone" class="align-middle mr-1"></iconify-icon>
|
||||
产品画布
|
||||
</button>
|
||||
<button id="swot-mode-btn" class="mode-btn-inactive bg-white text-purple-600 px-4 py-2 font-bold border-2 border-black hover:bg-purple-50 transition-all duration-200">
|
||||
<iconify-icon icon="ph:chart-bar-duotone" class="align-middle mr-1"></iconify-icon>
|
||||
SWOT分析
|
||||
</button>
|
||||
<div id="module-button-group" class="flex items-center gap-2"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -204,8 +194,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代码查看模态窗 -->
|
||||
<div id="code-modal" class="modal-overlay">
|
||||
<div class="modal-content code-modal-content">
|
||||
<div class="bg-gradient-to-r from-indigo-600 to-purple-600 p-4 border-b-4 border-black flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<iconify-icon icon="ph:code-fill" class="text-3xl text-white"></iconify-icon>
|
||||
<h2 class="text-xl font-black text-white">生成代码</h2>
|
||||
</div>
|
||||
<button id="close-code-modal-btn" class="text-white hover:bg-white/20 p-2 transition-all">
|
||||
<iconify-icon icon="ph:x-bold" class="text-2xl"></iconify-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="font-bold text-gray-700">以下为当前图表对应的代码,可复制后用于自定义编辑。</p>
|
||||
<button id="copy-code-btn" class="px-4 py-2 bg-indigo-500 text-white font-bold border-2 border-black hover:bg-indigo-600 transition-all flex items-center gap-2">
|
||||
<iconify-icon icon="ph:copy-bold"></iconify-icon>
|
||||
复制代码
|
||||
</button>
|
||||
</div>
|
||||
<pre id="code-content" class="code-viewer"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 引入JavaScript文件 -->
|
||||
<script src="js/utils.js"></script>
|
||||
<script src="js/services/storage-service.js"></script>
|
||||
<script src="js/services/conversation-service.js"></script>
|
||||
<script src="js/core/module-registry.js"></script>
|
||||
<script src="js/modules/product-canvas.js"></script>
|
||||
<script src="js/modules/swot.js"></script>
|
||||
<script src="js/modules/echarts.js"></script>
|
||||
<script src="js/modules/mermaid.js"></script>
|
||||
<script src="libs/js/mermaid.min.js"></script>
|
||||
<script src="libs/js/svg-pan-zoom.min.js"></script>
|
||||
<script src="libs/js/echarts.min.js"></script>
|
||||
<script src="js/core/module-runtime.js"></script>
|
||||
<script src="js/core/app-shell.js"></script>
|
||||
<script src="js/apiclient.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
|
||||
175
js/apiclient.js
175
js/apiclient.js
@@ -9,12 +9,25 @@ class APIClient {
|
||||
key: '',
|
||||
model: ''
|
||||
};
|
||||
this.prompts = {
|
||||
canvas: '',
|
||||
swot: ''
|
||||
this.promptMap = {};
|
||||
this.promptFiles = {
|
||||
canvas: 'prompts/canvas-prompt.txt',
|
||||
swot: 'prompts/swot-prompt.txt',
|
||||
echarts: 'prompts/echarts-prompt.txt',
|
||||
mermaid: 'prompts/mermaid-prompt.txt'
|
||||
};
|
||||
this.promptFallbacks = {
|
||||
canvas: '你是一个专业的产品战略分析师,擅长创建产品画布。',
|
||||
swot: '你是一个专业的商业战略分析师,擅长进行SWOT分析。',
|
||||
echarts:
|
||||
'你是一个资深的数据可视化专家,精通将自然语言需求转化为 ECharts 配置对象,请输出结构化 JSON option。',
|
||||
mermaid:
|
||||
'你是一个资深的可视化工程师,擅长用 Mermaid 语法创建清晰的图示,请只输出一个 ```mermaid 代码块。',
|
||||
default:
|
||||
'你是一个可靠的智能助手,请直接回答用户的问题并提供结构化输出。'
|
||||
};
|
||||
this.loadConfig();
|
||||
this.loadPrompts();
|
||||
this.preloadPrompts(Object.keys(this.promptFiles));
|
||||
}
|
||||
|
||||
// 加载API配置
|
||||
@@ -25,21 +38,45 @@ class APIClient {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载系统提示词
|
||||
async loadPrompts() {
|
||||
preloadPrompts(keys = []) {
|
||||
keys.forEach((key) => {
|
||||
this.ensurePrompt(key).catch((error) =>
|
||||
console.warn(`预加载提示词 ${key} 失败:`, error)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async ensurePrompt(promptKey) {
|
||||
if (!promptKey) return '';
|
||||
if (this.promptMap[promptKey]) {
|
||||
return this.promptMap[promptKey];
|
||||
}
|
||||
const prompt = await this.fetchPrompt(promptKey);
|
||||
this.promptMap[promptKey] = prompt;
|
||||
return prompt;
|
||||
}
|
||||
|
||||
async fetchPrompt(promptKey) {
|
||||
const filePath = this.promptFiles[promptKey];
|
||||
const fallback =
|
||||
this.promptFallbacks[promptKey] ||
|
||||
'你是一个可靠的智能助手,请直接回答用户问题。';
|
||||
|
||||
if (!filePath) {
|
||||
console.warn(`未找到提示词 ${promptKey} 对应的文件配置`);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
// 加载产品画布提示词
|
||||
const canvasResponse = await fetch('prompts/canvas-prompt.txt');
|
||||
this.prompts.canvas = await canvasResponse.text();
|
||||
|
||||
// 加载SWOT分析提示词
|
||||
const swotResponse = await fetch('prompts/swot-prompt.txt');
|
||||
this.prompts.swot = await swotResponse.text();
|
||||
const response = await fetch(filePath);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
return text.trim() || fallback;
|
||||
} catch (error) {
|
||||
console.error('加载提示词失败:', error);
|
||||
// 使用默认提示词
|
||||
this.prompts.canvas = '你是一个专业的产品战略分析师,擅长创建产品画布。';
|
||||
this.prompts.swot = '你是一个专业的商业战略分析师,擅长进行SWOT分析。';
|
||||
console.warn(`加载提示词 ${promptKey} 失败:`, error);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +113,49 @@ class APIClient {
|
||||
}
|
||||
}
|
||||
|
||||
async buildMessagesForModule(manifest, userMessage, contextMessages = []) {
|
||||
const prompt =
|
||||
(manifest && manifest.promptKey
|
||||
? await this.ensurePrompt(manifest.promptKey)
|
||||
: null) || this.promptFallbacks.default;
|
||||
|
||||
return [
|
||||
{ role: 'system', content: prompt },
|
||||
...contextMessages,
|
||||
{ role: 'user', content: userMessage }
|
||||
];
|
||||
}
|
||||
|
||||
async generateModuleCompletion(
|
||||
manifest,
|
||||
userMessage,
|
||||
contextMessages = [],
|
||||
options = {}
|
||||
) {
|
||||
const messages = await this.buildMessagesForModule(
|
||||
manifest,
|
||||
userMessage,
|
||||
contextMessages
|
||||
);
|
||||
return this.sendChatMessage(messages, options);
|
||||
}
|
||||
|
||||
async generateModuleStream(
|
||||
manifest,
|
||||
userMessage,
|
||||
contextMessages = [],
|
||||
onChunk,
|
||||
onComplete,
|
||||
options = {}
|
||||
) {
|
||||
const messages = await this.buildMessagesForModule(
|
||||
manifest,
|
||||
userMessage,
|
||||
contextMessages
|
||||
);
|
||||
return this.sendChatMessageStream(messages, options, onChunk, onComplete);
|
||||
}
|
||||
|
||||
// 发送聊天请求
|
||||
async sendChatMessage(messages, options = {}) {
|
||||
if (!this.isConfigValid()) {
|
||||
@@ -127,46 +207,46 @@ class APIClient {
|
||||
|
||||
// 生成产品画布的专用方法
|
||||
async generateProductCanvas(userRequest, context = []) {
|
||||
const messages = [
|
||||
{ role: 'system', content: this.prompts.canvas },
|
||||
...context,
|
||||
{ role: 'user', content: userRequest }
|
||||
];
|
||||
|
||||
return await this.sendChatMessage(messages, { maxTokens: 18000 });
|
||||
return this.generateModuleCompletion(
|
||||
{ promptKey: 'canvas' },
|
||||
userRequest,
|
||||
context,
|
||||
{ maxTokens: 18000 }
|
||||
);
|
||||
}
|
||||
|
||||
// 生成SWOT分析的专用方法
|
||||
async generateSWOTAnalysis(userRequest, context = []) {
|
||||
const messages = [
|
||||
{ role: 'system', content: this.prompts.swot },
|
||||
...context,
|
||||
{ role: 'user', content: userRequest }
|
||||
];
|
||||
|
||||
return await this.sendChatMessage(messages, { maxTokens: 18000 });
|
||||
return this.generateModuleCompletion(
|
||||
{ promptKey: 'swot' },
|
||||
userRequest,
|
||||
context,
|
||||
{ maxTokens: 18000 }
|
||||
);
|
||||
}
|
||||
|
||||
// 流式生成产品画布
|
||||
async generateProductCanvasStream(userRequest, context = [], onChunk, onComplete) {
|
||||
const messages = [
|
||||
{ role: 'system', content: this.prompts.canvas },
|
||||
...context,
|
||||
{ role: 'user', content: userRequest }
|
||||
];
|
||||
|
||||
return this.sendChatMessageStream(messages, { maxTokens: 13000 }, onChunk, onComplete);
|
||||
return this.generateModuleStream(
|
||||
{ promptKey: 'canvas' },
|
||||
userRequest,
|
||||
context,
|
||||
onChunk,
|
||||
onComplete,
|
||||
{ maxTokens: 13000 }
|
||||
);
|
||||
}
|
||||
|
||||
// 流式生成SWOT分析
|
||||
async generateSWOTAnalysisStream(userRequest, context = [], onChunk, onComplete) {
|
||||
const messages = [
|
||||
{ role: 'system', content: this.prompts.swot },
|
||||
...context,
|
||||
{ role: 'user', content: userRequest }
|
||||
];
|
||||
|
||||
return this.sendChatMessageStream(messages, { maxTokens: 13000 }, onChunk, onComplete);
|
||||
return this.generateModuleStream(
|
||||
{ promptKey: 'swot' },
|
||||
userRequest,
|
||||
context,
|
||||
onChunk,
|
||||
onComplete,
|
||||
{ maxTokens: 13000 }
|
||||
);
|
||||
}
|
||||
|
||||
// 流式发送聊天请求
|
||||
@@ -233,7 +313,8 @@ class APIClient {
|
||||
throw new Error('没有找到用户消息');
|
||||
}
|
||||
|
||||
const mode = Utils.storage.get('currentMode', 'canvas');
|
||||
const activeModuleId = Utils.storage.get('tool-engine:activeModuleId', 'product-canvas');
|
||||
const mode = activeModuleId === 'swot' ? 'swot' : 'canvas';
|
||||
|
||||
if (mode === 'canvas') {
|
||||
return await this.generateProductCanvas(lastUserMessage.content, contextMessages.slice(0, -1));
|
||||
@@ -296,4 +377,4 @@ class APIClient {
|
||||
}
|
||||
|
||||
// 创建全局API客户端实例
|
||||
window.apiClient = new APIClient();
|
||||
window.apiClient = new APIClient();
|
||||
|
||||
1809
js/core/app-shell.js
Normal file
1809
js/core/app-shell.js
Normal file
File diff suppressed because it is too large
Load Diff
36
js/core/module-registry.js
Normal file
36
js/core/module-registry.js
Normal file
@@ -0,0 +1,36 @@
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
class ModuleRegistry {
|
||||
constructor() {
|
||||
this.modules = new Map();
|
||||
this.order = [];
|
||||
}
|
||||
|
||||
register(manifest) {
|
||||
if (!manifest || !manifest.id) {
|
||||
throw new Error('注册模块失败:缺少 id');
|
||||
}
|
||||
if (this.modules.has(manifest.id)) {
|
||||
console.warn(`模块 ${manifest.id} 已存在,将被覆盖`);
|
||||
} else {
|
||||
this.order.push(manifest.id);
|
||||
}
|
||||
this.modules.set(manifest.id, manifest);
|
||||
}
|
||||
|
||||
get(moduleId) {
|
||||
return this.modules.get(moduleId) || null;
|
||||
}
|
||||
|
||||
list() {
|
||||
return this.order.map((id) => this.modules.get(id));
|
||||
}
|
||||
|
||||
has(moduleId) {
|
||||
return this.modules.has(moduleId);
|
||||
}
|
||||
}
|
||||
|
||||
global.ModuleRegistry = new ModuleRegistry();
|
||||
})(window);
|
||||
181
js/core/module-runtime.js
Normal file
181
js/core/module-runtime.js
Normal file
@@ -0,0 +1,181 @@
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
const DEFAULT_STORAGE_KEYS = {
|
||||
history: 'history',
|
||||
artifacts: 'artifacts',
|
||||
ui: 'uiState'
|
||||
};
|
||||
|
||||
class ModuleRuntime {
|
||||
constructor({ registry, storageService, conversationService }) {
|
||||
if (!registry) throw new Error('ModuleRuntime 需要 ModuleRegistry');
|
||||
if (!storageService) throw new Error('ModuleRuntime 需要 StorageService');
|
||||
if (!conversationService)
|
||||
throw new Error('ModuleRuntime 需要 ConversationService');
|
||||
|
||||
this.registry = registry;
|
||||
this.storageService = storageService;
|
||||
this.conversationService = conversationService;
|
||||
this.moduleStates = new Map();
|
||||
this.activeModuleId = null;
|
||||
}
|
||||
|
||||
_storageKeys(manifest) {
|
||||
return {
|
||||
...DEFAULT_STORAGE_KEYS,
|
||||
...(manifest.storageKeys || {})
|
||||
};
|
||||
}
|
||||
|
||||
_namespace(manifest) {
|
||||
const namespace =
|
||||
manifest.storageNamespace || `module:${manifest.id || 'unknown'}`;
|
||||
return this.storageService.namespace(namespace);
|
||||
}
|
||||
|
||||
_ensureState(manifest) {
|
||||
if (this.moduleStates.has(manifest.id)) {
|
||||
return this.moduleStates.get(manifest.id);
|
||||
}
|
||||
|
||||
const store = this._namespace(manifest);
|
||||
const keys = this._storageKeys(manifest);
|
||||
|
||||
const state = {
|
||||
artifacts: store.get(keys.artifacts, {}),
|
||||
uiState: store.get(keys.ui, {}),
|
||||
currentArtifactId: null
|
||||
};
|
||||
if (state.uiState && state.uiState.__activeArtifact) {
|
||||
state.currentArtifactId = state.uiState.__activeArtifact;
|
||||
}
|
||||
|
||||
this.moduleStates.set(manifest.id, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
_persistState(manifest) {
|
||||
const store = this._namespace(manifest);
|
||||
const keys = this._storageKeys(manifest);
|
||||
const state = this._ensureState(manifest);
|
||||
|
||||
if (state.uiState) {
|
||||
state.uiState.__activeArtifact = state.currentArtifactId;
|
||||
}
|
||||
store.set(keys.artifacts, state.artifacts);
|
||||
store.set(keys.ui, state.uiState);
|
||||
}
|
||||
|
||||
getManifest(moduleId) {
|
||||
const manifest = this.registry.get(moduleId);
|
||||
if (!manifest) {
|
||||
throw new Error(`未找到模块 ${moduleId}`);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
listManifests() {
|
||||
return this.registry.list();
|
||||
}
|
||||
|
||||
activate(moduleId) {
|
||||
const manifest = this.getManifest(moduleId);
|
||||
this.activeModuleId = moduleId;
|
||||
const state = this._ensureState(manifest);
|
||||
const context = {
|
||||
manifest,
|
||||
state,
|
||||
history: this.conversationService.getHistory(manifest)
|
||||
};
|
||||
if (manifest.hooks && typeof manifest.hooks.onActivate === 'function') {
|
||||
try {
|
||||
manifest.hooks.onActivate(context);
|
||||
} catch (error) {
|
||||
console.warn(`执行模块 ${moduleId} onActivate 时出错:`, error);
|
||||
}
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
getActiveModule() {
|
||||
if (!this.activeModuleId) return null;
|
||||
return this.getManifest(this.activeModuleId);
|
||||
}
|
||||
|
||||
getState(moduleId) {
|
||||
const manifest = this.getManifest(moduleId);
|
||||
return this._ensureState(manifest);
|
||||
}
|
||||
|
||||
getArtifacts(moduleId) {
|
||||
const state = this.getState(moduleId);
|
||||
return state.artifacts;
|
||||
}
|
||||
|
||||
saveArtifact(moduleId, artifactId, payload) {
|
||||
const manifest = this.getManifest(moduleId);
|
||||
const state = this._ensureState(manifest);
|
||||
state.artifacts[artifactId] = payload;
|
||||
state.currentArtifactId = artifactId;
|
||||
this._persistState(manifest);
|
||||
return payload;
|
||||
}
|
||||
|
||||
removeArtifact(moduleId, artifactId) {
|
||||
const manifest = this.getManifest(moduleId);
|
||||
const state = this._ensureState(manifest);
|
||||
if (state.artifacts[artifactId]) {
|
||||
delete state.artifacts[artifactId];
|
||||
if (state.currentArtifactId === artifactId) {
|
||||
state.currentArtifactId = null;
|
||||
}
|
||||
this._persistState(manifest);
|
||||
}
|
||||
}
|
||||
|
||||
setActiveArtifact(moduleId, artifactId) {
|
||||
const manifest = this.getManifest(moduleId);
|
||||
const state = this._ensureState(manifest);
|
||||
state.currentArtifactId = artifactId;
|
||||
this._persistState(manifest);
|
||||
}
|
||||
|
||||
getActiveArtifactId(moduleId) {
|
||||
const state = this.getState(moduleId);
|
||||
return state.currentArtifactId || null;
|
||||
}
|
||||
|
||||
updateUiState(moduleId, patch) {
|
||||
const manifest = this.getManifest(moduleId);
|
||||
const state = this._ensureState(manifest);
|
||||
state.uiState = {
|
||||
...state.uiState,
|
||||
...patch
|
||||
};
|
||||
this._persistState(manifest);
|
||||
return state.uiState;
|
||||
}
|
||||
|
||||
getUiState(moduleId, defaultValue = {}) {
|
||||
const state = this.getState(moduleId);
|
||||
const uiState = { ...(state.uiState || {}) };
|
||||
delete uiState.__activeArtifact;
|
||||
return { ...defaultValue, ...uiState };
|
||||
}
|
||||
|
||||
getConversationService() {
|
||||
return this.conversationService;
|
||||
}
|
||||
|
||||
clearArtifacts(moduleId) {
|
||||
const manifest = this.getManifest(moduleId);
|
||||
const state = this._ensureState(manifest);
|
||||
state.artifacts = {};
|
||||
state.currentArtifactId = null;
|
||||
this._persistState(manifest);
|
||||
}
|
||||
}
|
||||
|
||||
global.ModuleRuntime = ModuleRuntime;
|
||||
})(window);
|
||||
78
js/modules/echarts.js
Normal file
78
js/modules/echarts.js
Normal file
@@ -0,0 +1,78 @@
|
||||
(function registerEChartsModule(global) {
|
||||
'use strict';
|
||||
|
||||
if (!global.ModuleRegistry) {
|
||||
throw new Error('ModuleRegistry 未初始化');
|
||||
}
|
||||
|
||||
const CODE_FENCE_REGEX = /```(?:json|js|javascript|echarts|option)?\s*([\s\S]*?)```/i;
|
||||
|
||||
const parseOptionText = (text) => {
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
try {
|
||||
// 尝试处理 JS 对象语法
|
||||
// eslint-disable-next-line no-new-func
|
||||
return new Function(`return (${text});`)();
|
||||
} catch (innerError) {
|
||||
console.warn('解析 ECharts 配置失败:', innerError);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const parseResponse = (content) => {
|
||||
const match = content.match(CODE_FENCE_REGEX);
|
||||
if (match) {
|
||||
const optionText = match[1].trim();
|
||||
return {
|
||||
optionText,
|
||||
option: parseOptionText(optionText),
|
||||
beforeText: content.substring(0, match.index).trim(),
|
||||
afterText: content.substring(match.index + match[0].length).trim()
|
||||
};
|
||||
}
|
||||
return {
|
||||
optionText: '',
|
||||
option: null,
|
||||
beforeText: content.trim(),
|
||||
afterText: ''
|
||||
};
|
||||
};
|
||||
|
||||
global.ModuleRegistry.register({
|
||||
id: 'echarts',
|
||||
label: 'ECharts 图表',
|
||||
icon: 'ph:chart-line-up-duotone',
|
||||
renderer: 'echarts',
|
||||
promptKey: 'echarts',
|
||||
storageNamespace: 'module:echarts',
|
||||
chat: {
|
||||
placeholder: '描述想生成的图表或调整需求,我会输出 ECharts 配置…',
|
||||
streamStartToken: '```json',
|
||||
contextWindow: 8
|
||||
},
|
||||
artifact: {
|
||||
type: 'echarts-option',
|
||||
fence: ['json', 'js', 'javascript', 'echarts', 'option'],
|
||||
startPattern: /```(?:json|js|javascript|echarts|option)/i,
|
||||
parser: parseResponse
|
||||
},
|
||||
hooks: {
|
||||
onActivate() {
|
||||
// 预留钩子,可在此初始化额外资源
|
||||
}
|
||||
},
|
||||
exports: {
|
||||
allowSvg: true,
|
||||
allowPng: true,
|
||||
allowClipboard: true,
|
||||
allowCode: true
|
||||
},
|
||||
ui: {
|
||||
placeholderText: '生成的 ECharts 图表将在此处显示'
|
||||
}
|
||||
});
|
||||
})(window);
|
||||
58
js/modules/mermaid.js
Normal file
58
js/modules/mermaid.js
Normal file
@@ -0,0 +1,58 @@
|
||||
(function registerMermaidModule(global) {
|
||||
'use strict';
|
||||
|
||||
if (!global.ModuleRegistry) {
|
||||
throw new Error('ModuleRegistry 未初始化');
|
||||
}
|
||||
|
||||
const MERMAID_FENCE = /```mermaid\s*([\s\S]*?)```/i;
|
||||
|
||||
const parseResponse = (content = '') => {
|
||||
const match = content.match(MERMAID_FENCE);
|
||||
if (match) {
|
||||
const beforeText = content.substring(0, match.index).trim();
|
||||
const afterText = content.substring(match.index + match[0].length).trim();
|
||||
const code = match[1].trim();
|
||||
return {
|
||||
code,
|
||||
beforeText,
|
||||
afterText
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: '',
|
||||
beforeText: content.trim(),
|
||||
afterText: ''
|
||||
};
|
||||
};
|
||||
|
||||
global.ModuleRegistry.register({
|
||||
id: 'mermaid',
|
||||
label: 'Mermaid 图示',
|
||||
icon: 'ph:circles-three-plus-duotone',
|
||||
renderer: 'mermaid',
|
||||
promptKey: 'mermaid',
|
||||
storageNamespace: 'module:mermaid',
|
||||
chat: {
|
||||
placeholder: '描述你想生成的流程图、时序图或思维导图…',
|
||||
streamStartToken: '```mermaid',
|
||||
contextWindow: 8
|
||||
},
|
||||
artifact: {
|
||||
type: 'mermaid',
|
||||
fence: 'mermaid',
|
||||
startPattern: /```mermaid/i,
|
||||
parser: parseResponse
|
||||
},
|
||||
hooks: {},
|
||||
exports: {
|
||||
allowSvg: true,
|
||||
allowPng: true,
|
||||
allowClipboard: true,
|
||||
allowCode: true
|
||||
},
|
||||
ui: {
|
||||
placeholderText: '生成的 Mermaid 图示将在此处显示'
|
||||
}
|
||||
});
|
||||
})(window);
|
||||
43
js/modules/product-canvas.js
Normal file
43
js/modules/product-canvas.js
Normal file
@@ -0,0 +1,43 @@
|
||||
(function registerProductCanvasModule(global) {
|
||||
'use strict';
|
||||
|
||||
if (!global.ModuleRegistry) {
|
||||
throw new Error('ModuleRegistry 未初始化');
|
||||
}
|
||||
|
||||
const parseResponse = (content) => Utils.parseSVGResponse(content);
|
||||
|
||||
global.ModuleRegistry.register({
|
||||
id: 'product-canvas',
|
||||
label: '产品画布',
|
||||
icon: 'ph:pen-nib-duotone',
|
||||
renderer: 'svg',
|
||||
promptKey: 'canvas',
|
||||
storageNamespace: 'module:product-canvas',
|
||||
chat: {
|
||||
placeholder: '描述你的产品定位、用户画像、价值主张等内容…',
|
||||
streamStartToken: '```svg',
|
||||
contextWindow: 10
|
||||
},
|
||||
artifact: {
|
||||
type: 'svg',
|
||||
fence: 'svg',
|
||||
startPattern: /```(?:svg)?\s*<svg/i,
|
||||
parser: parseResponse
|
||||
},
|
||||
hooks: {
|
||||
onActivate() {
|
||||
// 保留扩展点,后续可追加自定义逻辑
|
||||
}
|
||||
},
|
||||
exports: {
|
||||
allowSvg: true,
|
||||
allowPng: true,
|
||||
allowClipboard: true,
|
||||
allowCode: true
|
||||
},
|
||||
ui: {
|
||||
placeholderText: '生成的产品画布将在此处显示'
|
||||
}
|
||||
});
|
||||
})(window);
|
||||
39
js/modules/swot.js
Normal file
39
js/modules/swot.js
Normal file
@@ -0,0 +1,39 @@
|
||||
(function registerSwotModule(global) {
|
||||
'use strict';
|
||||
|
||||
if (!global.ModuleRegistry) {
|
||||
throw new Error('ModuleRegistry 未初始化');
|
||||
}
|
||||
|
||||
const parseResponse = (content) => Utils.parseSVGResponse(content);
|
||||
|
||||
global.ModuleRegistry.register({
|
||||
id: 'swot',
|
||||
label: 'SWOT分析',
|
||||
icon: 'ph:chart-bar-duotone',
|
||||
renderer: 'svg',
|
||||
promptKey: 'swot',
|
||||
storageNamespace: 'module:swot',
|
||||
chat: {
|
||||
placeholder: '输入业务背景或问题,我来生成 SWOT 分析…',
|
||||
streamStartToken: '```svg',
|
||||
contextWindow: 10
|
||||
},
|
||||
artifact: {
|
||||
type: 'svg',
|
||||
fence: 'svg',
|
||||
startPattern: /```(?:svg)?\s*<svg/i,
|
||||
parser: parseResponse
|
||||
},
|
||||
hooks: {},
|
||||
exports: {
|
||||
allowSvg: true,
|
||||
allowPng: true,
|
||||
allowClipboard: true,
|
||||
allowCode: true
|
||||
},
|
||||
ui: {
|
||||
placeholderText: '生成的SWOT分析将在此处显示'
|
||||
}
|
||||
});
|
||||
})(window);
|
||||
118
js/services/conversation-service.js
Normal file
118
js/services/conversation-service.js
Normal file
@@ -0,0 +1,118 @@
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 管理模块化对话历史及上下文构建
|
||||
*/
|
||||
class ConversationService {
|
||||
constructor(storageService, defaultOptions = {}) {
|
||||
if (!storageService) {
|
||||
throw new Error('ConversationService 需要 StorageService 实例');
|
||||
}
|
||||
this.storageService = storageService;
|
||||
this.defaultOptions = {
|
||||
historyKey: 'history',
|
||||
contextWindow: 10,
|
||||
...defaultOptions
|
||||
};
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
_getHistoryKey(moduleConfig) {
|
||||
return moduleConfig.storageKeys?.history || this.defaultOptions.historyKey;
|
||||
}
|
||||
|
||||
_getNamespace(moduleConfig) {
|
||||
const namespace =
|
||||
moduleConfig.storageNamespace || `module:${moduleConfig.id}`;
|
||||
return this.storageService.namespace(namespace);
|
||||
}
|
||||
|
||||
_getCacheKey(moduleId) {
|
||||
return `history:${moduleId}`;
|
||||
}
|
||||
|
||||
getHistory(moduleConfig) {
|
||||
const cacheKey = this._getCacheKey(moduleConfig.id);
|
||||
if (this.cache.has(cacheKey)) {
|
||||
return this.cache.get(cacheKey);
|
||||
}
|
||||
|
||||
const store = this._getNamespace(moduleConfig);
|
||||
const history =
|
||||
store.get(this._getHistoryKey(moduleConfig), []).map((msg) => ({
|
||||
...msg
|
||||
}));
|
||||
|
||||
this.cache.set(cacheKey, history);
|
||||
return history;
|
||||
}
|
||||
|
||||
saveHistory(moduleConfig, history) {
|
||||
const cacheKey = this._getCacheKey(moduleConfig.id);
|
||||
const clonedHistory = history.map((msg) => ({ ...msg }));
|
||||
this.cache.set(cacheKey, clonedHistory);
|
||||
|
||||
const store = this._getNamespace(moduleConfig);
|
||||
store.set(this._getHistoryKey(moduleConfig), clonedHistory);
|
||||
}
|
||||
|
||||
appendMessage(moduleConfig, message) {
|
||||
const history = this.getHistory(moduleConfig);
|
||||
history.push({ ...message });
|
||||
this.saveHistory(moduleConfig, history);
|
||||
return history;
|
||||
}
|
||||
|
||||
replaceHistory(moduleConfig, history) {
|
||||
this.saveHistory(moduleConfig, history);
|
||||
}
|
||||
|
||||
clearHistory(moduleConfig) {
|
||||
this.saveHistory(moduleConfig, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建流式上下文,为最后一个用户消息提供所需历史
|
||||
*/
|
||||
buildContext(moduleConfig, tailMessages = null) {
|
||||
const history = this.getHistory(moduleConfig);
|
||||
if (!history.length) return null;
|
||||
|
||||
let targetIndex = history.length - 1;
|
||||
if (tailMessages != null) {
|
||||
targetIndex = Math.max(0, history.length - tailMessages);
|
||||
}
|
||||
|
||||
// 确保目标是用户消息
|
||||
while (targetIndex >= 0 && history[targetIndex].type !== 'user') {
|
||||
targetIndex -= 1;
|
||||
}
|
||||
|
||||
if (targetIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contextWindow =
|
||||
moduleConfig.chat?.contextWindow || this.defaultOptions.contextWindow;
|
||||
const start = Math.max(0, targetIndex - contextWindow);
|
||||
const contextSlice = history.slice(start, targetIndex);
|
||||
|
||||
const contextMessages = contextSlice
|
||||
.filter((msg) => msg.type === 'user' || msg.type === 'ai')
|
||||
.map((msg) => ({
|
||||
role: msg.type === 'user' ? 'user' : 'assistant',
|
||||
content: msg.content
|
||||
}));
|
||||
|
||||
return {
|
||||
history,
|
||||
userMessage: history[targetIndex],
|
||||
contextMessages,
|
||||
targetIndex
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
global.ConversationService = ConversationService;
|
||||
})(window);
|
||||
84
js/services/storage-service.js
Normal file
84
js/services/storage-service.js
Normal file
@@ -0,0 +1,84 @@
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 提供按命名空间隔离的本地存储封装
|
||||
* 依赖 Utils.storage 作为底层驱动
|
||||
*/
|
||||
class NamespacedStorage {
|
||||
constructor(namespace) {
|
||||
this.namespace = namespace;
|
||||
}
|
||||
|
||||
_key(key) {
|
||||
return `${this.namespace}:${key}`;
|
||||
}
|
||||
|
||||
get(key, defaultValue = null) {
|
||||
return Utils.storage.get(this._key(key), defaultValue);
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
return Utils.storage.set(this._key(key), value);
|
||||
}
|
||||
|
||||
remove(key) {
|
||||
return Utils.storage.remove(this._key(key));
|
||||
}
|
||||
|
||||
clear() {
|
||||
const prefix = `${this.namespace}:`;
|
||||
const toDelete = [];
|
||||
for (let i = 0; i < localStorage.length; i += 1) {
|
||||
const storageKey = localStorage.key(i);
|
||||
if (storageKey && storageKey.startsWith(prefix)) {
|
||||
toDelete.push(storageKey);
|
||||
}
|
||||
}
|
||||
toDelete.forEach((storageKey) => localStorage.removeItem(storageKey));
|
||||
}
|
||||
}
|
||||
|
||||
class StorageService {
|
||||
constructor(globalNamespace = 'tool-engine') {
|
||||
this.globalNamespace = globalNamespace;
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全局命名空间存储
|
||||
*/
|
||||
global() {
|
||||
if (!this.cache.has(this.globalNamespace)) {
|
||||
this.cache.set(
|
||||
this.globalNamespace,
|
||||
new NamespacedStorage(this.globalNamespace)
|
||||
);
|
||||
}
|
||||
return this.cache.get(this.globalNamespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定命名空间存储
|
||||
*/
|
||||
namespace(namespace) {
|
||||
if (!namespace) {
|
||||
throw new Error('Storage namespace 不能为空');
|
||||
}
|
||||
if (!this.cache.has(namespace)) {
|
||||
this.cache.set(namespace, new NamespacedStorage(namespace));
|
||||
}
|
||||
return this.cache.get(namespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定命名空间内容
|
||||
*/
|
||||
clearNamespace(namespace) {
|
||||
const store = this.namespace(namespace);
|
||||
store.clear();
|
||||
}
|
||||
}
|
||||
|
||||
global.StorageService = StorageService;
|
||||
})(window);
|
||||
21
libs/css/inter-font.css
Normal file
21
libs/css/inter-font.css
Normal file
@@ -0,0 +1,21 @@
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(../fonts/inter-400.ttf) format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(../fonts/inter-700.ttf) format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url(../fonts/inter-900.ttf) format('truetype');
|
||||
}
|
||||
65
libs/css/tailwind.css
Normal file
65
libs/css/tailwind.css
Normal file
File diff suppressed because one or more lines are too long
BIN
libs/fonts/inter-400.ttf
Normal file
BIN
libs/fonts/inter-400.ttf
Normal file
Binary file not shown.
BIN
libs/fonts/inter-700.ttf
Normal file
BIN
libs/fonts/inter-700.ttf
Normal file
Binary file not shown.
BIN
libs/fonts/inter-900.ttf
Normal file
BIN
libs/fonts/inter-900.ttf
Normal file
Binary file not shown.
45
libs/js/echarts.min.js
vendored
Normal file
45
libs/js/echarts.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
12
libs/js/iconify-icon.min.js
vendored
Normal file
12
libs/js/iconify-icon.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
69
libs/js/marked.min.js
vendored
Normal file
69
libs/js/marked.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2314
libs/js/mermaid.min.js
vendored
Normal file
2314
libs/js/mermaid.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
6
libs/js/panzoom.min.js
vendored
Normal file
6
libs/js/panzoom.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
libs/js/svg-pan-zoom.min.js
vendored
Normal file
3
libs/js/svg-pan-zoom.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
87
prompts/echarts-prompt.txt
Normal file
87
prompts/echarts-prompt.txt
Normal file
@@ -0,0 +1,87 @@
|
||||
你是一名资深的数据可视化专家,擅长使用 ECharts 将业务需求转化为可执行的配置。请遵循以下规则输出结果:
|
||||
|
||||
1. 使用 JSON 对象表达完整的 ECharts option,不要包含解释说明。
|
||||
2. 保持字段命名符合 ECharts 官方文档,避免多余字段。
|
||||
3. 如需附加解读或说明,请放在 JSON 代码块之外。
|
||||
4. 如果用户没有提供数据,请生成结构清晰的示例数据并说明需要用户替换的位置。
|
||||
5. 鼓励使用易读的调色板、标题和提示信息,兼顾桌面端展示。
|
||||
6. 根据用户需求选择合适的图表类型和样式
|
||||
7. 包含丰富的交互效果和美观的样式
|
||||
|
||||
可以参照以下JSON格式返回:
|
||||
|
||||
{
|
||||
"title": {
|
||||
"text": "图表标题",
|
||||
"subtext": "副标题(可选)",
|
||||
"left": "center",
|
||||
"textStyle": {
|
||||
"color": "#333",
|
||||
"fontWeight": "bold",
|
||||
"fontSize": 18
|
||||
}
|
||||
},
|
||||
"tooltip": {
|
||||
"trigger": "axis",
|
||||
"axisPointer": {
|
||||
"type": "cross",
|
||||
"crossStyle": {
|
||||
"color": "#999"
|
||||
}
|
||||
},
|
||||
"backgroundColor": "rgba(255,255,255,0.9)",
|
||||
"borderColor": "#ccc",
|
||||
"borderWidth": 1
|
||||
},
|
||||
"legend": {
|
||||
"show": true,
|
||||
"data": ["系列名称"],
|
||||
"top": "bottom",
|
||||
"padding": [20, 10, 10, 10]
|
||||
},
|
||||
"toolbox": {
|
||||
"feature": {
|
||||
"dataView": { "show": true, "readOnly": false, "title": "数据视图" },
|
||||
"magicType": { "show": true, "type": ["line", "bar"], "title": {"line": "切换为折线图", "bar": "切换为柱状图"} },
|
||||
"restore": { "show": true, "title": "还原" },
|
||||
"saveAsImage": { "show": true, "title": "保存为图片" }
|
||||
},
|
||||
"right": 20
|
||||
},
|
||||
"grid": {
|
||||
"left": "3%",
|
||||
"right": "4%",
|
||||
"bottom": "12%",
|
||||
"containLabel": true
|
||||
},
|
||||
"xAxis": {
|
||||
"type": "category",
|
||||
"data": ["实际的X轴数据数组"],
|
||||
"name": "X轴名称",
|
||||
"axisLabel": {
|
||||
"color": "#666"
|
||||
}
|
||||
},
|
||||
"yAxis": {
|
||||
"type": "value",
|
||||
"name": "Y轴名称",
|
||||
"axisLabel": {
|
||||
"color": "#666"
|
||||
}
|
||||
},
|
||||
"series": [
|
||||
{
|
||||
"name": "系列名称",
|
||||
"type": "bar",
|
||||
"data": ["实际的Y轴数据数组"],
|
||||
"itemStyle": {
|
||||
"color": "#3498db",
|
||||
"borderRadius": [4, 4, 0, 0]
|
||||
},
|
||||
"emphasis": {
|
||||
"focus": "series"
|
||||
}
|
||||
}
|
||||
],
|
||||
"color": ["#3498db", "#e74c3c", "#2ecc71", "#f39c12", "#9b59b6"]
|
||||
}
|
||||
38
prompts/mermaid-prompt.txt
Normal file
38
prompts/mermaid-prompt.txt
Normal file
@@ -0,0 +1,38 @@
|
||||
你是一个 mermaid 编写专家,我会给你提供一个场景,你来输出 mermaid 代码,只输出代码即可,不要输出其他的内容,无论我提出什么你都要以 mermaid 代码格式回答我;如果我没指定 mermaid 图的类型,默认使用 `flowchart LR`。
|
||||
|
||||
语法规则库 ()
|
||||
"Mermaid 语法规则和最佳实践"
|
||||
'((特殊字符处理 . "使用双引号包裹含有特殊字符或空格的文本")
|
||||
(HTML实体编码 . "< > & # 等字符使用 HTML 实体编码")
|
||||
(节点命名 . "使用简洁有意义的 ID,避免中文 ID")
|
||||
(连接符规范 . "flowchart: --> | sequenceDiagram: ->> | classDiagram: --|>")
|
||||
(注释规范 . "使用 %% 添加注释说明")
|
||||
(序号处理 . "序号后不要跟空格,如 1.xxx 而非 1. xxx")
|
||||
(颜色分层 . "使用不同背景色区分层级和分组"))
|
||||
|
||||
图表类型映射 ()
|
||||
"定义支持的图表类型及其特征"
|
||||
'((flowchart . (关键词 ("流程" "步骤" "过程" "决策" "分支")
|
||||
语法 "flowchart TD"
|
||||
适用场景 "业务流程、决策树、算法步骤"))
|
||||
(sequenceDiagram . (关键词 ("交互" "通信" "调用" "请求" "响应")
|
||||
语法 "sequenceDiagram"
|
||||
适用场景 "系统交互、API调用、用户操作"))
|
||||
(classDiagram . (关键词 ("类" "对象" "继承" "关系" "属性" "方法")
|
||||
语法 "classDiagram"
|
||||
适用场景 "系统设计、数据模型、架构图"))
|
||||
(stateDiagram . (关键词 ("状态" "转换" "事件" "条件")
|
||||
语法 "stateDiagram-v2"
|
||||
适用场景 "状态机、业务状态、流程状态"))
|
||||
(gantt . (关键词 ("时间" "计划" "任务" "进度" "里程碑")
|
||||
语法 "gantt"
|
||||
适用场景 "项目管理、时间规划、任务安排"))
|
||||
(pie . (关键词 ("比例" "占比" "分布" "百分比")
|
||||
语法 "pie"
|
||||
适用场景 "数据分析、统计展示、比例关系")))
|
||||
|
||||
(专业领域 . '(流程图 时序图 类图 状态图 甘特图 饼图))
|
||||
(核心能力 . '(文本分析 结构识别 语法生成 错误修复))
|
||||
(技术特长 . '(Mermaid语法 图表设计 可视化 代码优化))
|
||||
(工作原则 . '(准确理解 智能选择 规范输出 易于理解))
|
||||
(输出标准 . '(语法正确 结构清晰 美观实用 可直接使用))
|
||||
Reference in New Issue
Block a user