4 Commits

Author SHA1 Message Date
zs
945157424d feat: 扩展弹框配置重构
All checks were successful
Extension Build & Release / build (push) Successful in 53s
2026-03-03 16:14:39 +08:00
zs
4b7f308528 feat: 扩展弹框配置重构
All checks were successful
Extension Build & Release / build (push) Successful in 1m18s
2026-03-03 16:04:03 +08:00
zs
95ee3fa61d feat: 扩展弹框配置重构
All checks were successful
Extension Build & Release / build (push) Successful in 48s
2026-03-03 15:47:23 +08:00
zs
d82d59cbe4 feat: 扩展弹框配置重构
All checks were successful
Extension Build & Release / build (push) Successful in 1m32s
Backend Deploy (Go + Docker) / deploy (push) Successful in 1m51s
2026-03-03 15:32:33 +08:00
7 changed files with 563 additions and 153 deletions

View File

@@ -42,5 +42,17 @@
}, },
"description": "Toggle InsightReply Sidebar" "description": "Toggle InsightReply Sidebar"
} }
} },
"web_accessible_resources": [
{
"resources": [
"src/assets/*",
"assets/*"
],
"matches": [
"https://twitter.com/*",
"https://x.com/*"
]
}
]
} }

View File

@@ -1,20 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, inject, computed, type Ref } from 'vue'
const props = defineProps<{ // Injected reactive refs from sidebar-mount.ts
tweetData?: { const currentTweetData = inject<Ref<any>>('currentTweetData', ref(null))
id: string; const isVisible = inject<Ref<boolean>>('sidebarVisible', ref(false))
author: string;
content: string; // Computed getter for template convenience
stats: { const tweetData = computed(() => currentTweetData.value)
replies: string;
retweets: string;
likes: string;
}
}
}>()
const isVisible = ref(false) // Start hidden, wait for trigger
const selectedStrategy = ref('Insightful') const selectedStrategy = ref('Insightful')
const generatedReplies = ref<Array<{strategy: string, content: string}>>([]) const generatedReplies = ref<Array<{strategy: string, content: string}>>([])
const isGenerating = ref(false) const isGenerating = ref(false)
@@ -29,30 +22,21 @@ const defaultStrategies = [
const strategies = ref([...defaultStrategies]) const strategies = ref([...defaultStrategies])
onMounted(() => { // Fetch custom strategies on mount
// Listen for toggle messages directly in the component chrome.runtime.sendMessage({ type: 'FETCH_CUSTOM_STRATEGIES' }, (response) => {
chrome.runtime.onMessage.addListener((message) => { if (response && response.success && response.data) {
if (message.type === 'TOGGLE_SIDEBAR') { const customStrategies = response.data.map((s: any) => ({
isVisible.value = !isVisible.value id: s.strategy_key,
} else if (message.type === 'SHOW_INSIGHT') { label: s.label,
isVisible.value = true icon: '<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/>'
} }))
}) strategies.value = [...defaultStrategies, ...customStrategies]
}
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: '<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/>'
}))
strategies.value = [...defaultStrategies, ...customStrategies]
}
})
}) })
const generate = () => { const generate = () => {
if (!props.tweetData) return if (!tweetData.value) return
isGenerating.value = true isGenerating.value = true
generatedReplies.value = [] generatedReplies.value = []
@@ -68,7 +52,7 @@ const generate = () => {
chrome.runtime.sendMessage({ chrome.runtime.sendMessage({
type: 'GENERATE_REPLY', type: 'GENERATE_REPLY',
payload: { payload: {
tweetContent: props.tweetData!.content, tweetContent: tweetData.value!.content,
strategy: selectedStrategy.value, strategy: selectedStrategy.value,
identity: finalIdentity identity: finalIdentity
} }
@@ -93,7 +77,7 @@ const copyToClipboard = async (reply: any) => {
showProfileTip.value = true showProfileTip.value = true
setTimeout(() => { showProfileTip.value = false }, 7000) setTimeout(() => { showProfileTip.value = false }, 7000)
if (!props.tweetData || !props.tweetData.id) return; if (!tweetData.value || !tweetData.value.id) return;
chrome.storage.local.get(['jwt_token'], async (result) => { chrome.storage.local.get(['jwt_token'], async (result) => {
if (result.jwt_token) { if (result.jwt_token) {
@@ -107,7 +91,7 @@ const copyToClipboard = async (reply: any) => {
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
tweet_id: props.tweetData?.id, tweet_id: tweetData.value?.id,
strategy_type: reply.strategy || 'General', strategy_type: reply.strategy || 'General',
content: reply.content, content: reply.content,
language: 'en' language: 'en'
@@ -122,131 +106,96 @@ const copyToClipboard = async (reply: any) => {
</script> </script>
<template> <template>
<transition <div v-if="isVisible" class="ir-panel">
enter-active-class="transition duration-500 ease-out transform"
enter-from-class="translate-x-full opacity-0 scale-95" <!-- Header -->
enter-to-class="translate-x-0 opacity-100 scale-100" <div class="ir-header">
leave-active-class="transition duration-400 ease-in transform" <div class="ir-brand">
leave-from-class="translate-x-0 opacity-100 scale-100" <div class="ir-logo">
leave-to-class="translate-x-full opacity-0 scale-95" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/></svg>
> </div>
<div v-if="isVisible" class="fixed right-6 top-1/2 -translate-y-1/2 w-[380px] max-h-[90vh] flex flex-col glass-card rounded-[24px] shadow-[0_32px_64px_rgba(0,0,0,0.5)] text-[#f8fafc] font-sans z-[2147483647] overflow-hidden border border-white/5 selection:bg-rose-500/30"> <div>
<div class="ir-brand-name">InsightReply</div>
<!-- Premium Header --> <div class="ir-brand-sub">Spatial Copilot</div>
<div class="px-6 py-5 flex justify-between items-center bg-white/[0.02] border-b border-white/5">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-rose-500/10 border border-rose-500/20">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="text-rose-500"><path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/></svg>
</div>
<div>
<span class="text-sm font-bold tracking-tight block">InsightReply</span>
<span class="text-[9px] text-zinc-500 uppercase tracking-[0.2em] font-bold">Spatial Copilot</span>
</div>
</div> </div>
<button @click="isVisible = false" class="p-2 rounded-full hover:bg-white/10 transition-apple group">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-zinc-500 group-hover:text-white transition-apple"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div> </div>
<button class="ir-close-btn" @click="isVisible = false">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<!-- Content --> <!-- Content -->
<div class="p-6 flex-1 overflow-y-auto space-y-8 custom-scrollbar"> <div class="ir-content">
<!-- Context Module --> <!-- Tweet Context -->
<div v-if="tweetData" class="space-y-3"> <div v-if="tweetData">
<h3 class="text-[10px] font-bold text-zinc-500 uppercase tracking-widest flex items-center gap-2 px-1"> <div class="ir-label">
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" class="text-rose-500"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
Current Tweet Current Tweet
</h3> </div>
<div class="bg-black/20 rounded-2xl p-4 border border-white/[0.03] relative group"> <div class="ir-tweet-card">
<div class="text-[13px] text-zinc-400 leading-relaxed font-medium italic"> <div class="ir-tweet-text">"{{ tweetData.content }}"</div>
"{{ tweetData.content }}"
</div>
</div> </div>
</div> </div>
<!-- Strategy Grid --> <!-- Strategy Grid -->
<div class="space-y-4"> <div>
<h3 class="text-[10px] font-bold text-zinc-500 uppercase tracking-widest flex items-center gap-2 px-1"> <div class="ir-label">
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" class="text-rose-500"><path d="m16 4 4 4-4 4"/><path d="M20 8H4v12"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="m16 4 4 4-4 4"/><path d="M20 8H4v12"/></svg>
Generation Strategy Generation Strategy
</h3> </div>
<div class="grid grid-cols-1 gap-2.5"> <div class="ir-strategy-grid">
<button <button
v-for="s in strategies" v-for="s in strategies"
:key="s.id" :key="s.id"
@click="selectedStrategy = s.id" @click="selectedStrategy = s.id"
:class="[ :class="['ir-strategy-btn', selectedStrategy === s.id ? 'active' : '']"
'flex items-center gap-4 p-4 rounded-2xl border transition-apple group text-sm font-semibold active:scale-[0.98]',
selectedStrategy === s.id
? 'bg-rose-500/10 border-rose-500/30 text-rose-50 text-white shadow-[0_8px_16px_rgba(136,19,55,0.15)]'
: 'bg-white/[0.02] border-white/5 hover:bg-white/[0.05] text-zinc-400'
]"
> >
<div :class="['p-2 rounded-lg transition-apple', selectedStrategy === s.id ? 'bg-rose-500/20 text-rose-400' : 'bg-black/20 text-zinc-500 group-hover:text-zinc-300']"> <div class="ir-strategy-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="s.icon"></svg> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="s.icon"></svg>
</div> </div>
<span class="flex-1 text-left tracking-tight">{{ s.label }}</span> <span>{{ s.label }}</span>
<div v-if="selectedStrategy === s.id" class="w-2 h-2 rounded-full bg-rose-500 shadow-[0_0_8px_#f43f5e]"></div> <div v-if="selectedStrategy === s.id" class="ir-strategy-dot"></div>
</button> </button>
</div> </div>
</div> </div>
<!-- Result Module --> <!-- Reply Results -->
<div v-if="generatedReplies.length > 0" class="space-y-4 animate-in fade-in slide-in-from-bottom-4 duration-700"> <div v-if="generatedReplies.length > 0" class="ir-fade-in">
<h3 class="text-[10px] font-bold text-zinc-500 uppercase tracking-widest px-1">AI Suggestions</h3> <div class="ir-label">AI Suggestions</div>
<div v-for="(reply, idx) in generatedReplies" :key="idx" class="bg-white/[0.03] rounded-2xl p-5 border border-white/5 space-y-4 relative group hover:border-rose-500/20 transition-apple"> <div v-for="(reply, idx) in generatedReplies" :key="idx" class="ir-reply-card" style="margin-bottom: 12px;">
<div class="flex justify-between items-center"> <div class="ir-reply-header">
<span class="text-[9px] font-bold text-rose-400 bg-rose-400/10 px-2.5 py-1 rounded-full uppercase tracking-widest"> <span class="ir-reply-tag">{{ reply.strategy || 'Insight' }}</span>
{{ reply.strategy || 'Insight' }} <button class="ir-copy-btn" @click="copyToClipboard(reply)">
</span>
<button @click="copyToClipboard(reply)" class="opacity-0 group-hover:opacity-100 transition-apple text-[10px] font-bold text-zinc-500 hover:text-white flex items-center gap-2 px-2 py-1 rounded-lg hover:bg-white/5">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
COPY COPY
</button> </button>
</div> </div>
<div class="text-[13px] leading-relaxed whitespace-pre-wrap text-zinc-200 font-medium tracking-tight"> <div class="ir-reply-content">{{ reply.content }}</div>
{{ reply.content }}
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Sticky Action Footer --> <!-- Footer -->
<div class="p-6 bg-white/[0.02] border-t border-white/5 space-y-4 shadow-[0_-12px_24px_rgba(0,0,0,0.2)]"> <div class="ir-footer">
<transition enter-active-class="transition duration-500 transform ease-out" enter-from-class="opacity-0 translate-y-4" enter-to-class="opacity-100 translate-y-0" leave-active-class="transition duration-300 transform ease-in" leave-from-class="opacity-100 translate-y-0" leave-to-class="opacity-0 translate-y-4"> <div v-if="showProfileTip" class="ir-tip">
<div v-if="showProfileTip" class="bg-rose-500/10 border border-rose-500/20 text-rose-400 text-[11px] p-4 rounded-2xl flex gap-3 items-start relative font-medium leading-relaxed"> <span></span>
<span class="text-base leading-none"></span> <p><strong>Optimize Conversion:</strong> Make sure your X Bio prominently features your product link!</p>
<p><strong>Optimize Conversion:</strong> Make sure your X Bio prominently features your product link!</p> <button class="ir-tip-close" @click="showProfileTip = false"></button>
<button @click="showProfileTip = false" class="absolute top-3 right-3 opacity-50 hover:opacity-100"></button> </div>
</div>
</transition>
<button <button
@click="generate" @click="generate"
:disabled="isGenerating" :disabled="isGenerating"
class="w-full py-4 bg-gradient-to-br from-rose-600 to-rose-500 hover:from-rose-500 hover:to-rose-400 disabled:from-zinc-900 disabled:to-zinc-900 disabled:text-zinc-600 disabled:cursor-not-allowed text-white rounded-[20px] text-[15px] font-bold transition-all shadow-[0_12px_24px_rgba(136,19,55,0.25)] flex items-center justify-center gap-3 active:scale-[0.97]" class="ir-generate-btn"
> >
<div v-if="isGenerating" class="w-5 h-5 border-2 border-white/20 border-t-white rounded-full animate-spin"></div> <div v-if="isGenerating" class="ir-spinner"></div>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="text-rose-100"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg> <svg v-else xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
{{ isGenerating ? 'AI Thinking...' : 'Generate Pro Reply' }} {{ isGenerating ? 'AI Thinking...' : 'Generate Pro Reply' }}
</button> </button>
</div> </div>
</div> </div>
</transition>
</template> </template>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.05);
border-radius: 10px;
}
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
}
</style>

View File

@@ -1,15 +1,423 @@
import { createApp } from 'vue' import { createApp, ref } from 'vue'
import Sidebar from './Sidebar.vue' import Sidebar from './Sidebar.vue'
import '../assets/tailwind.css' // We might need to handle this specially for Shadow DOM
const MOUNT_ID = 'insight-reply-sidebar-root' const MOUNT_ID = 'insight-reply-sidebar-root'
function initSidebar(tweetData?: any) { // Shared reactive tweet data that the Sidebar component will receive
const currentTweetData = ref<any>(null)
const sidebarVisible = ref(false)
let isMounted = false
let isMounting = false
// All CSS is embedded here so it works inside Shadow DOM
// No dependency on Tailwind or external stylesheets
const SIDEBAR_CSS = `
/* ===== Reset & Base ===== */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ===== Sidebar Panel ===== */
.ir-panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 400px;
display: flex;
flex-direction: column;
background: #09090b;
border-left: 1px solid rgba(255,255,255,0.08);
box-shadow: -12px 0 40px rgba(0,0,0,0.6);
color: #f8fafc;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
z-index: 2147483647;
overflow: hidden;
pointer-events: auto;
animation: ir-slide-in 0.4s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
}
.ir-panel.ir-closing {
animation: ir-slide-out 0.35s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
}
@keyframes ir-slide-in {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
@keyframes ir-slide-out {
from { transform: translateX(0); }
to { transform: translateX(100%); }
}
/* ===== Header ===== */
.ir-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
background: rgba(255,255,255,0.02);
border-bottom: 1px solid rgba(255,255,255,0.06);
flex-shrink: 0;
}
.ir-brand {
display: flex;
align-items: center;
gap: 12px;
}
.ir-logo {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: linear-gradient(135deg, #e11d48, #be123c);
box-shadow: 0 4px 12px rgba(225,29,72,0.25);
}
.ir-logo svg {
color: white;
}
.ir-brand-name {
font-size: 15px;
font-weight: 800;
letter-spacing: -0.02em;
color: #fff;
}
.ir-brand-sub {
font-size: 9px;
color: #71717a;
text-transform: uppercase;
letter-spacing: 0.2em;
font-weight: 700;
}
.ir-close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 50%;
border: 1px solid rgba(255,255,255,0.06);
background: rgba(255,255,255,0.04);
cursor: pointer;
transition: all 0.2s ease;
color: #a1a1aa;
}
.ir-close-btn:hover {
background: rgba(225,29,72,0.15);
color: #fb7185;
border-color: rgba(225,29,72,0.3);
}
/* ===== Scrollable Content ===== */
.ir-content {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 28px;
}
.ir-content::-webkit-scrollbar {
width: 4px;
}
.ir-content::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.06);
border-radius: 10px;
}
.ir-content:hover::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.12);
}
/* ===== Section Labels ===== */
.ir-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 10px;
font-weight: 700;
color: #71717a;
text-transform: uppercase;
letter-spacing: 0.12em;
padding: 0 4px;
margin-bottom: 12px;
}
.ir-label svg {
color: #e11d48;
}
/* ===== Tweet Context ===== */
.ir-tweet-card {
background: rgba(0,0,0,0.3);
border-radius: 16px;
padding: 16px;
border: 1px solid rgba(255,255,255,0.04);
}
.ir-tweet-text {
font-size: 13px;
color: #a1a1aa;
line-height: 1.6;
font-weight: 500;
font-style: italic;
}
/* ===== Strategy Buttons ===== */
.ir-strategy-grid {
display: flex;
flex-direction: column;
gap: 8px;
}
.ir-strategy-btn {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
border-radius: 14px;
border: 1px solid rgba(255,255,255,0.06);
background: rgba(255,255,255,0.02);
cursor: pointer;
transition: all 0.2s ease;
font-size: 14px;
font-weight: 600;
color: #a1a1aa;
font-family: inherit;
text-align: left;
width: 100%;
}
.ir-strategy-btn:hover {
background: rgba(255,255,255,0.05);
border-color: rgba(255,255,255,0.1);
}
.ir-strategy-btn.active {
background: rgba(225,29,72,0.08);
border-color: rgba(225,29,72,0.25);
color: #fff;
}
.ir-strategy-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: rgba(0,0,0,0.3);
color: #71717a;
flex-shrink: 0;
transition: all 0.2s ease;
}
.ir-strategy-btn.active .ir-strategy-icon {
background: rgba(225,29,72,0.15);
color: #fb7185;
}
.ir-strategy-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #e11d48;
box-shadow: 0 0 8px #f43f5e;
margin-left: auto;
flex-shrink: 0;
}
/* ===== Reply Results ===== */
.ir-reply-card {
background: rgba(255,255,255,0.03);
border-radius: 16px;
padding: 20px;
border: 1px solid rgba(255,255,255,0.05);
transition: border-color 0.2s ease;
}
.ir-reply-card:hover {
border-color: rgba(225,29,72,0.2);
}
.ir-reply-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.ir-reply-tag {
font-size: 9px;
font-weight: 700;
color: #fb7185;
background: rgba(251,113,133,0.1);
padding: 4px 10px;
border-radius: 100px;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.ir-copy-btn {
opacity: 0;
font-size: 10px;
font-weight: 700;
color: #71717a;
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 6px;
transition: all 0.2s ease;
font-family: inherit;
}
.ir-reply-card:hover .ir-copy-btn {
opacity: 1;
}
.ir-copy-btn:hover {
color: #fff;
background: rgba(255,255,255,0.05);
}
.ir-reply-content {
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
color: #d4d4d8;
font-weight: 500;
letter-spacing: -0.01em;
}
/* ===== Footer ===== */
.ir-footer {
padding: 24px;
background: rgba(255,255,255,0.02);
border-top: 1px solid rgba(255,255,255,0.06);
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.ir-tip {
display: flex;
gap: 10px;
align-items: flex-start;
background: rgba(225,29,72,0.06);
border: 1px solid rgba(225,29,72,0.15);
border-radius: 14px;
padding: 14px;
font-size: 11px;
color: #fb7185;
font-weight: 500;
line-height: 1.5;
position: relative;
}
.ir-tip-close {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
color: #fb7185;
opacity: 0.5;
cursor: pointer;
font-size: 12px;
font-family: inherit;
}
.ir-tip-close:hover {
opacity: 1;
}
.ir-generate-btn {
width: 100%;
padding: 16px;
border: none;
border-radius: 18px;
font-size: 15px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #e11d48, #be123c);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
transition: all 0.2s ease;
box-shadow: 0 8px 20px rgba(225,29,72,0.25);
font-family: inherit;
}
.ir-generate-btn:hover {
background: linear-gradient(135deg, #f43f5e, #e11d48);
box-shadow: 0 12px 28px rgba(225,29,72,0.35);
transform: translateY(-1px);
}
.ir-generate-btn:active {
transform: scale(0.97);
}
.ir-generate-btn:disabled {
background: #18181b;
color: #52525b;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.ir-spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255,255,255,0.2);
border-top-color: white;
border-radius: 50%;
animation: ir-spin 0.8s linear infinite;
}
@keyframes ir-spin {
to { transform: rotate(360deg); }
}
/* ===== Fade-In for Results ===== */
.ir-fade-in {
animation: ir-fadein 0.5s ease forwards;
}
@keyframes ir-fadein {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
`
async function initSidebar() {
if (isMounted || isMounting) return
let host = document.getElementById(MOUNT_ID) let host = document.getElementById(MOUNT_ID)
if (host) return
if (host) return; isMounting = true
// 1. Create Host Element // 1. Create Host Element - positioned for right side only
host = document.createElement('div') host = document.createElement('div')
host.id = MOUNT_ID host.id = MOUNT_ID
host.style.cssText = ` host.style.cssText = `
@@ -17,7 +425,7 @@ function initSidebar(tweetData?: any) {
top: 0; top: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
left: 0; width: 400px;
z-index: 2147483647; z-index: 2147483647;
pointer-events: none; pointer-events: none;
` `
@@ -26,7 +434,12 @@ function initSidebar(tweetData?: any) {
// 2. Create Shadow Root // 2. Create Shadow Root
const shadowRoot = host.attachShadow({ mode: 'open' }) const shadowRoot = host.attachShadow({ mode: 'open' })
// 3. Create Container // 3. Inject self-contained CSS directly (no external files needed)
const styleEl = document.createElement('style')
styleEl.textContent = SIDEBAR_CSS
shadowRoot.appendChild(styleEl)
// 4. Create Container
const container = document.createElement('div') const container = document.createElement('div')
container.id = 'app' container.id = 'app'
container.style.cssText = ` container.style.cssText = `
@@ -35,30 +448,47 @@ function initSidebar(tweetData?: any) {
top: 0; top: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
left: 0; width: 100%;
` `
shadowRoot.appendChild(container) shadowRoot.appendChild(container)
// 4. Inject Styles // 5. Create Vue App with reactive provide
const injectStyles = () => { const app = createApp(Sidebar)
const styles = document.querySelectorAll('style, link[rel="stylesheet"]') app.provide('currentTweetData', currentTweetData)
styles.forEach(style => { app.provide('sidebarVisible', sidebarVisible)
shadowRoot.appendChild(style.cloneNode(true))
})
}
injectStyles()
// 5. Create Vue App
const app = createApp(Sidebar, { tweetData })
app.mount(container) app.mount(container)
isMounted = true
isMounting = false
console.log('[InsightReply] Sidebar mounted successfully')
} }
// Ensure it mounts on load if needed, but primarily triggered by messages async function showSidebar(tweetData?: any) {
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { await initSidebar()
if (message.type === 'SHOW_INSIGHT' || message.type === 'TOGGLE_SIDEBAR') {
initSidebar(message.payload); if (tweetData) {
// The component itself listens for TOGGLE_SIDEBAR to show/hide currentTweetData.value = tweetData
sendResponse({ received: true }); console.log('[InsightReply] Tweet data updated:', tweetData.content?.substring(0, 60))
} }
return true;
}); sidebarVisible.value = true
}
async function toggleSidebar() {
await initSidebar()
sidebarVisible.value = !sidebarVisible.value
}
// Listen for messages from background script
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
console.log('[InsightReply SidebarMount] Received message:', message.type)
if (message.type === 'SHOW_INSIGHT') {
showSidebar(message.payload)
sendResponse({ received: true })
} else if (message.type === 'TOGGLE_SIDEBAR') {
toggleSidebar()
sendResponse({ received: true })
}
return true
})

View File

@@ -39,6 +39,11 @@ const loadHotTweets = async () => {
} }
}) })
if (response.status === 429) {
error.value = '请求频率达到上限请稍后再试。Free 用户每日限额 10 次 API 调用。'
return
}
if (!response.ok) { if (!response.ok) {
throw new Error(`Opportunity discovery failed (${response.status})`) throw new Error(`Opportunity discovery failed (${response.status})`)
} }
@@ -46,7 +51,7 @@ const loadHotTweets = async () => {
const data = await response.json() const data = await response.json()
tweets.value = data || [] tweets.value = data || []
} catch (err: any) { } catch (err: any) {
error.value = err.message || 'Quantum network sync error.' error.value = err.message || 'Network connection error.'
} finally { } finally {
loading.value = false loading.value = false
} }

View File

@@ -2,6 +2,7 @@ package handler
import ( import (
"encoding/json" "encoding/json"
"log"
"net/http" "net/http"
"strings" "strings"
@@ -53,6 +54,7 @@ func (h *AIHandler) Generate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
userID := ctx.Value("userID").(string) userID := ctx.Value("userID").(string)
log.Printf("[AIHandler] Generate request from userID=%s strategy=%q provider=%q model=%q", userID, body.Strategy, body.Provider, body.Model)
// Fetch Product Profile Context // Fetch Product Profile Context
var productContext string var productContext string
@@ -79,9 +81,11 @@ func (h *AIHandler) Generate(w http.ResponseWriter, r *http.Request) {
replyString, err := h.svc.GenerateReply(ctx, body.TweetContent, productContext, body.Identity, body.Provider, body.Model) replyString, err := h.svc.GenerateReply(ctx, body.TweetContent, productContext, body.Identity, body.Provider, body.Model)
if err != nil { if err != nil {
log.Printf("[AIHandler] ERROR GenerateReply for userID=%s: %v", userID, err)
SendError(w, http.StatusBadGateway, 5002, "Failed to generate AI reply: "+err.Error()) SendError(w, http.StatusBadGateway, 5002, "Failed to generate AI reply: "+err.Error())
return return
} }
log.Printf("[AIHandler] GenerateReply success for userID=%s, reply length=%d", userID, len(replyString))
// Clean up potential markdown wrappers from LLM output // Clean up potential markdown wrappers from LLM output
cleanReply := strings.TrimSpace(replyString) cleanReply := strings.TrimSpace(replyString)

View File

@@ -2,6 +2,7 @@ package handler
import ( import (
"encoding/json" "encoding/json"
"log"
"net/http" "net/http"
"github.com/zs/InsightReply/internal/repository" "github.com/zs/InsightReply/internal/repository"
@@ -17,15 +18,18 @@ func NewTweetHandler(repo *repository.TweetRepository) *TweetHandler {
// GetHotTweets returns the top heating tweets spanning across all tracking targets // GetHotTweets returns the top heating tweets spanning across all tracking targets
func (h *TweetHandler) GetHotTweets(w http.ResponseWriter, r *http.Request) { func (h *TweetHandler) GetHotTweets(w http.ResponseWriter, r *http.Request) {
// Standardize to take the top 50 hottest tweets that haven't been manually marked as processed log.Printf("[TweetHandler] GetHotTweets called from %s", r.RemoteAddr)
tweets, err := h.repo.GetTopHeatingTweets(50) tweets, err := h.repo.GetTopHeatingTweets(50)
if err != nil { if err != nil {
log.Printf("[TweetHandler] ERROR GetTopHeatingTweets: %v", err)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]interface{}{"error": "failed to retrieve hot tweets"}) json.NewEncoder(w).Encode(map[string]interface{}{"error": "failed to retrieve hot tweets"})
return return
} }
log.Printf("[TweetHandler] GetHotTweets returning %d tweets", len(tweets))
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tweets) json.NewEncoder(w).Encode(tweets)
} }
@@ -34,15 +38,18 @@ func (h *TweetHandler) GetHotTweets(w http.ResponseWriter, r *http.Request) {
func (h *TweetHandler) GetSearchTweets(w http.ResponseWriter, r *http.Request) { func (h *TweetHandler) GetSearchTweets(w http.ResponseWriter, r *http.Request) {
keyword := r.URL.Query().Get("keyword") keyword := r.URL.Query().Get("keyword")
handle := r.URL.Query().Get("handle") handle := r.URL.Query().Get("handle")
log.Printf("[TweetHandler] SearchTweets called: keyword=%q handle=%q", keyword, handle)
tweets, err := h.repo.SearchTweets(keyword, handle, 50) tweets, err := h.repo.SearchTweets(keyword, handle, 50)
if err != nil { if err != nil {
log.Printf("[TweetHandler] ERROR SearchTweets: %v", err)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]interface{}{"error": "failed to search tweets"}) json.NewEncoder(w).Encode(map[string]interface{}{"error": "failed to search tweets"})
return return
} }
log.Printf("[TweetHandler] SearchTweets returning %d tweets", len(tweets))
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tweets) json.NewEncoder(w).Encode(tweets)
} }

View File

@@ -1,6 +1,7 @@
package middleware package middleware
import ( import (
"log"
"net/http" "net/http"
"sync" "sync"
"time" "time"
@@ -49,6 +50,7 @@ func RateLimit(db *gorm.DB) func(http.Handler) http.Handler {
// For now, fallback to generic tight limit for anonymous usage // For now, fallback to generic tight limit for anonymous usage
ipLimiter := getLimiter(r.RemoteAddr, "Free") ipLimiter := getLimiter(r.RemoteAddr, "Free")
if !ipLimiter.Allow() { if !ipLimiter.Allow() {
log.Printf("[RateLimit] 429 for anonymous IP=%s path=%s", r.RemoteAddr, r.URL.Path)
http.Error(w, `{"code":429, "message":"Too Many Requests: Rate limit exceeded"}`, http.StatusTooManyRequests) http.Error(w, `{"code":429, "message":"Too Many Requests: Rate limit exceeded"}`, http.StatusTooManyRequests)
return return
} }
@@ -72,6 +74,7 @@ func RateLimit(db *gorm.DB) func(http.Handler) http.Handler {
limiter := getLimiter(userID, tier) limiter := getLimiter(userID, tier)
if !limiter.Allow() { if !limiter.Allow() {
log.Printf("[RateLimit] 429 for userID=%s tier=%s path=%s", userID, tier, r.URL.Path)
http.Error(w, `{"code":429, "message":"Too Many Requests: Daily quota or rate limit exceeded"}`, http.StatusTooManyRequests) http.Error(w, `{"code":429, "message":"Too Many Requests: Daily quota or rate limit exceeded"}`, http.StatusTooManyRequests)
return return
} }