From 4088c1220fd46c2be9e55d2e31bc2221b3a0936b Mon Sep 17 00:00:00 2001 From: niuwei Date: Wed, 10 Dec 2025 01:52:20 +0800 Subject: [PATCH 01/13] =?UTF-8?q?1.=E5=8E=BB=E6=8E=89=E7=A1=AC=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E7=9A=84https://localhost:3458=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=8F=AF=E9=85=8D=E7=BD=AEAPI=20Base=20URL=E3=80=822.?= =?UTF-8?q?=E5=9C=A8=E4=B8=BB=E9=A2=98=E5=9D=97=E4=B8=AD=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?api=5Fbase=5Furl=E8=AE=BE=E7=BD=AE=EF=BC=8C=E5=8F=AF=E4=BB=A5?= =?UTF-8?q?=E5=9C=A8Shopify=E5=90=8E=E5=8F=B0=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- extensions/chat-bubble/assets/chat.js | 24 +++++++++++++++---- .../chat-bubble/blocks/chat-interface.liquid | 10 +++++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/extensions/chat-bubble/assets/chat.js b/extensions/chat-bubble/assets/chat.js index 52f0e04b..bf675b96 100644 --- a/extensions/chat-bubble/assets/chat.js +++ b/extensions/chat-bubble/assets/chat.js @@ -464,6 +464,21 @@ * API communication and data handling */ API: { + /** + * Get base URL for API requests. + * Prefer a configurable value from shopChatConfig, otherwise fall back to localhost. + * Trailing slashes are removed to simplify path concatenation. + * @returns {string} + */ + getApiBaseUrl: function() { + const configuredBaseUrl = window.shopChatConfig?.apiBaseUrl; + const baseUrl = configuredBaseUrl && configuredBaseUrl.trim().length > 0 + ? configuredBaseUrl.trim() + : 'https://localhost:3458'; + + // Remove trailing slash if present + return baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; + }, /** * Stream a response from the API * @param {string} userMessage - User's message text @@ -481,7 +496,7 @@ prompt_type: promptType }); - const streamUrl = 'https://localhost:3458/chat'; + const streamUrl = this.getApiBaseUrl() + '/chat'; const shopId = window.shopId; const response = await fetch(streamUrl, { @@ -630,7 +645,8 @@ messagesContainer.appendChild(loadingMessage); // Fetch history from the server - const historyUrl = `https://localhost:3458/chat?history=true&conversation_id=${encodeURIComponent(conversationId)}`; + const historyUrl = this.getApiBaseUrl() + + `/chat?history=true&conversation_id=${encodeURIComponent(conversationId)}`; console.log('Fetching history from:', historyUrl); const response = await fetch(historyUrl, { @@ -779,8 +795,8 @@ attemptCount++; try { - const tokenUrl = 'https://localhost:3458/auth/token-status?conversation_id=' + - encodeURIComponent(conversationId); + const tokenUrl = ShopAIChat.API.getApiBaseUrl() + + '/auth/token-status?conversation_id=' + encodeURIComponent(conversationId); const response = await fetch(tokenUrl); if (!response.ok) { diff --git a/extensions/chat-bubble/blocks/chat-interface.liquid b/extensions/chat-bubble/blocks/chat-interface.liquid index 57f6ac0c..9814e965 100644 --- a/extensions/chat-bubble/blocks/chat-interface.liquid +++ b/extensions/chat-bubble/blocks/chat-interface.liquid @@ -34,7 +34,8 @@ @@ -71,6 +72,13 @@ } ], "default": "standardAssistant" + }, + { + "type": "text", + "id": "api_base_url", + "label": "API Base URL (optional)", + "info": "例如 https://lit-ability-angeles-staff.trycloudflare.com,留空则使用 https://localhost:3458", + "default": "" } ] } From 542c3e374a197ddd0a3899ab1550961697a43c26 Mon Sep 17 00:00:00 2001 From: niuwei Date: Wed, 10 Dec 2025 01:59:53 +0800 Subject: [PATCH 02/13] =?UTF-8?q?1.=E5=8E=BB=E6=8E=89=E7=A1=AC=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E7=9A=84https://localhost:3458=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=8F=AF=E9=85=8D=E7=BD=AEAPI=20Base=20URL=E3=80=822.?= =?UTF-8?q?=E5=9C=A8=E4=B8=BB=E9=A2=98=E5=9D=97=E4=B8=AD=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?api=5Fbase=5Furl=E8=AE=BE=E7=BD=AE=EF=BC=8C=E5=8F=AF=E4=BB=A5?= =?UTF-8?q?=E5=9C=A8Shopify=E5=90=8E=E5=8F=B0=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- extensions/chat-bubble/blocks/chat-interface.liquid | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/extensions/chat-bubble/blocks/chat-interface.liquid b/extensions/chat-bubble/blocks/chat-interface.liquid index 9814e965..133dea4e 100644 --- a/extensions/chat-bubble/blocks/chat-interface.liquid +++ b/extensions/chat-bubble/blocks/chat-interface.liquid @@ -77,8 +77,7 @@ "type": "text", "id": "api_base_url", "label": "API Base URL (optional)", - "info": "例如 https://lit-ability-angeles-staff.trycloudflare.com,留空则使用 https://localhost:3458", - "default": "" + "info": "例如 https://lit-ability-angeles-staff.trycloudflare.com,留空则使用 https://localhost:3458" } ] } From 45f59bfe209a4a08334f27a55810566332113b35 Mon Sep 17 00:00:00 2001 From: niuwei Date: Wed, 10 Dec 2025 11:31:28 +0800 Subject: [PATCH 03/13] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E8=B0=83=E7=94=A8claud?= =?UTF-8?q?e=20api=20key=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/claude.server.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/services/claude.server.js b/app/services/claude.server.js index 5ec0a982..a8541646 100644 --- a/app/services/claude.server.js +++ b/app/services/claude.server.js @@ -12,10 +12,10 @@ import systemPrompts from "../prompts/prompts.json"; * @returns {Object} Claude service with methods for interacting with Claude API */ export function createClaudeService(apiKey = process.env.CLAUDE_API_KEY) { - // Initialize Claude client - const anthropic = new Anthropic({ - apiKey: apiKey, - baseURL: 'https://proxy.shopify.ai/apis/anthropic' + // Initialize Claude client - call Anthropic directly using the official API key. + // We intentionally DO NOT use the Shopify AI proxy here to avoid X-Api-Key format issues. + const anthropic = new Anthropic({ + apiKey: apiKey }); /** From 50d57551f58cc81edec5241a1f08298257252cf4 Mon Sep 17 00:00:00 2001 From: niuwei Date: Wed, 10 Dec 2025 14:41:53 +0800 Subject: [PATCH 04/13] =?UTF-8?q?1.gitignore=E4=B8=AD=E5=B1=8F=E8=94=BD.id?= =?UTF-8?q?ea=E3=80=822.=E4=BF=AE=E6=94=B9client=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ shopify.app.toml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index aa1274d1..848e9804 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ shopify.*.toml # Hide files auto-generated by react router .react-router/ + +.idea diff --git a/shopify.app.toml b/shopify.app.toml index c632ceaf..dd39542a 100644 --- a/shopify.app.toml +++ b/shopify.app.toml @@ -1,6 +1,6 @@ # Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration -client_id = "YOUR_CLIENT_ID" +client_id = "4112cc090597f73175212de8b77b148d" name = "shop-chat-agent" handle = "shop-chat-agent" application_url = "https://shop-chat-agent.com" From f7153423cb5d1c8543dd6a9e10802766193541d5 Mon Sep 17 00:00:00 2001 From: niuwei Date: Wed, 10 Dec 2025 16:02:54 +0800 Subject: [PATCH 05/13] =?UTF-8?q?=E4=B8=BB=E9=A2=98=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E7=9A=84AI=20Chat=20Assistant=20block=E5=86=85?= =?UTF-8?q?=E6=8A=8ASystem=20Prompt=E9=80=89=E6=88=90standardAssistant?= =?UTF-8?q?=EF=BC=8C=E5=9C=A8Custom=20system=20prompt=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E6=A1=86=E9=87=8C=E5=A1=AB=E4=BD=A0=E6=83=B3=E8=A6=81=E7=9A=84?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D=EF=BC=88=E4=B8=AD=E6=96=87/?= =?UTF-8?q?=E8=8B=B1=E6=96=87=E9=83=BD=E8=A1=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/routes/chat.jsx | 6 +++++- app/services/claude.server.js | 9 ++++++--- extensions/chat-bubble/assets/chat.js | 6 +++++- extensions/chat-bubble/blocks/chat-interface.liquid | 9 ++++++++- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/app/routes/chat.jsx b/app/routes/chat.jsx index d85ddc09..1c6fdff5 100644 --- a/app/routes/chat.jsx +++ b/app/routes/chat.jsx @@ -79,6 +79,7 @@ async function handleChatRequest(request) { // Generate or use existing conversation ID const conversationId = body.conversation_id || Date.now().toString(); const promptType = body.prompt_type || AppConfig.api.defaultPromptType; + const systemPromptOverride = body.system_prompt_override; // Create a stream for the response const responseStream = createSseStream(async (stream) => { @@ -87,6 +88,7 @@ async function handleChatRequest(request) { userMessage, conversationId, promptType, + systemPromptOverride, stream }); }); @@ -117,6 +119,7 @@ async function handleChatSession({ userMessage, conversationId, promptType, + systemPromptOverride, stream }) { // Initialize services @@ -184,7 +187,8 @@ async function handleChatSession({ { messages: conversationHistory, promptType, - tools: mcpClient.tools + tools: mcpClient.tools, + systemOverride: systemPromptOverride }, { // Handle text chunks diff --git a/app/services/claude.server.js b/app/services/claude.server.js index a8541646..f71005ed 100644 --- a/app/services/claude.server.js +++ b/app/services/claude.server.js @@ -33,10 +33,13 @@ export function createClaudeService(apiKey = process.env.CLAUDE_API_KEY) { const streamConversation = async ({ messages, promptType = AppConfig.api.defaultPromptType, - tools + tools, + systemOverride }, streamHandlers) => { - // Get system prompt from configuration or use default - const systemInstruction = getSystemPrompt(promptType); + // Get system prompt from configuration or use an override if provided + const systemInstruction = systemOverride && systemOverride.trim().length > 0 + ? systemOverride + : getSystemPrompt(promptType); // Create stream const stream = await anthropic.messages.stream({ diff --git a/extensions/chat-bubble/assets/chat.js b/extensions/chat-bubble/assets/chat.js index bf675b96..74b30313 100644 --- a/extensions/chat-bubble/assets/chat.js +++ b/extensions/chat-bubble/assets/chat.js @@ -490,10 +490,14 @@ try { const promptType = window.shopChatConfig?.promptType || "standardAssistant"; + const customSystemPrompt = window.shopChatConfig?.customSystemPrompt || ""; const requestBody = JSON.stringify({ message: userMessage, conversation_id: conversationId, - prompt_type: promptType + prompt_type: promptType, + system_prompt_override: customSystemPrompt && customSystemPrompt.trim() !== '' + ? customSystemPrompt + : undefined }); const streamUrl = this.getApiBaseUrl() + '/chat'; diff --git a/extensions/chat-bubble/blocks/chat-interface.liquid b/extensions/chat-bubble/blocks/chat-interface.liquid index 133dea4e..b3032081 100644 --- a/extensions/chat-bubble/blocks/chat-interface.liquid +++ b/extensions/chat-bubble/blocks/chat-interface.liquid @@ -35,7 +35,8 @@ window.shopChatConfig = { promptType: {{ block.settings.system_prompt | json }}, welcomeMessage: {{ block.settings.welcome_message | json }}, - apiBaseUrl: {{ block.settings.api_base_url | json }} + apiBaseUrl: {{ block.settings.api_base_url | json }}, + customSystemPrompt: {{ block.settings.custom_system_prompt | json }} }; window.shopId = {{ shop.id }}; @@ -78,6 +79,12 @@ "id": "api_base_url", "label": "API Base URL (optional)", "info": "例如 https://lit-ability-angeles-staff.trycloudflare.com,留空则使用 https://localhost:3458" + }, + { + "type": "textarea", + "id": "custom_system_prompt", + "label": "Custom system prompt (optional)", + "info": "如果填写,将覆盖所选的系统提示词(例如 standardAssistant)" } ] } From c2971c7c5b102eec2d9d22087c297f98b8d22394 Mon Sep 17 00:00:00 2001 From: niuwei Date: Wed, 10 Dec 2025 18:10:23 +0800 Subject: [PATCH 06/13] =?UTF-8?q?=E6=98=BE=E7=A4=BA=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/claude.server.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/services/claude.server.js b/app/services/claude.server.js index f71005ed..4048a634 100644 --- a/app/services/claude.server.js +++ b/app/services/claude.server.js @@ -41,6 +41,19 @@ export function createClaudeService(apiKey = process.env.CLAUDE_API_KEY) { ? systemOverride : getSystemPrompt(promptType); + // Log prompt usage for debugging/teaching + try { + console.log("======== Claude system prompt configuration ========"); + console.log("Prompt type:", promptType); + console.log("Has override from frontend:", !!(systemOverride && systemOverride.trim().length > 0)); + console.log("system prompt length:", systemInstruction ? systemInstruction.length : 0); + console.log("system prompt preview:", systemInstruction || ""); + console.log("====================================================="); + } catch (e) { + // 日志本身出错时不要影响正常请求 + console.warn("Failed to log system prompt configuration:", e?.message || e); + } + // Create stream const stream = await anthropic.messages.stream({ model: AppConfig.api.defaultModel, From 7c187e6aeb1c497d486a65664d7e8999a946f640 Mon Sep 17 00:00:00 2001 From: niuwei Date: Wed, 10 Dec 2025 18:42:30 +0800 Subject: [PATCH 07/13] =?UTF-8?q?=E6=9B=B4=E6=96=B0Claude=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E4=B8=BAclaude-sonnet-4-5-20250929?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/config.server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/config.server.js b/app/services/config.server.js index 628f8eb3..cfaf6adf 100644 --- a/app/services/config.server.js +++ b/app/services/config.server.js @@ -6,7 +6,7 @@ export const AppConfig = { // API Configuration api: { - defaultModel: 'claude-3-5-sonnet-latest', + defaultModel: 'claude-sonnet-4-5-20250929', maxTokens: 2000, defaultPromptType: 'standardAssistant', }, From 997396080992b8f9d9106bf7c2b451d0593fa367 Mon Sep 17 00:00:00 2001 From: niuwei Date: Wed, 10 Dec 2025 20:12:14 +0800 Subject: [PATCH 08/13] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=9C=80=E6=B1=82?= =?UTF-8?q?=EF=BC=9A=E6=B3=A8=E5=86=8C/=E7=99=BB=E5=BD=95=EF=BC=9B?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E6=8F=90=E7=A4=BA=E8=AF=8D=EF=BC=9B=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B=E5=8E=86=E5=8F=B2=E7=89=88=E6=9C=AC=EF=BC=9B=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E4=B8=AA=E6=80=A7=E5=8C=96AI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/db.server.js | 164 ++++++ app/routes/api.chat-auth.jsx | 214 +++++++ app/routes/api.user-prompt.jsx | 179 ++++++ app/routes/chat.jsx | 22 +- extensions/chat-bubble/assets/chat.css | 420 +++++++++++++ extensions/chat-bubble/assets/chat.js | 556 +++++++++++++++++- .../chat-bubble/blocks/chat-interface.liquid | 16 +- prisma/schema.prisma | 30 + 8 files changed, 1594 insertions(+), 7 deletions(-) create mode 100644 app/routes/api.chat-auth.jsx create mode 100644 app/routes/api.user-prompt.jsx diff --git a/app/db.server.js b/app/db.server.js index be46d49b..14277fcf 100644 --- a/app/db.server.js +++ b/app/db.server.js @@ -257,3 +257,167 @@ export async function getCustomerAccountUrls(conversationId) { return null; } } + +// ============================================ +// ChatUser 用户管理相关函数 +// ============================================ + +/** + * 创建新的聊天用户 + * @param {string} shopId - 商店ID + * @param {string} username - 用户名 + * @param {string} passwordHash - 密码哈希 + * @returns {Promise} - 创建的用户 + */ +export async function createChatUser(shopId, username, passwordHash) { + try { + return await prisma.chatUser.create({ + data: { + shopId, + username, + passwordHash + } + }); + } catch (error) { + console.error('Error creating chat user:', error); + throw error; + } +} + +/** + * 根据商店ID和用户名查找用户 + * @param {string} shopId - 商店ID + * @param {string} username - 用户名 + * @returns {Promise} - 用户对象或null + */ +export async function getChatUserByUsername(shopId, username) { + try { + return await prisma.chatUser.findUnique({ + where: { + shopId_username: { shopId, username } + } + }); + } catch (error) { + console.error('Error getting chat user:', error); + return null; + } +} + +/** + * 根据ID查找用户 + * @param {string} userId - 用户ID + * @returns {Promise} - 用户对象或null + */ +export async function getChatUserById(userId) { + try { + return await prisma.chatUser.findUnique({ + where: { id: userId } + }); + } catch (error) { + console.error('Error getting chat user by id:', error); + return null; + } +} + +/** + * 更新用户的当前提示词 + * @param {string} userId - 用户ID + * @param {string} prompt - 新的提示词 + * @returns {Promise} - 更新后的用户对象 + */ +export async function updateUserPrompt(userId, prompt) { + try { + // 获取当前用户以检查是否需要保存历史 + const user = await prisma.chatUser.findUnique({ + where: { id: userId } + }); + + // 如果用户有旧的提示词,保存到历史记录 + if (user && user.currentPrompt) { + // 获取当前最大版本号 + const lastHistory = await prisma.promptHistory.findFirst({ + where: { userId }, + orderBy: { version: 'desc' } + }); + const newVersion = (lastHistory?.version || 0) + 1; + + // 保存旧提示词到历史 + await prisma.promptHistory.create({ + data: { + userId, + content: user.currentPrompt, + version: newVersion + } + }); + } + + // 更新当前提示词 + return await prisma.chatUser.update({ + where: { id: userId }, + data: { + currentPrompt: prompt, + updatedAt: new Date() + } + }); + } catch (error) { + console.error('Error updating user prompt:', error); + throw error; + } +} + +/** + * 获取用户的提示词历史记录 + * @param {string} userId - 用户ID + * @returns {Promise} - 提示词历史列表 + */ +export async function getPromptHistory(userId) { + try { + return await prisma.promptHistory.findMany({ + where: { userId }, + orderBy: { version: 'desc' } + }); + } catch (error) { + console.error('Error getting prompt history:', error); + return []; + } +} + +/** + * 根据ID获取某个历史提示词 + * @param {string} historyId - 历史记录ID + * @returns {Promise} - 历史记录或null + */ +export async function getPromptHistoryById(historyId) { + try { + return await prisma.promptHistory.findUnique({ + where: { id: historyId } + }); + } catch (error) { + console.error('Error getting prompt history by id:', error); + return null; + } +} + +/** + * 恢复用户提示词到某个历史版本 + * @param {string} userId - 用户ID + * @param {string} historyId - 历史记录ID + * @returns {Promise} - 更新后的用户对象 + */ +export async function restorePromptFromHistory(userId, historyId) { + try { + const history = await prisma.promptHistory.findUnique({ + where: { id: historyId } + }); + + if (!history || history.userId !== userId) { + throw new Error('History not found or unauthorized'); + } + + // 使用 updateUserPrompt 来保存当前提示词到历史并更新 + return await updateUserPrompt(userId, history.content); + } catch (error) { + console.error('Error restoring prompt from history:', error); + throw error; + } +} diff --git a/app/routes/api.chat-auth.jsx b/app/routes/api.chat-auth.jsx new file mode 100644 index 00000000..9c0ca835 --- /dev/null +++ b/app/routes/api.chat-auth.jsx @@ -0,0 +1,214 @@ +/** + * Chat User Authentication API + * 用户注册/登录接口 + */ +import crypto from 'crypto'; +import { createChatUser, getChatUserByUsername, getChatUserById } from "../db.server"; + +/** + * 简单的密码哈希函数 + */ +function hashPassword(password) { + return crypto.createHash('sha256').update(password).digest('hex'); +} + +/** + * 生成简单的认证 token + */ +function generateToken(userId) { + const payload = { + userId, + exp: Date.now() + 7 * 24 * 60 * 60 * 1000 // 7天过期 + }; + return Buffer.from(JSON.stringify(payload)).toString('base64'); +} + +/** + * 解析认证 token + */ +function parseToken(token) { + try { + const payload = JSON.parse(Buffer.from(token, 'base64').toString('utf8')); + if (payload.exp < Date.now()) { + return null; // Token 已过期 + } + return payload; + } catch { + return null; + } +} + +/** + * GET 请求 - 检查登录状态 + */ +export async function loader({ request }) { + // Handle OPTIONS requests (CORS preflight) + if (request.method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: corsHeaders(request) + }); + } + + const url = new URL(request.url); + const action = url.searchParams.get("action"); + + if (action === "check") { + // 从 header 中获取 token + const authHeader = request.headers.get("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return jsonResponse({ authenticated: false }, 200, request); + } + + const token = authHeader.substring(7); + const payload = parseToken(token); + + if (!payload) { + return jsonResponse({ authenticated: false }, 200, request); + } + + const user = await getChatUserById(payload.userId); + if (!user || !user.isActive) { + return jsonResponse({ authenticated: false }, 200, request); + } + + return jsonResponse({ + authenticated: true, + userId: user.id, + username: user.username, + currentPrompt: user.currentPrompt + }, 200, request); + } + + return jsonResponse({ error: "Invalid action" }, 400, request); +} + +/** + * POST 请求 - 注册/登录 + */ +export async function action({ request }) { + // Handle OPTIONS requests (CORS preflight) + if (request.method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: corsHeaders(request) + }); + } + + try { + const body = await request.json(); + const { action, username, password, shopId } = body; + + // 验证必要参数 + if (!username || !password || !shopId) { + return jsonResponse({ error: "缺少必要参数" }, 400, request); + } + + // 用户名验证 + if (username.length < 2 || username.length > 50) { + return jsonResponse({ error: "用户名长度应为2-50个字符" }, 400, request); + } + + // 密码验证 + if (password.length < 4) { + return jsonResponse({ error: "密码长度至少4个字符" }, 400, request); + } + + if (action === "register") { + return handleRegister(shopId, username, password, request); + } else if (action === "login") { + return handleLogin(shopId, username, password, request); + } else { + return jsonResponse({ error: "无效的操作" }, 400, request); + } + } catch (error) { + console.error("Auth error:", error); + return jsonResponse({ error: "服务器错误" }, 500, request); + } +} + +/** + * 处理用户注册 + */ +async function handleRegister(shopId, username, password, request) { + // 检查用户是否已存在 + const existingUser = await getChatUserByUsername(shopId, username); + if (existingUser) { + return jsonResponse({ error: "用户名已存在" }, 409, request); + } + + // 创建新用户 + const passwordHash = hashPassword(password); + const user = await createChatUser(shopId, username, passwordHash); + + // 生成 token + const token = generateToken(user.id); + + return jsonResponse({ + success: true, + userId: user.id, + username: user.username, + token + }, 201, request); +} + +/** + * 处理用户登录 + */ +async function handleLogin(shopId, username, password, request) { + // 查找用户 + const user = await getChatUserByUsername(shopId, username); + if (!user) { + return jsonResponse({ error: "用户名或密码错误" }, 401, request); + } + + // 验证密码 + const passwordHash = hashPassword(password); + if (user.passwordHash !== passwordHash) { + return jsonResponse({ error: "用户名或密码错误" }, 401, request); + } + + // 检查用户是否激活 + if (!user.isActive) { + return jsonResponse({ error: "账户已被禁用" }, 403, request); + } + + // 生成 token + const token = generateToken(user.id); + + return jsonResponse({ + success: true, + userId: user.id, + username: user.username, + currentPrompt: user.currentPrompt, + token + }, 200, request); +} + +/** + * 返回 JSON 响应 + */ +function jsonResponse(data, status, request) { + return new Response(JSON.stringify(data), { + status, + headers: { + "Content-Type": "application/json", + ...corsHeaders(request) + } + }); +} + +/** + * CORS 头信息 + */ +function corsHeaders(request) { + const origin = request.headers.get("Origin") || "*"; + + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Accept, Authorization", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Max-Age": "86400" + }; +} diff --git a/app/routes/api.user-prompt.jsx b/app/routes/api.user-prompt.jsx new file mode 100644 index 00000000..0d78a906 --- /dev/null +++ b/app/routes/api.user-prompt.jsx @@ -0,0 +1,179 @@ +/** + * User Prompt Management API + * 用户提示词管理接口(含历史版本) + */ +import { + getChatUserById, + updateUserPrompt, + getPromptHistory, + restorePromptFromHistory +} from "../db.server"; + +/** + * 解析认证 token + */ +function parseToken(token) { + try { + const payload = JSON.parse(Buffer.from(token, 'base64').toString('utf8')); + if (payload.exp < Date.now()) { + return null; + } + return payload; + } catch { + return null; + } +} + +/** + * 从请求中提取并验证用户 + */ +async function authenticateRequest(request) { + const authHeader = request.headers.get("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return null; + } + + const token = authHeader.substring(7); + const payload = parseToken(token); + + if (!payload) { + return null; + } + + const user = await getChatUserById(payload.userId); + if (!user || !user.isActive) { + return null; + } + + return user; +} + +/** + * GET 请求 - 获取当前提示词或历史记录 + */ +export async function loader({ request }) { + // Handle OPTIONS requests (CORS preflight) + if (request.method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: corsHeaders(request) + }); + } + + // 验证用户 + const user = await authenticateRequest(request); + if (!user) { + return jsonResponse({ error: "未授权" }, 401, request); + } + + const url = new URL(request.url); + const action = url.searchParams.get("action"); + + if (action === "history") { + // 获取提示词历史记录 + const history = await getPromptHistory(user.id); + return jsonResponse({ + history: history.map(h => ({ + id: h.id, + version: h.version, + content: h.content, + createdAt: h.createdAt.toISOString() + })) + }, 200, request); + } + + // 默认返回当前提示词 + return jsonResponse({ + currentPrompt: user.currentPrompt || "", + userId: user.id + }, 200, request); +} + +/** + * POST 请求 - 更新提示词或恢复历史版本 + */ +export async function action({ request }) { + // Handle OPTIONS requests (CORS preflight) + if (request.method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: corsHeaders(request) + }); + } + + // 验证用户 + const user = await authenticateRequest(request); + if (!user) { + return jsonResponse({ error: "未授权" }, 401, request); + } + + try { + const body = await request.json(); + const { action } = body; + + if (action === "update") { + // 更新提示词 + const { prompt } = body; + + if (typeof prompt !== 'string') { + return jsonResponse({ error: "提示词必须是字符串" }, 400, request); + } + + const updatedUser = await updateUserPrompt(user.id, prompt); + + return jsonResponse({ + success: true, + currentPrompt: updatedUser.currentPrompt + }, 200, request); + } + + if (action === "restore") { + // 恢复历史版本 + const { historyId } = body; + + if (!historyId) { + return jsonResponse({ error: "缺少历史记录ID" }, 400, request); + } + + const updatedUser = await restorePromptFromHistory(user.id, historyId); + + return jsonResponse({ + success: true, + currentPrompt: updatedUser.currentPrompt + }, 200, request); + } + + return jsonResponse({ error: "无效的操作" }, 400, request); + } catch (error) { + console.error("Prompt API error:", error); + return jsonResponse({ error: "服务器错误" }, 500, request); + } +} + +/** + * 返回 JSON 响应 + */ +function jsonResponse(data, status, request) { + return new Response(JSON.stringify(data), { + status, + headers: { + "Content-Type": "application/json", + ...corsHeaders(request) + } + }); +} + +/** + * CORS 头信息 + */ +function corsHeaders(request) { + const origin = request.headers.get("Origin") || "*"; + + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Accept, Authorization", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Max-Age": "86400" + }; +} diff --git a/app/routes/chat.jsx b/app/routes/chat.jsx index 1c6fdff5..2d78d5fc 100644 --- a/app/routes/chat.jsx +++ b/app/routes/chat.jsx @@ -3,7 +3,7 @@ * Handles chat interactions with Claude API and tools */ import MCPClient from "../mcp-client"; -import { saveMessage, getConversationHistory, storeCustomerAccountUrls, getCustomerAccountUrls as getCustomerAccountUrlsFromDb } from "../db.server"; +import { saveMessage, getConversationHistory, storeCustomerAccountUrls, getCustomerAccountUrls as getCustomerAccountUrlsFromDb, getChatUserById } from "../db.server"; import AppConfig from "../services/config.server"; import { createSseStream } from "../services/streaming.server"; import { createClaudeService } from "../services/claude.server"; @@ -80,6 +80,7 @@ async function handleChatRequest(request) { const conversationId = body.conversation_id || Date.now().toString(); const promptType = body.prompt_type || AppConfig.api.defaultPromptType; const systemPromptOverride = body.system_prompt_override; + const userId = body.user_id; // 用户ID(可选) // Create a stream for the response const responseStream = createSseStream(async (stream) => { @@ -89,6 +90,7 @@ async function handleChatRequest(request) { conversationId, promptType, systemPromptOverride, + userId, stream }); }); @@ -112,6 +114,7 @@ async function handleChatRequest(request) { * @param {string} params.userMessage - The user's message * @param {string} params.conversationId - The conversation ID * @param {string} params.promptType - The prompt type + * @param {string} params.userId - The user ID (optional) * @param {Object} params.stream - Stream manager for sending responses */ async function handleChatSession({ @@ -120,12 +123,27 @@ async function handleChatSession({ conversationId, promptType, systemPromptOverride, + userId, stream }) { // Initialize services const claudeService = createClaudeService(); const toolService = createToolService(); + // 如果有用户ID,加载用户专属提示词 + let effectiveSystemPrompt = systemPromptOverride; + if (userId) { + try { + const chatUser = await getChatUserById(userId); + if (chatUser?.currentPrompt) { + effectiveSystemPrompt = chatUser.currentPrompt; + console.log(`Using user-specific prompt for user: ${userId}`); + } + } catch (error) { + console.error('Error loading user prompt:', error); + } + } + // Initialize MCP client const shopId = request.headers.get("X-Shopify-Shop-Id"); const shopDomain = request.headers.get("Origin"); @@ -188,7 +206,7 @@ async function handleChatSession({ messages: conversationHistory, promptType, tools: mcpClient.tools, - systemOverride: systemPromptOverride + systemOverride: effectiveSystemPrompt }, { // Handle text chunks diff --git a/extensions/chat-bubble/assets/chat.css b/extensions/chat-bubble/assets/chat.css index 001cd1de..f14b5970 100644 --- a/extensions/chat-bubble/assets/chat.css +++ b/extensions/chat-bubble/assets/chat.css @@ -609,3 +609,423 @@ transform: scale(1); } } + + /* ================================ + User & Settings Button Styles + ================================ */ + .shop-ai-header-actions { + display: flex; + align-items: center; + gap: 8px; + } + + .shop-ai-user-btn, + .shop-ai-settings-btn { + background: rgba(255, 255, 255, 0.2); + border: none; + border-radius: 50%; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.2s; + } + + .shop-ai-user-btn:hover, + .shop-ai-settings-btn:hover { + background: rgba(255, 255, 255, 0.3); + } + + .shop-ai-user-btn svg, + .shop-ai-settings-btn svg { + width: 18px; + height: 18px; + color: white; + } + + .shop-ai-user-btn.logged-in { + background: rgba(255, 255, 255, 0.4); + } + + /* ================================ + User Menu Styles + ================================ */ + .shop-ai-user-menu { + background: white; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + min-width: 150px; + z-index: 10000; + overflow: hidden; + } + + .shop-ai-user-info { + padding: 12px 16px; + border-bottom: 1px solid #eee; + font-weight: 600; + color: #333; + } + + .shop-ai-user-menu-item { + padding: 10px 16px; + cursor: pointer; + transition: background 0.2s; + font-size: 14px; + color: #333; + } + + .shop-ai-user-menu-item:hover { + background: #f5f5f5; + } + + /* ================================ + Auth Modal Styles + ================================ */ + .shop-ai-auth-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 100000; + padding: 20px; + } + + .shop-ai-auth-content { + background: white; + border-radius: 12px; + width: 100%; + max-width: 360px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); + } + + .shop-ai-auth-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid #eee; + } + + .shop-ai-auth-header h3 { + margin: 0; + font-size: 18px; + color: #333; + } + + .shop-ai-auth-close { + background: none; + border: none; + font-size: 24px; + color: #999; + cursor: pointer; + line-height: 1; + } + + .shop-ai-auth-close:hover { + color: #333; + } + + .shop-ai-auth-form { + padding: 20px; + } + + .shop-ai-auth-field { + margin-bottom: 16px; + } + + .shop-ai-auth-field label { + display: block; + margin-bottom: 6px; + font-size: 14px; + font-weight: 500; + color: #333; + } + + .shop-ai-auth-field input { + width: 100%; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 14px; + box-sizing: border-box; + } + + .shop-ai-auth-field input:focus { + outline: none; + border-color: #5046e4; + } + + .shop-ai-auth-error { + background: #fee; + color: #c00; + padding: 10px 12px; + border-radius: 6px; + font-size: 13px; + margin-bottom: 16px; + } + + .shop-ai-auth-submit { + width: 100%; + padding: 12px; + background: #5046e4; + color: white; + border: none; + border-radius: 6px; + font-size: 15px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; + } + + .shop-ai-auth-submit:hover { + background: #3f36c0; + } + + .shop-ai-auth-submit:disabled { + background: #aaa; + cursor: not-allowed; + } + + .shop-ai-auth-switch { + padding: 16px 20px; + text-align: center; + border-top: 1px solid #eee; + font-size: 14px; + color: #666; + } + + .shop-ai-auth-switch a { + color: #5046e4; + text-decoration: none; + font-weight: 500; + } + + .shop-ai-auth-switch a:hover { + text-decoration: underline; + } + + /* ================================ + Settings Panel Styles + ================================ */ + .shop-ai-settings-panel { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 100000; + padding: 20px; + } + + .shop-ai-settings-content { + background: white; + border-radius: 12px; + width: 100%; + max-width: 500px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); + } + + .shop-ai-settings-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid #eee; + } + + .shop-ai-settings-header h3 { + margin: 0; + font-size: 18px; + color: #333; + } + + .shop-ai-settings-close { + background: none; + border: none; + font-size: 24px; + color: #999; + cursor: pointer; + line-height: 1; + } + + .shop-ai-settings-body { + flex: 1; + overflow-y: auto; + padding: 20px; + } + + .shop-ai-settings-tabs { + display: flex; + gap: 8px; + margin-bottom: 16px; + } + + .shop-ai-tab { + padding: 8px 16px; + background: #f5f5f5; + border: none; + border-radius: 6px; + font-size: 14px; + cursor: pointer; + transition: background 0.2s; + } + + .shop-ai-tab:hover { + background: #eee; + } + + .shop-ai-tab.active { + background: #5046e4; + color: white; + } + + .shop-ai-tab-content { + display: none; + } + + .shop-ai-tab-content.active { + display: block; + } + + .shop-ai-prompt-editor { + width: 100%; + min-height: 200px; + padding: 12px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 14px; + font-family: inherit; + resize: vertical; + box-sizing: border-box; + } + + .shop-ai-prompt-editor:focus { + outline: none; + border-color: #5046e4; + } + + .shop-ai-settings-actions { + margin-top: 16px; + text-align: right; + } + + .shop-ai-save-prompt { + padding: 10px 24px; + background: #5046e4; + color: white; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; + } + + .shop-ai-save-prompt:hover { + background: #3f36c0; + } + + .shop-ai-save-prompt:disabled { + background: #aaa; + cursor: not-allowed; + } + + /* History List */ + .shop-ai-history-list { + max-height: 300px; + overflow-y: auto; + } + + .shop-ai-history-item { + padding: 12px; + border: 1px solid #eee; + border-radius: 8px; + margin-bottom: 10px; + } + + .shop-ai-history-meta { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + font-size: 12px; + color: #666; + } + + .shop-ai-history-version { + font-weight: 600; + color: #5046e4; + } + + .shop-ai-history-content { + font-size: 13px; + color: #333; + margin-bottom: 10px; + line-height: 1.4; + word-break: break-word; + } + + .shop-ai-restore-btn { + padding: 6px 12px; + background: #f5f5f5; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + transition: background 0.2s; + } + + .shop-ai-restore-btn:hover { + background: #eee; + } + + .shop-ai-restore-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .shop-ai-no-history, + .shop-ai-loading, + .shop-ai-error { + text-align: center; + padding: 20px; + color: #666; + font-size: 14px; + } + + .shop-ai-error { + color: #c00; + } + + /* Mobile responsiveness for modals */ + @media (max-width: 480px) { + .shop-ai-auth-content, + .shop-ai-settings-content { + max-width: 100%; + max-height: 90vh; + margin: 10px; + } + + .shop-ai-settings-tabs { + flex-wrap: wrap; + } + + .shop-ai-tab { + flex: 1; + text-align: center; + min-width: 100px; + } + } diff --git a/extensions/chat-bubble/assets/chat.js b/extensions/chat-bubble/assets/chat.js index 74b30313..3997c7ee 100644 --- a/extensions/chat-bubble/assets/chat.js +++ b/extensions/chat-bubble/assets/chat.js @@ -33,7 +33,9 @@ closeButton: container.querySelector('.shop-ai-chat-close'), chatInput: container.querySelector('.shop-ai-chat-input input'), sendButton: container.querySelector('.shop-ai-chat-send'), - messagesContainer: container.querySelector('.shop-ai-chat-messages') + messagesContainer: container.querySelector('.shop-ai-chat-messages'), + userButton: container.querySelector('.shop-ai-user-btn'), + settingsButton: container.querySelector('.shop-ai-settings-btn') }; // Detect mobile device @@ -52,7 +54,7 @@ * Set up all event listeners for UI interactions */ setupEventListeners: function() { - const { chatBubble, closeButton, chatInput, sendButton, messagesContainer } = this.elements; + const { chatBubble, closeButton, chatInput, sendButton, messagesContainer, userButton, settingsButton } = this.elements; // Toggle chat window visibility chatBubble.addEventListener('click', () => this.toggleChatWindow()); @@ -60,6 +62,25 @@ // Close chat window closeButton.addEventListener('click', () => this.closeChatWindow()); + // User button - login/logout + if (userButton) { + userButton.addEventListener('click', () => { + if (ShopAIChat.User.isLoggedIn()) { + // Show user menu + this.showUserMenu(); + } else { + ShopAIChat.User.showAuthModal('login'); + } + }); + } + + // Settings button + if (settingsButton) { + settingsButton.addEventListener('click', () => { + ShopAIChat.Settings.showPanel(); + }); + } + // Send message when pressing Enter in input chatInput.addEventListener('keypress', (e) => { if (e.key === 'Enter' && chatInput.value.trim() !== '') { @@ -184,6 +205,78 @@ } }, + /** + * Update user button appearance based on login state + */ + updateUserButton: function() { + const { userButton } = this.elements; + if (!userButton) return; + + if (ShopAIChat.User.isLoggedIn()) { + userButton.classList.add('logged-in'); + userButton.title = ShopAIChat.User.username; + } else { + userButton.classList.remove('logged-in'); + userButton.title = '登录'; + } + }, + + /** + * Show user menu (for logged in users) + */ + showUserMenu: function() { + // Remove existing menu if any + const existingMenu = document.querySelector('.shop-ai-user-menu'); + if (existingMenu) { + existingMenu.remove(); + return; + } + + const { userButton } = this.elements; + const rect = userButton.getBoundingClientRect(); + + const menu = document.createElement('div'); + menu.classList.add('shop-ai-user-menu'); + menu.innerHTML = ` + +
提示词设置
+
退出登录
+ `; + + menu.style.position = 'fixed'; + menu.style.top = (rect.bottom + 5) + 'px'; + menu.style.right = (window.innerWidth - rect.right) + 'px'; + + document.body.appendChild(menu); + + // Event handlers + menu.querySelectorAll('.shop-ai-user-menu-item').forEach(item => { + item.addEventListener('click', () => { + const action = item.dataset.action; + if (action === 'settings') { + ShopAIChat.Settings.showPanel(); + } else if (action === 'logout') { + ShopAIChat.User.logout(); + const messagesContainer = ShopAIChat.UI.elements.messagesContainer; + ShopAIChat.Message.add('您已退出登录', 'assistant', messagesContainer); + } + menu.remove(); + }); + }); + + // Close menu when clicking outside + setTimeout(() => { + document.addEventListener('click', function closeMenu(e) { + if (!menu.contains(e.target) && e.target !== userButton) { + menu.remove(); + document.removeEventListener('click', closeMenu); + } + }); + }, 0); + }, + /** * Display product results in the chat * @param {Array} products - Array of product data objects @@ -491,14 +584,23 @@ try { const promptType = window.shopChatConfig?.promptType || "standardAssistant"; const customSystemPrompt = window.shopChatConfig?.customSystemPrompt || ""; - const requestBody = JSON.stringify({ + + // 构建请求体,如果用户已登录则包含 user_id + const requestData = { message: userMessage, conversation_id: conversationId, prompt_type: promptType, system_prompt_override: customSystemPrompt && customSystemPrompt.trim() !== '' ? customSystemPrompt : undefined - }); + }; + + // 如果用户已登录,添加 user_id(让后端加载用户专属提示词) + if (ShopAIChat.User.isLoggedIn()) { + requestData.user_id = ShopAIChat.User.userId; + } + + const requestBody = JSON.stringify(requestData); const streamUrl = this.getApiBaseUrl() + '/chat'; const shopId = window.shopId; @@ -839,6 +941,448 @@ } }, + /** + * User authentication functionality + * 用户认证模块 + */ + User: { + userId: null, + username: null, + token: null, + currentPrompt: null, + + /** + * Initialize user state from localStorage + */ + init: function() { + this.userId = localStorage.getItem('shopChatUserId'); + this.username = localStorage.getItem('shopChatUsername'); + this.token = localStorage.getItem('shopChatToken'); + this.currentPrompt = localStorage.getItem('shopChatCurrentPrompt'); + }, + + /** + * Check if user is logged in + */ + isLoggedIn: function() { + return !!(this.userId && this.token); + }, + + /** + * Get authorization header + */ + getAuthHeader: function() { + return this.token ? { 'Authorization': 'Bearer ' + this.token } : {}; + }, + + /** + * Register a new user + */ + register: async function(username, password) { + const shopId = window.shopId; + const apiBaseUrl = ShopAIChat.API.getApiBaseUrl(); + + const response = await fetch(apiBaseUrl + '/api/chat-auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'register', + username, + password, + shopId: String(shopId) + }) + }); + + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || '注册失败'); + } + + this.saveUserData(data); + return data; + }, + + /** + * Login an existing user + */ + login: async function(username, password) { + const shopId = window.shopId; + const apiBaseUrl = ShopAIChat.API.getApiBaseUrl(); + + const response = await fetch(apiBaseUrl + '/api/chat-auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'login', + username, + password, + shopId: String(shopId) + }) + }); + + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || '登录失败'); + } + + this.saveUserData(data); + return data; + }, + + /** + * Save user data to localStorage + */ + saveUserData: function(data) { + this.userId = data.userId; + this.username = data.username; + this.token = data.token; + this.currentPrompt = data.currentPrompt || null; + + localStorage.setItem('shopChatUserId', data.userId); + localStorage.setItem('shopChatUsername', data.username); + localStorage.setItem('shopChatToken', data.token); + if (data.currentPrompt) { + localStorage.setItem('shopChatCurrentPrompt', data.currentPrompt); + } + }, + + /** + * Logout the current user + */ + logout: function() { + this.userId = null; + this.username = null; + this.token = null; + this.currentPrompt = null; + + localStorage.removeItem('shopChatUserId'); + localStorage.removeItem('shopChatUsername'); + localStorage.removeItem('shopChatToken'); + localStorage.removeItem('shopChatCurrentPrompt'); + + // Update UI + ShopAIChat.UI.updateUserButton(); + }, + + /** + * Show login/register modal + */ + showAuthModal: function(mode = 'login') { + // Remove existing modal if any + const existingModal = document.querySelector('.shop-ai-auth-modal'); + if (existingModal) existingModal.remove(); + + const modal = document.createElement('div'); + modal.classList.add('shop-ai-auth-modal'); + modal.innerHTML = ` +
+
+

