feat: 部署初版测试
Some checks failed
Extension Build & Release / build (push) Failing after 1m5s
Backend Deploy (Go + Docker) / deploy (push) Failing after 1m40s
Web Console Deploy (Vue 3 + Vite) / deploy (push) Has been cancelled

This commit is contained in:
zs
2026-03-02 21:25:21 +08:00
parent db3abb3174
commit 8cf6cb944b
97 changed files with 10250 additions and 209 deletions

1
extension/.env Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8080/api/v1

View File

@@ -0,0 +1,3 @@
# MUST ALWAYS BE AN ABSOLUTE DOMAIN!
# Extensions cannot use proxy relatives like /api/v1
VITE_API_BASE_URL=https://insight.buildapp.eu.org/api/v1

View File

@@ -10,6 +10,10 @@
"service_worker": "src/background/index.ts",
"type": "module"
},
"options_ui": {
"page": "options.html",
"open_in_tab": true
},
"permissions": [
"storage",
"activeTab"

12
extension/options.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>InsightReply Options & Auth</title>
</head>
<body class="bg-[#0A0A0A] text-white">
<div id="app"></div>
<script type="module" src="/src/options/main.ts"></script>
</body>
</html>

View File

@@ -1,59 +1,167 @@
<script setup lang="ts">
import { ref } from 'vue'
import { cn } from './lib/utils'
import { ref, onMounted } from 'vue'
const isLoading = ref(false)
const activeTab = ref<'settings' | 'history'>('settings')
const triggerMockLoading = () => {
isLoading.value = true
setTimeout(() => {
isLoading.value = false
}, 1000)
// Settings State
const identity = ref('Independent Developer / Founder')
const language = ref('auto')
const isSaving = ref(false)
// History State
const historyList = ref<Array<any>>([])
onMounted(() => {
// Load settings
chrome.storage.sync.get(['identity', 'language'], (res) => {
if (res.identity) identity.value = String(res.identity)
if (res.language) language.value = String(res.language)
})
// Load history
chrome.storage.local.get(['history'], (res) => {
if (Array.isArray(res.history)) {
historyList.value = res.history
}
})
})
const saveSettings = () => {
isSaving.value = true
chrome.storage.sync.set({
identity: identity.value,
language: language.value
}, () => {
setTimeout(() => {
isSaving.value = false
}, 600)
})
}
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text)
}
const clearHistory = () => {
chrome.storage.local.set({ history: [] }, () => {
historyList.value = []
})
}
</script>
<template>
<div class="w-[400px] h-[600px] bg-[#0A0A0A]/90 backdrop-blur-xl border border-white/10 text-[#E5E5E5] p-6 flex flex-col font-sans">
<div class="w-[400px] h-[600px] bg-[#0A0A0A] text-[#E5E5E5] flex flex-col font-sans overflow-hidden">
<!-- Title Area -->
<div class="mb-6">
<h1 class="text-xl font-medium tracking-tight bg-gradient-to-r from-violet-500 to-blue-500 bg-clip-text text-transparent inline-block">
InsightReply
</h1>
<p class="text-xs text-zinc-400 mt-1">Social Insight Copilot</p>
<!-- Header -->
<div class="p-5 border-b border-white/10 bg-white/5 flex justify-between items-center">
<div>
<h1 class="text-xl font-semibold tracking-tight bg-gradient-to-r from-violet-500 to-blue-500 bg-clip-text text-transparent">
InsightReply
</h1>
<p class="text-[11px] text-zinc-500 mt-1 uppercase tracking-widest font-semibold">Social Copilot</p>
</div>
<!-- Tab Switcher -->
<div class="flex gap-1 bg-black/50 p-1 rounded-lg border border-white/5">
<button
@click="activeTab = 'settings'"
:class="['px-3 py-1 text-xs font-medium rounded-md transition-all', activeTab === 'settings' ? 'bg-zinc-800 text-white shadow-md' : 'text-zinc-500 hover:text-zinc-300']"
>
Settings
</button>
<button
@click="activeTab = 'history'"
:class="['px-3 py-1 text-xs font-medium rounded-md transition-all', activeTab === 'history' ? 'bg-zinc-800 text-white shadow-md' : 'text-zinc-500 hover:text-zinc-300']"
>
History
</button>
</div>
</div>
<!-- Main Content Area -->
<div class="flex-1 overflow-y-auto pr-2 space-y-4">
<div class="flex-1 overflow-y-auto p-5">
<!-- Example Heat Score Card -->
<div class="bg-[#171717] rounded-xl p-4 border border-white/5 shadow-lg shadow-black/50">
<div class="flex justify-between items-center mb-2">
<span class="text-sm font-medium text-zinc-300">Current Tweet Heat</span>
<span class="text-xs font-bold px-2 py-0.5 rounded-full bg-orange-500/20 text-orange-400 border border-orange-500/20">Hot</span>
</div>
<div class="flex items-end gap-2">
<span class="text-3xl font-semibold tracking-tighter">87.5</span>
<span class="text-xs text-zinc-500 mb-1">/ 100</span>
<!-- Settings Tab -->
<div v-if="activeTab === 'settings'" class="animate-in fade-in slide-in-from-right-4 duration-300 space-y-6">
<div>
<h2 class="text-base font-medium text-white mb-1">Your Identity Profile</h2>
<p class="text-xs text-zinc-400 mb-4">Set your background so AI can generate relevant, authentic replies matching your persona.</p>
<div class="space-y-4">
<div class="space-y-1.5">
<label class="text-xs font-medium text-zinc-300">Identity Label</label>
<input
v-model="identity"
type="text"
class="w-full bg-[#171717] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 transition-colors"
placeholder="e.g. AI Founder, Indie Hacker, Marketer"
/>
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium text-zinc-300">Reply Language</label>
<select
v-model="language"
class="w-full bg-[#171717] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 transition-colors appearance-none"
>
<option value="auto">Auto (Match Tweet)</option>
<option value="en">English (en)</option>
<option value="zh">Chinese (zh)</option>
</select>
</div>
</div>
</div>
<button
@click="saveSettings"
:disabled="isSaving"
class="w-full py-2.5 bg-brand-primary hover:bg-brand-primary/90 text-white rounded-lg text-sm font-medium transition-all shadow-lg flex items-center justify-center gap-2 mt-4 disabled:opacity-70 disabled:cursor-not-allowed"
>
<span v-if="isSaving" class="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin"></span>
{{ isSaving ? 'Saving...' : 'Save Preferences' }}
</button>
</div>
<!-- Action Button -->
<button
@click="triggerMockLoading"
:disabled="isLoading"
:class="cn(
'w-full py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ease-in-out',
'flex items-center justify-center gap-2',
isLoading
? 'bg-[#171717] text-zinc-500 border border-white/5 cursor-not-allowed'
: 'bg-brand-primary hover:bg-brand-primary/90 text-white shadow-lg shadow-brand-primary/20 hover:scale-[0.98]'
)"
>
<span v-if="isLoading" class="animate-spin inline-block w-4 h-4 border-2 border-white/20 border-t-white rounded-full"></span>
{{ isLoading ? 'Generating Insights...' : 'Generate Replies' }}
</button>
<!-- History Tab -->
<div v-if="activeTab === 'history'" class="animate-in fade-in slide-in-from-left-4 duration-300 flex flex-col h-full">
<div class="flex justify-between items-center mb-4 pb-2 border-b border-white/10">
<h2 class="text-xs font-semibold text-zinc-400 uppercase tracking-widest">Generation History</h2>
<button @click="clearHistory" class="text-xs text-red-400/80 hover:text-red-400 transition-colors">Clear All</button>
</div>
<div v-if="historyList.length === 0" class="flex-1 flex flex-col items-center justify-center text-zinc-500 space-y-3 pb-10">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" class="opacity-20"><path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<p class="text-sm">No history yet</p>
</div>
<div v-else class="space-y-6">
<div v-for="(item, idx) in historyList" :key="idx" class="space-y-3 bg-white/5 rounded-xl p-4 border border-white/5">
<div class="text-[10px] text-zinc-500 font-mono">{{ new Date(item.timestamp).toLocaleString() }}</div>
<div class="text-xs italic text-zinc-400 border-l-2 border-zinc-700 pl-2">"{{ item.tweetContent?.substring(0, 100) }}{{ item.tweetContent?.length > 100 ? '...' : '' }}"</div>
<div class="space-y-2 mt-3 pt-3 border-t border-white/5">
<div v-for="(reply, rIdx) in item.replies" :key="rIdx" class="bg-[#171717] rounded-lg p-3 group relative">
<div class="flex justify-between items-center mb-1">
<span class="text-[10px] font-medium text-brand-primary bg-brand-primary/10 px-1.5 py-0.5 rounded">{{ reply.strategy }}</span>
<button @click="copyToClipboard(reply.content)" class="opacity-0 group-hover:opacity-100 transition-opacity text-[10px] text-zinc-400 hover:text-white">Copy</button>
</div>
<div class="text-xs text-zinc-300 leading-relaxed">{{ reply.content }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style>
/* Scoped overrides */
body {
margin: 0;
padding: 0;
width: 400px;
height: 600px;
}
</style>

View File

@@ -5,33 +5,87 @@
console.log('InsightReply Background Script Loaded');
const API_BASE = 'http://localhost:8080/api/v1';
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1';
chrome.runtime.onMessage.addListener((message: { type: string; payload?: any }, _sender: chrome.runtime.MessageSender, sendResponse: (response?: any) => void) => {
if (message.type === 'SHOW_INSIGHT') {
console.log('Received tweet data in background:', message.payload);
}
if (message.type === 'FETCH_CUSTOM_STRATEGIES') {
chrome.storage.local.get(['jwt_token'], (res) => {
const token = res.jwt_token;
if (!token) {
sendResponse({ success: false, data: [] });
return;
}
fetch(`${API_BASE}/users/me/strategies`, {
headers: { 'Authorization': `Bearer ${token}` }
})
.then(resp => resp.json())
.then(data => {
if (data.code === 200 && Array.isArray(data.data)) {
sendResponse({ success: true, data: data.data });
} else {
sendResponse({ success: false, data: [] });
}
})
.catch(err => {
console.error('Fetch strategies error:', err);
sendResponse({ success: false, data: [] });
});
});
return true;
}
if (message.type === 'GENERATE_REPLY') {
const { tweetContent, strategy, identity } = message.payload;
fetch(`${API_BASE}/ai/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tweet_content: tweetContent,
strategy: strategy,
identity: identity || 'Independent Developer / Founder'
chrome.storage.local.get(['jwt_token'], (res) => {
const token = res.jwt_token;
if (!token) {
sendResponse({ success: false, error: 'unauthorized', message: 'Please log in via the Extension Options page.' });
return;
}
fetch(`${API_BASE}/ai/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
tweet_content: tweetContent,
strategy: strategy,
identity: identity || 'Independent Developer / Founder'
})
})
})
.then(resp => resp.json())
.then(data => {
sendResponse({ success: true, data: data.data });
})
.catch(err => {
console.error('API Error:', err);
sendResponse({ success: false, error: err.message });
});
.then(resp => resp.json())
.then(data => {
const resultData = data.data;
sendResponse({ success: true, data: resultData });
// Save to History Tab
if (resultData && resultData.replies) {
chrome.storage.local.get(['history'], (res) => {
const history = Array.isArray(res.history) ? res.history : [];
const newEntry = {
timestamp: Date.now(),
tweetContent,
replies: resultData.replies
};
const updatedHistory = [newEntry, ...history].slice(0, 50); // Keep last 50
chrome.storage.local.set({ history: updatedHistory });
});
}
})
.catch(err => {
console.error('API Error:', err);
sendResponse({ success: false, error: err.message });
});
});
return true; // Keep channel open for async response
}

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
const props = defineProps<{
tweetData?: {
id: string;
author: string;
content: string;
stats: {
@@ -15,40 +16,101 @@ const props = defineProps<{
const isVisible = ref(true)
const selectedStrategy = ref('Insightful')
const generatedReply = ref('')
const generatedReplies = ref<Array<{strategy: string, content: string}>>([])
const isGenerating = ref(false)
const strategies = [
const defaultStrategies = [
{ id: 'Insightful', label: '认知升级型', icon: '🧠' },
{ id: 'Humorous', label: '幽默风趣型', icon: '😄' },
{ id: 'Professional', label: '专业严谨型', icon: '⚖️' },
{ id: 'Supportive', label: '共鸣支持型', icon: '❤️' },
{ id: 'Critical', label: '锐评批判型', icon: '🔥' }
{ id: 'Critical', label: '锐评批判型', icon: '🔥' },
{ id: 'Quote', label: '引用转发型', icon: '💬' }
]
const strategies = ref([...defaultStrategies])
onMounted(() => {
chrome.runtime.sendMessage({ type: 'FETCH_CUSTOM_STRATEGIES' }, (response) => {
if (response && response.success && response.data) {
const customStrategies = response.data.map((s: any) => ({
id: s.strategy_key,
label: s.label,
icon: '✨'
}))
strategies.value = [...defaultStrategies, ...customStrategies]
}
})
})
const generate = () => {
if (!props.tweetData) return
isGenerating.value = true
chrome.runtime.sendMessage({
type: 'GENERATE_REPLY',
payload: {
tweetContent: props.tweetData.content,
strategy: selectedStrategy.value,
identity: 'Independent Developer / Founder' // Could be dynamic later
}
}, (response) => {
isGenerating.value = false
if (response && response.success) {
generatedReply.value = response.data.reply
} else {
generatedReply.value = 'Failed to generate reply. Please check your connection or API key.'
generatedReplies.value = []
chrome.storage.sync.get(['identity', 'language'], (res) => {
let finalIdentity = res.identity ? String(res.identity) : 'Independent Developer / Founder'
if (res.language && res.language !== 'auto') {
const langStr = String(res.language)
const langMap: Record<string, string> = { 'zh': 'Chinese (Simplified)', 'en': 'English' }
finalIdentity += ` (CRITICAL: You MUST reply in ${langMap[langStr] || langStr})`
}
chrome.runtime.sendMessage({
type: 'GENERATE_REPLY',
payload: {
tweetContent: props.tweetData!.content,
strategy: selectedStrategy.value,
identity: finalIdentity
}
}, (response) => {
isGenerating.value = false
if (response && response.success) {
generatedReplies.value = response.data.replies || []
} else if (response && response.error === 'unauthorized') {
generatedReplies.value = [{ strategy: 'Auth Required', content: 'Connection required. Please log in first.' }]
chrome.runtime.openOptionsPage()
} else {
generatedReplies.value = [{ strategy: 'Error', content: response?.error || 'Failed to generate reply. Please check your connection or API key.' }]
}
})
})
}
const copyToClipboard = () => {
navigator.clipboard.writeText(generatedReply.value)
const showProfileTip = ref(false)
const copyToClipboard = async (reply: any) => {
navigator.clipboard.writeText(reply.content)
showProfileTip.value = true
setTimeout(() => { showProfileTip.value = false }, 7000)
// Epic 13: Record generated reply for performance tracking telemetry
if (!props.tweetData || !props.tweetData.id) return;
chrome.storage.local.get(['jwt_token'], async (result) => {
if (result.jwt_token) {
try {
const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'
const token = result.jwt_token;
await fetch(`${apiBase}/replies/record`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
tweet_id: props.tweetData?.id,
strategy_type: reply.strategy || 'General',
content: reply.content,
language: 'en'
})
});
} catch (err) {
console.error('Failed to log telemetry:', err) // Non blocking telemetry
}
}
});
}
</script>
@@ -98,20 +160,40 @@ const copyToClipboard = () => {
</div>
<!-- Result Area -->
<div v-if="generatedReply" class="space-y-3 animate-in fade-in slide-in-from-bottom-2 duration-500">
<div v-if="generatedReplies.length > 0" class="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-500">
<div class="flex justify-between items-center">
<span class="text-xs font-semibold text-zinc-500 uppercase tracking-widest">Assistant Suggestion</span>
<button @click="copyToClipboard" class="text-[10px] text-brand-primary hover:underline">Copy Result</button>
<span class="text-xs font-semibold text-zinc-500 uppercase tracking-widest">Assistant Suggestions</span>
</div>
<div class="bg-[#171717] rounded-xl p-4 border border-white/10 text-sm leading-relaxed whitespace-pre-wrap">
{{ generatedReply }}
<div v-for="(reply, idx) in generatedReplies" :key="idx" class="bg-[#171717] rounded-xl p-4 border border-white/10 space-y-3 relative group transition-all hover:bg-[#202020] hover:border-white/20">
<div class="flex justify-between items-center">
<span class="text-xs font-medium text-brand-primary bg-brand-primary/10 px-2 py-0.5 rounded-full">
{{ reply.strategy || 'Suggestion' }}
</span>
<button @click="copyToClipboard(reply)" class="opacity-0 group-hover:opacity-100 transition-opacity text-[10px] text-zinc-400 hover:text-white flex items-center gap-1 cursor-pointer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
Copy
</button>
</div>
<div class="text-sm leading-relaxed whitespace-pre-wrap text-[#E5E5E5]">
{{ reply.content }}
</div>
</div>
</div>
</div>
<!-- Footer Action -->
<div class="p-4 bg-white/5 border-t border-white/5">
<div class="p-4 bg-white/5 border-t border-white/5 flex flex-col gap-3">
<transition enter-active-class="transition ease-out duration-300 transform" enter-from-class="opacity-0 translate-y-2" enter-to-class="opacity-100 translate-y-0" leave-active-class="transition ease-in duration-200 transform" leave-from-class="opacity-100 translate-y-0" leave-to-class="opacity-0 translate-y-2">
<div v-if="showProfileTip" class="bg-blue-500/10 border border-blue-500/20 text-blue-400 text-[11px] p-2.5 rounded-lg flex gap-2 items-start relative pr-6">
<span class="text-sm leading-none">💡</span>
<p class="leading-relaxed"><strong>Optimze your conversion:</strong> Ensure your X Bio and Pinned Tweet clearly mention your product/link!</p>
<button @click="showProfileTip = false" class="absolute top-2 right-2 text-blue-400/50 hover:text-blue-400"></button>
</div>
</transition>
<button
@click="generate"
:disabled="isGenerating"

View File

@@ -17,22 +17,48 @@ interface TweetData {
};
}
let relevanceKeywords: string[] = [];
chrome.storage.local.get(['relevance_keywords'], (res) => {
if (res.relevance_keywords) {
relevanceKeywords = String(res.relevance_keywords).split(',').map((k: string) => k.trim().toLowerCase()).filter(Boolean);
}
});
chrome.storage.onChanged.addListener((changes, namespace) => {
if (namespace === 'local' && changes.relevance_keywords) {
const val = String(changes.relevance_keywords.newValue || '');
relevanceKeywords = val.split(',').map((k: string) => k.trim().toLowerCase()).filter(Boolean);
}
});
// 2. 提取推文内容的逻辑
const parseStat = (statString: string): number => {
if (!statString) return 0;
const match = statString.match(/([\d,\.]+)\s*([KkMmB])?/i);
if (!match) return 0;
const baseStr = match[1] || '0';
let num = parseFloat(baseStr.replace(/,/g, ''));
const multiplier = match[2] ? match[2].toUpperCase() : '';
if (multiplier === 'K') num *= 1000;
if (multiplier === 'M') num *= 1000000;
if (multiplier === 'B') num *= 1000000000;
return num;
};
const extractTweetData = (tweetElement: HTMLElement): TweetData | null => {
try {
// 根据 X 的 DOM 结构提取 (可能会随 Twitter 更新而变化)
const textElement = tweetElement.querySelector('[data-testid="tweetText"]');
const authorElement = tweetElement.querySelector('[data-testid="User-Name"]');
const linkElement = tweetElement.querySelector('time')?.parentElement as HTMLAnchorElement;
// 互动数据提取
if (!textElement || !authorElement || !linkElement) return null;
const getStat = (testid: string) => {
const el = tweetElement.querySelector(`[data-testid="${testid}"]`);
return el?.getAttribute('aria-label') || '0';
};
if (!textElement || !authorElement || !linkElement) return null;
const tweetId = linkElement.href.split('/').pop() || '';
return {
@@ -51,13 +77,68 @@ const extractTweetData = (tweetElement: HTMLElement): TweetData | null => {
}
};
const injectBadges = (tweetElement: HTMLElement, data: TweetData) => {
if (tweetElement.dataset.insightBadged === 'true') return;
tweetElement.dataset.insightBadged = 'true';
const likes = parseStat(data.stats.likes);
const retweets = parseStat(data.stats.retweets);
const replies = parseStat(data.stats.replies);
const heatScore = likes * 1 + retweets * 2 + replies * 3;
let badgeText = '';
let badgeStyle = '';
if (heatScore > 50000) {
badgeText = '🔥 Trending';
badgeStyle = 'color: #f97316; background: rgba(249, 115, 22, 0.1); border: 1px solid rgba(249, 115, 22, 0.2);';
} else if (heatScore > 5000) {
badgeText = '⚡ Rising';
badgeStyle = 'color: #3b82f6; background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.2);';
}
let isRelevant = false;
if (relevanceKeywords.length > 0 && data.content) {
const contentLower = data.content.toLowerCase();
isRelevant = relevanceKeywords.some(kw => contentLower.includes(kw));
}
if (!badgeText && !isRelevant) return;
const authorSection = tweetElement.querySelector('[data-testid="User-Name"]');
if (!authorSection) return;
const container = document.createElement('div');
container.className = 'insight-badges-container';
container.style.cssText = `display: inline-flex; align-items: center; gap: 4px; margin-left: 8px;`;
if (badgeText) {
const heatBadge = document.createElement('span');
heatBadge.style.cssText = `padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: bold; ${badgeStyle}`;
heatBadge.innerText = badgeText;
container.appendChild(heatBadge);
}
if (isRelevant) {
const relBadge = document.createElement('span');
relBadge.style.cssText = `padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: bold; color: #10b981; background: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.2);`;
relBadge.innerText = '🎯 Relevant';
container.appendChild(relBadge);
}
const firstLine = authorSection.firstElementChild;
if (firstLine) {
firstLine.appendChild(container);
} else {
authorSection.appendChild(container);
}
};
// 3. 注入“Insight”按钮
const injectInsightButton = (tweetElement: HTMLElement) => {
// 查找操作栏 (Actions bar)
const actionBar = tweetElement.querySelector('[role="group"]');
if (!actionBar || actionBar.querySelector('.insight-reply-btn')) return;
// 创建按钮
const btnContainer = document.createElement('div');
btnContainer.className = 'insight-reply-btn';
btnContainer.style.display = 'flex';
@@ -65,7 +146,6 @@ const injectInsightButton = (tweetElement: HTMLElement) => {
btnContainer.style.marginLeft = '12px';
btnContainer.style.cursor = 'pointer';
// 按钮内部图标 (简易版)
btnContainer.innerHTML = `
<div style="padding: 4px; border-radius: 9999px; transition: background 0.2s;" onmouseover="this.style.background='rgba(139, 92, 246, 0.1)'" onmouseout="this.style.background='transparent'">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" style="color: #8B5CF6;">
@@ -77,9 +157,6 @@ const injectInsightButton = (tweetElement: HTMLElement) => {
btnContainer.onclick = (e) => {
e.stopPropagation();
const data = extractTweetData(tweetElement);
console.log('Target Tweet Data:', data);
// 发送消息给插件侧边栏/Popup (后续实现)
if (data) {
chrome.runtime.sendMessage({ type: 'SHOW_INSIGHT', payload: data });
}
@@ -92,7 +169,10 @@ const injectInsightButton = (tweetElement: HTMLElement) => {
const scanTweets = () => {
const tweets = document.querySelectorAll('article[data-testid="tweet"]');
tweets.forEach((tweet) => {
injectInsightButton(tweet as HTMLElement);
const el = tweet as HTMLElement;
injectInsightButton(el);
const data = extractTweetData(el);
if (data) injectBadges(el, data);
});
};

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import { ref } from 'vue'
const emit = defineEmits(['authenticated'])
const isLogin = ref(true)
const isLoading = ref(false)
const errorMsg = ref('')
const form = ref({
email: '',
password: ''
})
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'
const submitAuth = async () => {
if (!form.value.email || !form.value.password) {
errorMsg.value = 'Email and password are required'
return
}
errorMsg.value = ''
isLoading.value = true
const endpoint = isLogin.value ? '/auth/login' : '/users/register'
try {
const res = await fetch(`${API_BASE}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: form.value.email,
password: form.value.password
})
})
const data = await res.json()
if (!res.ok || data.code !== 200) {
throw new Error(data.message || 'Authentication failed')
}
// Auth Success
const token = data.data.token
const userId = data.data.user_id || data.data.user?.id
chrome.storage.local.set({ jwt_token: token, user_id: userId }, () => {
emit('authenticated', token)
})
} catch (err: any) {
errorMsg.value = err.message
} finally {
isLoading.value = false
}
}
</script>
<template>
<div class="space-y-4">
<div v-if="errorMsg" class="p-3 bg-red-500/10 border border-red-500/20 text-red-400 text-sm rounded-lg">
{{ errorMsg }}
</div>
<form @submit.prevent="submitAuth" class="space-y-4 text-left">
<div class="space-y-1.5">
<label class="text-xs font-medium text-zinc-300">Email Address</label>
<input
v-model="form.email"
type="email"
required
class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 transition-colors"
placeholder="your@email.com"
/>
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium text-zinc-300">Password</label>
<input
v-model="form.password"
type="password"
required
class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 transition-colors"
placeholder="••••••••"
/>
</div>
<button
type="submit"
:disabled="isLoading"
class="w-full py-2.5 bg-brand-primary hover:bg-brand-primary/90 text-white rounded-lg text-sm font-medium transition-all shadow-lg flex items-center justify-center gap-2 mt-2 disabled:opacity-70 disabled:cursor-not-allowed"
>
<span v-if="isLoading" class="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin"></span>
{{ isLoading ? 'Processing...' : (isLogin ? 'Sign In' : 'Create Account') }}
</button>
</form>
<div class="text-center mt-4 border-t border-white/10 pt-4">
<button
type="button"
@click="isLogin = !isLogin"
class="text-xs text-zinc-400 hover:text-white transition-colors"
>
{{ isLogin ? "Don't have an account? Sign up" : "Already have an account? Log in" }}
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const props = defineProps<{ token: string }>()
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'
const competitors = ref<Array<any>>([])
const isLoading = ref(true)
const isSubmitting = ref(false)
const errorMsg = ref('')
const showForm = ref(false)
const form = ref({
competitor_name: '',
platform: 'twitter',
target_handle: '',
keywords: ''
})
const fetchCompetitors = async () => {
isLoading.value = true
try {
const res = await fetch(`${API_BASE}/monitors/competitors`, {
headers: { 'Authorization': `Bearer ${props.token}` }
})
const data = await res.json()
if (res.ok && data.code === 200) {
competitors.value = data.data || []
}
} catch (err) {
console.error(err)
} finally {
isLoading.value = false
}
}
const createCompetitor = async () => {
isSubmitting.value = true
errorMsg.value = ''
try {
const res = await fetch(`${API_BASE}/monitors/competitors`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${props.token}`
},
body: JSON.stringify(form.value)
})
const data = await res.json()
if (!res.ok || data.code !== 200) {
throw new Error(data.message || 'Failed to add competitor')
}
// Reset form & reload
form.value = { competitor_name: '', platform: 'twitter', target_handle: '', keywords: '' }
showForm.value = false
await fetchCompetitors()
} catch (err: any) {
errorMsg.value = err.message
} finally {
isSubmitting.value = false
}
}
const deleteCompetitor = async (id: number) => {
if (!confirm('Are you sure you want to stop tracking this competitor?')) return
try {
const res = await fetch(`${API_BASE}/monitors/competitors/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${props.token}` }
})
if (res.ok) {
competitors.value = competitors.value.filter(c => c.id !== id)
}
} catch (err) {
console.error(err)
}
}
onMounted(() => {
if (props.token) fetchCompetitors()
})
</script>
<template>
<div class="space-y-6">
<div v-if="isLoading" class="p-10 flex justify-center">
<div class="w-6 h-6 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>
</div>
<div v-else>
<div class="flex justify-between items-center mb-6">
<p class="text-sm text-zinc-400">Track competitors tweets & mentions to find opportunistic conversations.</p>
<button
v-if="!showForm"
@click="showForm = true"
class="px-4 py-2 bg-brand-primary/20 text-brand-primary border border-brand-primary/30 rounded-lg text-xs font-semibold hover:bg-brand-primary hover:text-white transition-colors flex items-center gap-1"
>
+ Add Competitor
</button>
</div>
<!-- Create Form -->
<form v-if="showForm" @submit.prevent="createCompetitor" class="bg-[#171717] border border-white/10 rounded-xl p-6 space-y-4 mb-8">
<div class="flex justify-between items-center mb-2 border-b border-white/10 pb-2">
<h3 class="text-sm font-semibold text-white">Add Target Radar</h3>
<button type="button" @click="showForm = false" class="text-zinc-500 hover:text-white"></button>
</div>
<div v-if="errorMsg" class="p-3 bg-red-500/10 border border-red-500/20 text-red-400 text-xs rounded-lg">
{{ errorMsg }}
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="text-xs font-medium text-zinc-300">Competitor Product / Name</label>
<input v-model="form.competitor_name" required class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50" placeholder="e.g. Acme Corp" />
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium text-zinc-300">Platform</label>
<select v-model="form.platform" class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 appearance-none">
<option value="twitter">Twitter / X</option>
<option value="reddit">Reddit (Coming Soon)</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="text-xs font-medium text-zinc-300">Target Handle</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-zinc-500 pointer-events-none text-sm">@</span>
<input v-model="form.target_handle" class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg pl-8 pr-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50" placeholder="acmecorp" />
</div>
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium text-zinc-300">Track Keywords (Comma separated)</label>
<input v-model="form.keywords" required class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50" placeholder="e.g. acme sucks, alternative to acme" />
</div>
</div>
<div class="flex justify-end pt-4">
<button type="submit" :disabled="isSubmitting" class="px-5 py-2.5 bg-brand-primary hover:bg-brand-primary/90 text-white rounded-lg text-sm font-medium transition-all shadow flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed">
<span v-if="isSubmitting" class="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin"></span>
{{ isSubmitting ? 'Saving...' : 'Start Tracking' }}
</button>
</div>
</form>
<!-- List -->
<div v-if="competitors.length === 0 && !showForm" class="text-center p-12 border border-dashed border-white/10 rounded-xl text-zinc-500">
You are not tracking any competitors.
</div>
<div class="grid grid-cols-2 gap-4">
<div v-for="c in competitors" :key="c.id" class="bg-[#0A0A0A] border border-white/10 rounded-xl p-5 relative group hover:border-white/20 transition-colors">
<div class="flex justify-between items-start mb-4">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center text-brand-primary">
<svg v-if="c.platform === 'twitter'" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>
</div>
<div>
<h4 class="text-sm font-semibold text-white">{{ c.competitor_name }}</h4>
<p v-if="c.target_handle" class="text-xs text-zinc-500">@{{ c.target_handle }}</p>
</div>
</div>
<span class="px-2.5 py-1 bg-green-500/10 text-green-400 text-[10px] font-bold rounded-full border border-green-500/20 uppercase tracking-wide">Active</span>
</div>
<div class="space-y-1">
<p class="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold">Keywords Targeted</p>
<div class="flex flex-wrap gap-1.5">
<span v-for="kw in c.keywords.split(',')" :key="kw" class="px-2 py-0.5 bg-zinc-800 text-zinc-300 text-[11px] rounded flex border border-zinc-700">
{{ kw.trim() }}
</span>
</div>
</div>
<button @click="deleteCompetitor(c.id)" class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity text-red-500 hover:text-red-400 p-2 rounded-lg hover:bg-red-500/10">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,141 @@
<template>
<div class="h-full flex flex-col p-6 space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-white tracking-tight flex items-center gap-2">
🔥 Hot Opportunities
</h2>
<p class="text-zinc-400 mt-1">High-potential threads bubbling up from your monitoring radar</p>
</div>
<button
@click="loadHotTweets"
class="p-2 bg-white/5 border border-white/10 rounded-lg hover:bg-white/10 transition-colors"
title="Refresh data"
>
<svg v-if="loading" class="w-5 h-5 text-white animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
<span v-else></span>
</button>
</div>
<!-- Error State -->
<div v-if="error" class="bg-red-500/10 border border-red-500/20 text-red-400 p-4 rounded-xl flex items-start gap-3">
<span></span>
<p class="text-sm font-medium">{{ error }}</p>
</div>
<!-- Empty State -->
<div v-else-if="!loading && tweets.length === 0" class="flex-1 flex flex-col items-center justify-center text-center p-8 bg-white/5 border border-white/10 rounded-2xl border-dashed">
<div class="w-16 h-16 bg-white/5 rounded-full flex items-center justify-center mb-4">
<span class="text-2xl opacity-50">🔭</span>
</div>
<h3 class="text-white font-medium mb-2">No hot tweets found</h3>
<p class="text-sm text-zinc-400 max-w-sm">
The scraper is running in the background. It takes some time and activity to build momentum heat scores. Check back later!
</p>
</div>
<!-- List -->
<div v-else class="grid gap-4 md:grid-cols-2">
<div v-for="tweet in tweets" :key="tweet.id" class="p-5 bg-white/5 border border-white/10 hover:border-white/20 hover:bg-white/[0.07] transition-all rounded-xl relative group flex flex-col justify-between">
<!-- Header -->
<div class="flex justify-between items-start mb-3">
<div class="flex flex-col">
<span class="font-bold text-white tracking-wide">@{{ tweet.author_handle }}</span>
<span class="text-[11px] text-zinc-500 font-mono">{{ new Date(tweet.posted_at).toLocaleString() }}</span>
</div>
<div class="flex items-center gap-1.5 bg-rose-500/10 text-rose-400 border border-rose-500/20 px-2 py-1 rounded text-xs font-bold font-mono">
<span>🔥</span> {{ tweet.heat_score }}
</div>
</div>
<!-- Body -->
<p class="text-zinc-300 text-sm leading-relaxed mb-4 line-clamp-4">
{{ tweet.content }}
</p>
<!-- Footer -->
<div class="mt-auto flex items-center justify-between border-t border-white/5 pt-3">
<div class="flex gap-4 text-xs text-zinc-400 font-mono">
<span title="Replies">💬 {{ tweet.reply_count }}</span>
<span title="Retweets">🔁 {{ tweet.retweet_count }}</span>
<span title="Likes"> {{ tweet.like_count }}</span>
</div>
<button
@click="openTweet(tweet.author_handle, tweet.x_tweet_id)"
class="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-medium rounded-md transition-colors"
>
Reply on X
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const props = defineProps<{
token: string
}>()
interface Tweet {
id: string
x_tweet_id: string
author_handle: string
content: string
posted_at: string
like_count: number
retweet_count: number
reply_count: number
heat_score: number
}
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'
const tweets = ref<Tweet[]>([])
const loading = ref(false)
const error = ref('')
const loadHotTweets = async () => {
if (!props.token) {
error.value = 'Unauthenticated. Please login.'
return
}
loading.value = true
error.value = ''
try {
const response = await fetch(`${API_BASE}/tweets/hot`, {
headers: {
'Authorization': `Bearer ${props.token}`
}
})
if (!response.ok) {
throw new Error(`Failed to load hot opportunities (${response.status})`)
}
const data = await response.json()
tweets.value = data || []
} catch (err: any) {
error.value = err.message || 'Network error while fetching tweets.'
} finally {
loading.value = false
}
}
const openTweet = (handle: string, tweetId: string) => {
// Construct the active twitter URL to trigger the context script sidebar there
const url = `https://twitter.com/${handle}/status/${tweetId}`
chrome.tabs.create({ url })
}
onMounted(() => {
loadHotTweets()
})
</script>

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import Auth from './Auth.vue'
import Profile from './Profile.vue'
import Strategies from './Strategies.vue'
import Competitors from './Competitors.vue'
import HotTweets from './HotTweets.vue'
const token = ref('')
const isLoading = ref(true)
const activeTab = ref('profile') // 'profile', 'strategies', 'competitors'
onMounted(() => {
chrome.storage.local.get(['jwt_token'], (res) => {
if (res.jwt_token) {
token.value = String(res.jwt_token)
}
isLoading.value = false
})
})
const onAuthenticated = (newToken: string) => {
token.value = newToken
}
const logout = () => {
chrome.storage.local.remove(['jwt_token', 'user_id'], () => {
token.value = ''
})
}
</script>
<template>
<div class="min-h-screen flex flex-col items-center pt-20 pb-10 font-sans">
<div class="w-full max-w-4xl px-6">
<div class="mb-10 flex justify-between items-end border-b border-white/10 pb-4">
<div>
<h1 class="text-3xl font-bold tracking-tight bg-gradient-to-r from-violet-500 to-blue-500 bg-clip-text text-transparent">
InsightReply Dashboard
</h1>
<p class="text-zinc-400 mt-2">Configure your custom AI strategies, product context, and monitor radar.</p>
</div>
<div v-if="token" class="flex items-center gap-4">
<span class="text-sm text-green-400 flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-green-400"></div> Connected
</span>
<button @click="logout" class="text-sm text-zinc-500 hover:text-white transition-colors">Sign out</button>
</div>
</div>
<div v-if="isLoading" class="flex justify-center p-20">
<div class="w-8 h-8 border-4 border-white/20 border-t-brand-primary rounded-full animate-spin"></div>
</div>
<div v-else-if="!token" class="bg-[#171717] border border-white/10 rounded-2xl p-8 max-w-md mx-auto shadow-2xl mt-10">
<h2 class="text-xl font-semibold mb-2 text-center">Authentication Required</h2>
<p class="text-zinc-400 text-sm mb-6 text-center">Log in to link your InsightReply account.</p>
<Auth @authenticated="onAuthenticated" />
</div>
<div v-else class="flex gap-8 items-start">
<!-- Sidebar Menu -->
<div class="w-64 flex flex-col gap-2 shrink-0">
<button
@click="activeTab = 'profile'"
:class="['text-left px-4 py-3 rounded-lg text-sm font-medium transition-colors', activeTab === 'profile' ? 'bg-white/10 text-white' : 'text-zinc-400 hover:bg-white/5 hover:text-zinc-300']"
>
Product Profile
</button>
<button
@click="activeTab = 'strategies'"
:class="['text-left px-4 py-3 rounded-lg text-sm font-medium transition-colors', activeTab === 'strategies' ? 'bg-white/10 text-white' : 'text-zinc-400 hover:bg-white/5 hover:text-zinc-300']"
>
Custom Strategies
</button>
<button
@click="activeTab = 'competitors'"
:class="['text-left px-4 py-3 rounded-lg text-sm font-medium transition-colors', activeTab === 'competitors' ? 'bg-white/10 text-white' : 'text-zinc-400 hover:bg-white/5 hover:text-zinc-300']"
>
Competitor Radar
</button>
<div class="h-px bg-white/10 my-2"></div>
<button
@click="activeTab = 'hottweets'"
:class="['text-left px-4 py-3 rounded-lg text-sm font-medium transition-colors flex items-center justify-between', activeTab === 'hottweets' ? 'bg-orange-500/20 text-orange-400 border border-orange-500/30' : 'text-zinc-400 hover:bg-white/5 hover:text-zinc-300']"
>
<span>🔥 Opportunities</span>
<span v-if="activeTab !== 'hottweets'" class="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></span>
</button>
</div>
<!-- Main Content Area -->
<div class="flex-1 bg-[#171717] border border-white/10 rounded-2xl p-8 shadow-xl min-h-[500px]">
<div v-show="activeTab === 'profile'" class="animate-in fade-in duration-300">
<h2 class="text-lg font-semibold mb-6 border-b border-white/10 pb-4">Product Profile Configuration</h2>
<Profile :token="token" />
</div>
<div v-show="activeTab === 'strategies'" class="animate-in fade-in duration-300">
<h2 class="text-lg font-semibold mb-6 border-b border-white/10 pb-4">Custom Generation Strategies</h2>
<Strategies :token="token" />
</div>
<div v-show="activeTab === 'competitors'" class="animate-in fade-in duration-300">
<h2 class="text-lg font-semibold mb-6 border-b border-white/10 pb-4">Competitor Monitoring</h2>
<Competitors :token="token" />
</div>
<div v-show="activeTab === 'hottweets'" class="animate-in fade-in duration-300 h-full">
<HotTweets :token="token" />
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,215 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const props = defineProps<{ token: string }>()
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'
const isLoading = ref(true)
const isSaving = ref(false)
const savedMessage = ref('')
const errorMsg = ref('')
// Default LLM Options based on the API docs GET /sys/config/llms (mocking for now as we didn't build that API fully)
const providers = ['openai', 'anthropic', 'gemini', 'deepseek']
const form = ref({
product_name: '',
industry: '',
target_audience: '',
core_features: '',
user_intent: '',
relevance_keywords: '',
default_llm_provider: '',
default_llm_model: ''
})
const fetchProfile = async () => {
try {
const res = await fetch(`${API_BASE}/users/me/product_profiles`, {
headers: { 'Authorization': `Bearer ${props.token}` }
})
const data = await res.json()
if (res.ok && data.code === 200 && data.data) {
const p = data.data
form.value = {
product_name: p.product_name || '',
industry: p.industry || '',
target_audience: p.target_audience || '',
core_features: p.core_features || '',
user_intent: p.user_intent || '',
relevance_keywords: p.relevance_keywords || '',
default_llm_provider: p.default_llm_provider || '',
default_llm_model: p.default_llm_model || ''
}
// Sync relevance keywords to local storage for Content Script
chrome.storage.local.set({ relevance_keywords: form.value.relevance_keywords })
}
} catch (err: any) {
console.error('Failed to load profile:', err)
} finally {
isLoading.value = false
}
}
const saveProfile = async () => {
isSaving.value = true
savedMessage.value = ''
errorMsg.value = ''
try {
const res = await fetch(`${API_BASE}/users/me/product_profiles`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${props.token}`
},
body: JSON.stringify(form.value)
})
const data = await res.json()
if (!res.ok || data.code !== 200) {
throw new Error(data.message || 'Failed to save profile')
}
// Sync relevance keywords to local storage for Content Script
chrome.storage.local.set({ relevance_keywords: form.value.relevance_keywords })
savedMessage.value = 'Profile saved successfully!'
setTimeout(() => { savedMessage.value = '' }, 3000)
} catch (err: any) {
errorMsg.value = err.message
} finally {
isSaving.value = false
}
}
onMounted(() => {
if (props.token) {
fetchProfile()
}
})
</script>
<template>
<div v-if="isLoading" class="p-10 flex justify-center">
<div class="w-6 h-6 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>
</div>
<form v-else @submit.prevent="saveProfile" class="space-y-6">
<div v-if="errorMsg" class="p-3 bg-red-500/10 border border-red-500/20 text-red-400 text-sm rounded-lg">
{{ errorMsg }}
</div>
<div v-if="savedMessage" class="p-3 bg-green-500/10 border border-green-500/20 text-green-400 text-sm rounded-lg">
{{ savedMessage }}
</div>
<div class="grid grid-cols-2 gap-6">
<div class="space-y-1.5">
<label class="text-xs font-medium text-zinc-300">Product Name</label>
<input
v-model="form.product_name"
type="text"
required
class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 transition-colors"
placeholder="e.g. SwiftBiu"
/>
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium text-zinc-300">Industry / Niche</label>
<input
v-model="form.industry"
type="text"
class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 transition-colors"
placeholder="e.g. AI Tools, Developer Productivity"
/>
</div>
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium text-zinc-300">Target Audience</label>
<input
v-model="form.target_audience"
type="text"
class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 transition-colors"
placeholder="e.g. Indie hackers building on MacOS"
/>
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium text-zinc-300 flex items-center gap-2">
Core Features
<span class="text-[10px] text-zinc-500 font-normal">What does your product do?</span>
</label>
<textarea
v-model="form.core_features"
rows="3"
class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 transition-colors resize-none"
placeholder="e.g. One-click screenshot translation, local OCR, native macOS support..."
></textarea>
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium text-zinc-300 flex items-center gap-2">
User Intent / Pitch Angle
<span class="text-[10px] text-zinc-500 font-normal">How do you usually reply to people?</span>
</label>
<textarea
v-model="form.user_intent"
rows="2"
class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 transition-colors resize-none"
placeholder="e.g. I generally want to help people solve translation problems and mention that SwiftBiu is a fast native solution."
></textarea>
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium text-zinc-300">Relevance Keywords (Comma separated)</label>
<input
v-model="form.relevance_keywords"
type="text"
class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 transition-colors"
placeholder="e.g. translation, OCR, macos app, bob alternative"
/>
</div>
<div class="pt-4 border-t border-white/10 pb-2">
<h3 class="text-sm font-medium text-zinc-300 mb-4">Advanced: Preferred LLM Override</h3>
<div class="grid grid-cols-2 gap-6">
<div class="space-y-1.5">
<label class="text-xs font-medium text-zinc-400">Provider</label>
<select
v-model="form.default_llm_provider"
class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 appearance-none"
>
<option value="">System Default (Recommended)</option>
<option v-for="p in providers" :key="p" :value="p">{{ p.charAt(0).toUpperCase() + p.slice(1) }}</option>
</select>
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium text-zinc-400">Model Name</label>
<input
v-model="form.default_llm_model"
type="text"
class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 transition-colors"
placeholder="e.g. gpt-4o, claude-3-5-sonnet"
/>
</div>
</div>
</div>
<div class="pt-4 flex justify-end">
<button
type="submit"
:disabled="isSaving"
class="px-6 py-2.5 bg-brand-primary hover:bg-brand-primary/90 text-white rounded-lg text-sm font-medium transition-all shadow-lg flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed"
>
<span v-if="isSaving" class="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin"></span>
{{ isSaving ? 'Saving...' : 'Save Profile' }}
</button>
</div>
</form>
</template>

View File

@@ -0,0 +1,162 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const props = defineProps<{ token: string }>()
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'
const strategies = ref<Array<any>>([])
const isLoading = ref(true)
const isSubmitting = ref(false)
const errorMsg = ref('')
const showForm = ref(false)
const form = ref({
strategy_key: '',
label: '',
description: ''
})
const fetchStrategies = async () => {
isLoading.value = true
try {
const res = await fetch(`${API_BASE}/users/me/strategies`, {
headers: { 'Authorization': `Bearer ${props.token}` }
})
const data = await res.json()
if (res.ok && data.code === 200) {
strategies.value = data.data || []
}
} catch (err) {
console.error(err)
} finally {
isLoading.value = false
}
}
const createStrategy = async () => {
isSubmitting.value = true
errorMsg.value = ''
try {
const res = await fetch(`${API_BASE}/users/me/strategies`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${props.token}`
},
body: JSON.stringify(form.value)
})
const data = await res.json()
if (!res.ok || data.code !== 200) {
throw new Error(data.message || 'Failed to create strategy')
}
// Reset form & reload
form.value = { strategy_key: '', label: '', description: '' }
showForm.value = false
await fetchStrategies()
} catch (err: any) {
errorMsg.value = err.message
} finally {
isSubmitting.value = false
}
}
const deleteStrategy = async (id: number) => {
if (!confirm('Are you sure you want to delete this custom strategy?')) return
try {
const res = await fetch(`${API_BASE}/users/me/strategies/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${props.token}` }
})
if (res.ok) {
strategies.value = strategies.value.filter(s => s.id !== id)
}
} catch (err) {
console.error(err)
}
}
onMounted(() => {
if (props.token) fetchStrategies()
})
</script>
<template>
<div class="space-y-6">
<div v-if="isLoading" class="p-10 flex justify-center">
<div class="w-6 h-6 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>
</div>
<div v-else>
<div class="flex justify-between items-center mb-6">
<p class="text-sm text-zinc-400">These custom prompt angles will appear dynamically in your Twitter sidebar.</p>
<button
v-if="!showForm"
@click="showForm = true"
class="px-4 py-2 bg-brand-primary/20 text-brand-primary border border-brand-primary/30 rounded-lg text-xs font-semibold hover:bg-brand-primary hover:text-white transition-colors flex items-center gap-1"
>
+ Create Custom Strategy
</button>
</div>
<!-- Create Form -->
<form v-if="showForm" @submit.prevent="createStrategy" class="bg-white/5 border border-white/10 rounded-xl p-6 space-y-4 mb-8">
<div class="flex justify-between items-center mb-2">
<h3 class="text-sm font-semibold text-white">New Strategy</h3>
<button type="button" @click="showForm = false" class="text-zinc-500 hover:text-white"></button>
</div>
<div v-if="errorMsg" class="p-3 bg-red-500/10 border border-red-500/20 text-red-400 text-xs rounded-lg">
{{ errorMsg }}
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="text-[11px] font-medium text-zinc-400 uppercase tracking-widest">Internal ID</label>
<input v-model="form.strategy_key" required class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50" placeholder="e.g. startup_pitch" />
</div>
<div class="space-y-1.5">
<label class="text-[11px] font-medium text-zinc-400 uppercase tracking-widest">Display Label (Sidebar)</label>
<input v-model="form.label" required class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50" placeholder="e.g. Startup Pitch" />
</div>
</div>
<div class="space-y-1.5">
<label class="text-[11px] font-medium text-zinc-400 uppercase tracking-widest">Prompt Instructions</label>
<textarea v-model="form.description" required rows="3" class="w-full bg-[#0A0A0A] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-primary/50 resize-none" placeholder="Instruct the AI exactly how to respond using this strategy. e.g. Be concise, act like a VC, ask challenging questions."></textarea>
</div>
<div class="flex justify-end pt-2">
<button type="submit" :disabled="isSubmitting" class="px-5 py-2 bg-brand-primary hover:bg-brand-primary/90 text-white rounded-lg text-sm font-medium transition-all shadow-lg flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed">
<span v-if="isSubmitting" class="w-3 h-3 border-2 border-white/20 border-t-white rounded-full animate-spin"></span>
{{ isSubmitting ? 'Saving...' : 'Add Strategy' }}
</button>
</div>
</form>
<!-- List -->
<div v-if="strategies.length === 0 && !showForm" class="text-center p-12 border border-dashed border-white/10 rounded-xl text-zinc-500">
You haven't created any custom strategies yet.
</div>
<div class="space-y-4">
<div v-for="s in strategies" :key="s.id" class="bg-white/5 border border-white/10 rounded-xl p-5 flex justify-between items-start group hover:border-white/20 transition-colors">
<div class="space-y-2">
<div class="flex items-center gap-3">
<span class="text-sm font-semibold text-white">{{ s.label }}</span>
<span class="text-[10px] font-mono text-brand-primary bg-brand-primary/10 px-2 py-0.5 rounded-full">{{ s.strategy_key }}</span>
</div>
<p class="text-xs text-zinc-400 leading-relaxed max-w-2xl">{{ s.description }}</p>
</div>
<button @click="deleteStrategy(s.id)" class="opacity-0 group-hover:opacity-100 transition-opacity text-red-500 hover:text-red-400 p-2 rounded-lg hover:bg-red-500/10">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import '../style.css' // Reuse Tailwind CSS
import Options from './Options.vue'
createApp(Options).mount('#app')