${mode === 'login' ? '登录' : '注册'}

+ +
+
+
+ + +
+
+ + +
+ + +
+
+ ${mode === 'login' + ? '还没有账户?立即注册' + : '已有账户?立即登录'} +
+
+ `; + + document.body.appendChild(modal); + + // Event handlers + const closeBtn = modal.querySelector('.shop-ai-auth-close'); + const form = modal.querySelector('.shop-ai-auth-form'); + const switchLink = modal.querySelector('.shop-ai-auth-switch a'); + const errorDiv = modal.querySelector('.shop-ai-auth-error'); + + closeBtn.addEventListener('click', () => modal.remove()); + modal.addEventListener('click', (e) => { + if (e.target === modal) modal.remove(); + }); + + switchLink.addEventListener('click', (e) => { + e.preventDefault(); + modal.remove(); + this.showAuthModal(e.target.dataset.mode); + }); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const username = form.username.value.trim(); + const password = form.password.value; + + try { + errorDiv.style.display = 'none'; + const submitBtn = form.querySelector('.shop-ai-auth-submit'); + submitBtn.disabled = true; + submitBtn.textContent = '处理中...'; + + if (mode === 'login') { + await this.login(username, password); + } else { + await this.register(username, password); + } + + modal.remove(); + ShopAIChat.UI.updateUserButton(); + + // Show success message + const messagesContainer = ShopAIChat.UI.elements.messagesContainer; + ShopAIChat.Message.add(`${mode === 'login' ? '登录' : '注册'}成功!欢迎 ${this.username}`, 'assistant', messagesContainer); + } catch (error) { + errorDiv.textContent = error.message; + errorDiv.style.display = 'block'; + const submitBtn = form.querySelector('.shop-ai-auth-submit'); + submitBtn.disabled = false; + submitBtn.textContent = mode === 'login' ? '登录' : '注册'; + } + }); + } + }, + + /** + * Settings panel functionality + * 设置面板模块 + */ + Settings: { + /** + * Show settings panel + */ + showPanel: function() { + if (!ShopAIChat.User.isLoggedIn()) { + ShopAIChat.User.showAuthModal('login'); + return; + } + + // Remove existing panel if any + const existingPanel = document.querySelector('.shop-ai-settings-panel'); + if (existingPanel) existingPanel.remove(); + + const panel = document.createElement('div'); + panel.classList.add('shop-ai-settings-panel'); + panel.innerHTML = ` +
+
+

提示词设置

+ +
+
+
+ + +
+
+ +
+ +
+
+
+
+
加载中...
+
+
+
+
+ `; + + document.body.appendChild(panel); + + // Event handlers + const closeBtn = panel.querySelector('.shop-ai-settings-close'); + const tabs = panel.querySelectorAll('.shop-ai-tab'); + const saveBtn = panel.querySelector('.shop-ai-save-prompt'); + const editor = panel.querySelector('.shop-ai-prompt-editor'); + + closeBtn.addEventListener('click', () => panel.remove()); + panel.addEventListener('click', (e) => { + if (e.target === panel) panel.remove(); + }); + + tabs.forEach(tab => { + tab.addEventListener('click', () => { + tabs.forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + + panel.querySelectorAll('.shop-ai-tab-content').forEach(content => { + content.classList.remove('active'); + if (content.dataset.tab === tab.dataset.tab) { + content.classList.add('active'); + } + }); + + if (tab.dataset.tab === 'history') { + this.loadHistory(panel); + } + }); + }); + + saveBtn.addEventListener('click', async () => { + const prompt = editor.value.trim(); + try { + saveBtn.disabled = true; + saveBtn.textContent = '保存中...'; + await this.savePrompt(prompt); + saveBtn.textContent = '保存成功!'; + setTimeout(() => { + saveBtn.disabled = false; + saveBtn.textContent = '保存提示词'; + }, 2000); + } catch (error) { + alert('保存失败: ' + error.message); + saveBtn.disabled = false; + saveBtn.textContent = '保存提示词'; + } + }); + }, + + /** + * Save prompt to server + */ + savePrompt: async function(prompt) { + const apiBaseUrl = ShopAIChat.API.getApiBaseUrl(); + + const response = await fetch(apiBaseUrl + '/api/user-prompt', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...ShopAIChat.User.getAuthHeader() + }, + body: JSON.stringify({ + action: 'update', + prompt + }) + }); + + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || '保存失败'); + } + + // Update local state + ShopAIChat.User.currentPrompt = data.currentPrompt; + localStorage.setItem('shopChatCurrentPrompt', data.currentPrompt || ''); + + return data; + }, + + /** + * Load prompt history + */ + loadHistory: async function(panel) { + const historyList = panel.querySelector('.shop-ai-history-list'); + const apiBaseUrl = ShopAIChat.API.getApiBaseUrl(); + + try { + const response = await fetch(apiBaseUrl + '/api/user-prompt?action=history', { + headers: ShopAIChat.User.getAuthHeader() + }); + + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || '加载失败'); + } + + if (!data.history || data.history.length === 0) { + historyList.innerHTML = '
暂无历史记录
'; + return; + } + + historyList.innerHTML = data.history.map(item => ` +
+
+ 版本 ${item.version} + ${new Date(item.createdAt).toLocaleString()} +
+
${this.truncateText(item.content, 100)}
+ +
+ `).join(''); + + // Add restore handlers + historyList.querySelectorAll('.shop-ai-restore-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + const historyId = e.target.dataset.id; + try { + btn.disabled = true; + btn.textContent = '恢复中...'; + await this.restorePrompt(historyId); + + // Update editor + const editor = panel.querySelector('.shop-ai-prompt-editor'); + editor.value = ShopAIChat.User.currentPrompt || ''; + + // Switch to editor tab + panel.querySelector('.shop-ai-tab[data-tab="editor"]').click(); + + alert('恢复成功!'); + } catch (error) { + alert('恢复失败: ' + error.message); + btn.disabled = false; + btn.textContent = '恢复此版本'; + } + }); + }); + } catch (error) { + historyList.innerHTML = '
加载失败: ' + error.message + '
'; + } + }, + + /** + * Restore prompt from history + */ + restorePrompt: async function(historyId) { + const apiBaseUrl = ShopAIChat.API.getApiBaseUrl(); + + const response = await fetch(apiBaseUrl + '/api/user-prompt', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...ShopAIChat.User.getAuthHeader() + }, + body: JSON.stringify({ + action: 'restore', + historyId + }) + }); + + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || '恢复失败'); + } + + // Update local state + ShopAIChat.User.currentPrompt = data.currentPrompt; + localStorage.setItem('shopChatCurrentPrompt', data.currentPrompt || ''); + + return data; + }, + + /** + * Truncate text with ellipsis + */ + truncateText: function(text, maxLength) { + if (!text) return ''; + if (text.length <= maxLength) return text; + return text.substring(0, maxLength) + '...'; + } + }, + /** * Product-related functionality */ @@ -931,6 +1475,10 @@ this.UI.init(container); + // Initialize User module + this.User.init(); + this.UI.updateUserButton(); + // Check for existing conversation const conversationId = sessionStorage.getItem('shopAiConversationId'); diff --git a/extensions/chat-bubble/blocks/chat-interface.liquid b/extensions/chat-bubble/blocks/chat-interface.liquid index b3032081..5f500b7b 100644 --- a/extensions/chat-bubble/blocks/chat-interface.liquid +++ b/extensions/chat-bubble/blocks/chat-interface.liquid @@ -11,7 +11,21 @@
{{ 'chat.title' | t }}
- +
+ + + +
diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 954788d9..a996ca29 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -80,3 +80,33 @@ model CustomerAccountUrls { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +// 聊天用户表 - 用于用户自助注册和登录 +model ChatUser { + id String @id @default(cuid()) + shopId String // 所属商店ID + username String // 用户名 + passwordHash String // 密码哈希 + currentPrompt String? // 当前使用的提示词 + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + promptHistory PromptHistory[] // 提示词历史记录 + + @@unique([shopId, username]) // 同一商店用户名唯一 + @@index([shopId]) +} + +// 提示词历史表 - 保存每次修改的历史版本 +model PromptHistory { + id String @id @default(cuid()) + userId String + user ChatUser @relation(fields: [userId], references: [id], onDelete: Cascade) + content String // 提示词内容 + version Int // 版本号 + createdAt DateTime @default(now()) + + @@index([userId]) + @@index([userId, version]) +} From a06f73c208a6bdb14ddd7dde3a049a5e195ff771 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Dec 2025 15:00:52 +0000 Subject: [PATCH 09/13] =?UTF-8?q?fix:=20=E6=B7=BB=E5=8A=A0=E7=BC=BA?= =?UTF-8?q?=E5=A4=B1=E7=9A=84=20Shopify=20OAuth=20scopes=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 .env.example 中添加 SCOPES 环境变量示例 - 在 shopify.app.toml 中添加 Admin API scopes (read_orders, read_products, read_customers, read_inventory, write_products) - 保留原有的 Customer Account API scopes 注意:更改 scopes 后需要重新安装 App 才能生效 --- .env.example | 5 +++++ shopify.app.toml | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 7e6b0f9d..e44647c4 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,8 @@ CLAUDE_API_KEY=YOUR_CLAUDE_API_KEY REDIRECT_URL=https://localhost:3458/auth/callback SHOPIFY_API_KEY=YOUR_APP_CLIENT_ID +SHOPIFY_API_SECRET=YOUR_APP_SECRET_KEY + +# Shopify OAuth Scopes - 必须包含你需要的所有权限 +# 更改此配置后,需要重新安装App才能生效 +SCOPES=read_orders,read_products,read_customers,read_inventory,write_products diff --git a/shopify.app.toml b/shopify.app.toml index dd39542a..9a8bf87c 100644 --- a/shopify.app.toml +++ b/shopify.app.toml @@ -14,7 +14,10 @@ api_version = "2025-04" [access_scopes] # Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes -scopes = "customer_read_customers,customer_read_orders,customer_read_store_credit_account_transactions,customer_read_store_credit_accounts,unauthenticated_read_product_listings" +# Admin API scopes (标准权限 - 用于读取店铺数据) +# Customer Account API scopes (客户账户权限) +# 注意:更改scopes后需要重新安装App才能生效 +scopes = "read_orders,read_products,read_customers,read_inventory,write_products,customer_read_customers,customer_read_orders,customer_read_store_credit_account_transactions,customer_read_store_credit_accounts,unauthenticated_read_product_listings" [auth] redirect_urls = [ "https://shop-chat-agent.com/api/auth" ] From 2a5c8f16dbea2a9c559000dde129d94df22e0dc5 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 13 Dec 2025 15:30:41 +0000 Subject: [PATCH 10/13] commit shopify.app.toml --- shopify.app.toml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/shopify.app.toml b/shopify.app.toml index dd39542a..73633f29 100644 --- a/shopify.app.toml +++ b/shopify.app.toml @@ -1,28 +1,27 @@ # Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration client_id = "4112cc090597f73175212de8b77b148d" -name = "shop-chat-agent" -handle = "shop-chat-agent" -application_url = "https://shop-chat-agent.com" +name = "shop-chat-agent-app" +application_url = "https://example.com" embedded = true +handle = "shop-chat-agent-app" [build] +automatically_update_urls_on_dev = true include_config_on_deploy = true [webhooks] -api_version = "2025-04" +api_version = "2026-01" [access_scopes] # Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes scopes = "customer_read_customers,customer_read_orders,customer_read_store_credit_account_transactions,customer_read_store_credit_accounts,unauthenticated_read_product_listings" [auth] -redirect_urls = [ "https://shop-chat-agent.com/api/auth" ] +redirect_urls = [ "https://example.com/api/auth" ] [pos] embedded = false [customer_authentication] -redirect_uris = [ - "https://shop-chat-agent.com/callback" -] +redirect_uris = [ "https://shop-chat-agent.com/callback" ] From 7edf8e53d02f423c990b88daed8e6648bb444ac7 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 13 Dec 2025 15:30:41 +0000 Subject: [PATCH 11/13] commit shopify.app.toml --- shopify.app.toml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/shopify.app.toml b/shopify.app.toml index 9a8bf87c..9f100418 100644 --- a/shopify.app.toml +++ b/shopify.app.toml @@ -1,16 +1,17 @@ # Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration client_id = "4112cc090597f73175212de8b77b148d" -name = "shop-chat-agent" -handle = "shop-chat-agent" -application_url = "https://shop-chat-agent.com" +name = "shop-chat-agent-app" +application_url = "https://example.com" embedded = true +handle = "shop-chat-agent-app" [build] +automatically_update_urls_on_dev = true include_config_on_deploy = true [webhooks] -api_version = "2025-04" +api_version = "2026-01" [access_scopes] # Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes @@ -20,12 +21,10 @@ api_version = "2025-04" scopes = "read_orders,read_products,read_customers,read_inventory,write_products,customer_read_customers,customer_read_orders,customer_read_store_credit_account_transactions,customer_read_store_credit_accounts,unauthenticated_read_product_listings" [auth] -redirect_urls = [ "https://shop-chat-agent.com/api/auth" ] +redirect_urls = [ "https://example.com/api/auth" ] [pos] embedded = false [customer_authentication] -redirect_uris = [ - "https://shop-chat-agent.com/callback" -] +redirect_uris = [ "https://shop-chat-agent.com/callback" ] From 8c0940c9a96069f2b6f7cb22b07e49529c3bfb57 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 13 Dec 2025 15:34:23 +0000 Subject: [PATCH 12/13] add prisma/migrations/20251210123806_add_chat_user_and_prompt_history --- .../migration.sql | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 prisma/migrations/20251210123806_add_chat_user_and_prompt_history/migration.sql diff --git a/prisma/migrations/20251210123806_add_chat_user_and_prompt_history/migration.sql b/prisma/migrations/20251210123806_add_chat_user_and_prompt_history/migration.sql new file mode 100644 index 00000000..3fbebb6d --- /dev/null +++ b/prisma/migrations/20251210123806_add_chat_user_and_prompt_history/migration.sql @@ -0,0 +1,33 @@ +-- CreateTable +CREATE TABLE "ChatUser" ( + "id" TEXT NOT NULL PRIMARY KEY, + "shopId" TEXT NOT NULL, + "username" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "currentPrompt" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "PromptHistory" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "content" TEXT NOT NULL, + "version" INTEGER NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PromptHistory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "ChatUser" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "ChatUser_shopId_idx" ON "ChatUser"("shopId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ChatUser_shopId_username_key" ON "ChatUser"("shopId", "username"); + +-- CreateIndex +CREATE INDEX "PromptHistory_userId_idx" ON "PromptHistory"("userId"); + +-- CreateIndex +CREATE INDEX "PromptHistory_userId_version_idx" ON "PromptHistory"("userId", "version"); From b345c5f9e9476750e26558a6b831932515f50e70 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Dec 2025 16:26:20 +0000 Subject: [PATCH 13/13] fix: add missing webhook route for APP_UNINSTALLED The shopify.web.toml configured webhooks_path as /webhooks/app/uninstalled but no corresponding route existed. Created webhooks.app.uninstalled.jsx to handle the APP_UNINSTALLED webhook from Shopify. --- app/routes/webhooks.app.uninstalled.jsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 app/routes/webhooks.app.uninstalled.jsx diff --git a/app/routes/webhooks.app.uninstalled.jsx b/app/routes/webhooks.app.uninstalled.jsx new file mode 100644 index 00000000..7448e7ba --- /dev/null +++ b/app/routes/webhooks.app.uninstalled.jsx @@ -0,0 +1,20 @@ +import { authenticate } from "../shopify.server"; +import db from "../db.server"; + +export const action = async ({ request }) => { + const { shop, session, topic } = await authenticate.webhook(request); + + console.log(`Received ${topic} webhook for ${shop}`); + + switch (topic) { + case 'APP_UNINSTALLED': + if (session) { + await db.session.deleteMany({ where: { shop } }); + } + break; + default: + throw new Response('Unhandled webhook topic', { status: 404 }); + } + + return new Response(); +};