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

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,44 @@
name: Web Console Deploy (Vue 3 + Vite)
on:
push:
branches:
- main
paths:
- 'web/**'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: 获取代码
uses: actions/checkout@v4
- name: 安装 Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: 安装依赖并打包
run: |
cd web
npm install
npm run build
- name: 准备部署文件夹
run: |
mkdir -p deploy_web
cp -r web/dist/* deploy_web/
- name: 部署静态文件到服务器
uses: up9cloud/action-rsync@master
env:
USER: root
HOST: 144.24.60.0
KEY: ${{secrets.USAARMLOGIN_SSH_KEY}}
ARGS: -avz --delete
SSH_ARGS: "-p 22 -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no"
SOURCE: ./deploy_web/
TARGET: /var/admin/InsightReply/web
VERBOSE: true

112
README.md Normal file
View File

@@ -0,0 +1,112 @@
# InsightReply 🚀
InsightReply 是一款专为独立开发者与数字游民设计的 **AI 驱动 X (Twitter) 自动化营销套件**
它通过高可用爬虫监控社媒热点,利用大语言模型(如 Gemini、OpenAI提取互动风格并自动在浏览器拓展层或管理后台生成极具转化率的爆款回复。
---
## 🗂️ 核心架构
InsightReply 采用现代化解耦的三端架构:
1. **`/server` (后端主脑)**
- 核心语言Golang 1.22 + GORM + Chi Router
- 数据库PostgreSQL 14+
- 功能模块Nitter 高可用爬虫引擎、多模态 AI 接入层、策略派发与遥测统计引擎。
2. **`/web` (SaaS 管理控制台)**
- 核心选型Vue 3 + Vite + Tailwind 玻璃态美学
- 功能模块:数据指标大盘、策略雷达与组合词配置、生成表现记录仪表板。
3. **`/extension` (Chrome 浏览器助手)**
- 核心选型CRXJS + Vue 3
- 功能模块:侵入 X 原生界面,一键触发 AI 分析、克隆高赞评论语言风格。
---
## 🛠️ 本地开发指南 (Local Development)
### 1. 启动 PostgreSQL 数据库
### 2. 配置与启动后端 (Go Server)
```bash
cd server
# 复制环境变量模板并填入真实的 Key (特别是数据库与大模型 Key)
cp .env.example .env
# 获取依赖并运行
go mod tidy
go run cmd/server/main.go
# 默认后端将运行在 http://localhost:8080
```
### 3. 配置与启动 Web 后台控制台 (Vue)
```bash
cd web
npm install
# 确保 web/.env 中的 VITE_API_BASE_URL=http://localhost:8080/api/v1
# (本地环境直连,已配置完成)
npm run dev
# 访问 http://localhost:5173
```
### 4. 配置与编译 Chrome 扩展 (Extension)
```bash
cd extension
npm install
# 确保 extension/.env 中的 VITE_API_BASE_URL=http://localhost:8080/api/v1
npm run dev
```
打开 Chrome -> `chrome://extensions/` -> 开启「开发者模式」 -> 点击「加载已解压的扩展程序」 -> 选择 `extension/dist` 目录进入调试。
---
## 🚀 生产部署指南 (Production Deployment)
本系统采用原生 **Docker Compose 后端 + 本地 Caddy 高性能网关反代 + 自动化 Gitea Actions CI/CD**
### 0. 准备与环境变量隔离
1. **对于 `/web`**:生产环境已自动配置为相对路径:`VITE_API_BASE_URL=/api/v1`。无需变动。
2. **对于 `/extension`**:修改 `extension/.env.production`,写入**你服务器真实的 Caddy HTTPS 域名绝对路径**
```env
# extension/.env.production
VITE_API_BASE_URL=https://insight.buildapp.eu.org/api/v1
```
### 1. 服务器环境准备
服务器需提前安装 `docker`, `docker-compose`, 与 `caddy`
### 2. 部署后端 Docker (包含日志持久化映射)
在服务器的 `/var/admin/InsightReply/server` 目录下放置你真实的 `.env` 配置文件。随后执行启动指令,后端将在 Docker 隔离网内暴露出 `8009` 端口供本地宿主机 Caddy 本地调用。
```bash
cd /var/admin/InsightReply/server
docker-compose up -d --build
```
> 此时应用的全局运行日志将自动映射并写入由于宿主机的 `/root/logs/InsightReply.log` 内以供探查。
### 3. Caddyfile 反向代理与 SSL 自动签发
在宿主机中编辑 `/etc/caddy/Caddyfile`,配置以下动静分离策略:
```caddyfile
# 将此处替换为您的真实商用域名
insight.buildapp.eu.org {
# [后端路由] 代理所有 API 请求到 Go Docker 的透传端口
handle /api/* {
reverse_proxy insight.buildapp.eu.org
}
# [前端路由] 静态托管来自 CI/CD 发行的 Vue 3 制品,并支持 SPA 默认寻址
handle {
root * /var/admin/InsightReply/web
try_files {path} {path}/ /index.html
file_server
}
}
```
配置完成后使用 `caddy reload` 重载规则并等待几十秒完成 Let's Encrypt 等机构的安全加密证书签发。
### 4. 配置自动化 CI/CD
所有的部署流水线配置均存放在 `.gitea/workflows` 目录下,支持推送 `main` 分支时利用 Rsync 执行秒级增量部署及无缝重启。

506
docs/API.md Normal file
View File

@@ -0,0 +1,506 @@
# InsightReply API 接口文档
> **Base URL**`https://<YOUR_SERVER>/api/v1`
> **认证方式**Bearer Token (JWT)
> **内容类型**`application/json`
---
## 通用响应格式
### 成功响应
```json
{
"code": 200,
"message": "success",
"data": { ... }
}
```
### 错误响应
```json
{
"code": 4001,
"message": "具体的错误描述",
"data": null
}
```
### HTTP Status Code 规范
| HTTP Status | 使用场景 |
|-------------|---------|
| `200 OK` | 正常响应 |
| `201 Created` | 资源创建成功 |
| `400 Bad Request` | 请求参数错误 (对应 code 4xxx) |
| `401 Unauthorized` | 未认证或 Token 过期 |
| `403 Forbidden` | 权限不足(如 Free 用户访问 Pro 功能) |
| `429 Too Many Requests` | 触发 Rate Limit |
| `500 Internal Server Error` | 服务端异常 (对应 code 5xxx) |
| `502 Bad Gateway` | 上游服务(如 LLM不可用 |
### 业务错误码速查表
| Code | 含义 |
|------|------|
| `4001` | 请求体解析失败 / 参数缺失 |
| `4002` | 必填字段为空 |
| `4003` | 权限不足Tier 限制) |
| `4004` | 资源不存在 |
| `4005` | Rate Limit 超限 |
| `4010` | 未认证 / Token 无效 |
| `4011` | Token 已过期 |
| `5001` | 数据库读写错误 |
| `5002` | LLM 调用失败 |
| `5003` | 外部服务超时 |
---
## 认证相关
### POST `/auth/register` — 用户注册
**请求体**
```json
{
"email": "user@example.com",
"password": "securePassword123"
}
```
**成功响应**
```json
{
"code": 201,
"message": "success",
"data": {
"user_id": "uuid-xxxx",
"access_token": "eyJhbG...",
"refresh_token": "eyJhbG..."
}
}
```
---
### POST `/auth/login` — 用户登录
**请求体**
```json
{
"email": "user@example.com",
"password": "securePassword123"
}
```
**成功响应**
```json
{
"code": 200,
"message": "success",
"data": {
"access_token": "eyJhbG...",
"refresh_token": "eyJhbG...",
"user": {
"id": "uuid-xxxx",
"email": "user@example.com",
"subscription_tier": "Free",
"identity_label": "独立开发者"
}
}
}
```
---
### POST `/auth/refresh` — 刷新 Token
**请求体**
```json
{
"refresh_token": "eyJhbG..."
}
```
---
## AI 评论生成 (需认证)
### POST `/ai/generate` — 生成评论
**请求头**
```
Authorization: Bearer <access_token>
```
**请求体**
```json
{
"tweet_content": "AI agents are going to replace 80% of SaaS tools in the next 2 years.",
"strategy": "all",
"identity": "AI 创始人",
"language": "en",
"max_length": 280
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `tweet_content` | string | ✅ | 目标推文原文 |
| `strategy` | string | ❌ | `cognitive_upgrade` / `contrarian` / `data_supplement` / `empathy` / `founder_exp` / `all`(默认 `all` |
| `identity` | string | ❌ | 用户身份标签,默认使用用户 Profile 中的设置 |
| `language` | string | ❌ | 输出语言,`en` / `zh` / `auto`(默认 `auto`,跟随原推文语言) |
| `max_length` | int | ❌ | 最大字符数,默认 280 |
**成功响应**
```json
{
"code": 200,
"message": "success",
"data": {
"tweet_id": "optional-if-known",
"replies": [
{
"strategy": "cognitive_upgrade",
"label": "认知升级型",
"candidates": [
"Most people are thinking about this wrong. The real shift isn't agents replacing SaaS — it's agents making SaaS invisible. The UI layer vanishes, but the data layer matters more than ever.",
"Hot take: agents won't replace SaaS. They'll rebuild it. Every workflow tool becomes an agent orchestrator. The winners know this already."
]
},
{
"strategy": "contrarian",
"label": "反向观点型",
"candidates": ["..."]
}
],
"usage": {
"prompt_tokens": 320,
"completion_tokens": 580,
"total_tokens": 900
}
}
}
```
---
## 用户配置 (需认证)
### GET `/users/me` — 获取当前用户信息
**成功响应**
```json
{
"code": 200,
"message": "success",
"data": {
"id": "uuid-xxxx",
"email": "user@example.com",
"subscription_tier": "Pro",
"identity_label": "AI 创始人",
"daily_usage": { "generate": 5, "limit": -1 },
"created_at": "2026-02-01T00:00:00Z"
}
}
```
### PUT `/users/me/preferences` — 更新用户偏好
**请求体 (可增量更新)**
```json
{
"identity_label": "SaaS Builder",
"language_preference": "zh"
}
```
---
## 监控配置 (需认证)
### POST `/monitors/keywords` — 添加关键词监控
**请求体**
```json
{
"keyword": "AI agent"
}
```
### GET `/monitors/keywords` — 获取关键词列表
### DELETE `/monitors/keywords/{id}` — 删除关键词
### POST `/monitors/accounts` — 添加账号监控
**请求体**
```json
{
"x_handle": "sama"
}
```
### GET `/monitors/accounts` — 获取监控账号列表
### DELETE `/monitors/accounts/{id}` — 删除监控账号
---
## 用户可配置系统 API (需认证)
### GET `/users/me/product_profiles` — 获取用户产品档案
**成功响应**
```json
{
"code": 200,
"message": "success",
"data": {
"product_name": "InsightReply",
"tagline": "AI-powered audience engagement",
"domain": "SaaS",
"key_features": "[\"Multi-LLM\", \"Custom Strategies\"]",
"target_users": "Founders, Indie Hackers",
"product_url": "https://insightreply.example.com",
"competitors": "[\"ReplyGuy\", \"TweetHunter\"]",
"relevance_keywords": "[\"marketing\", \"twitter growth\"]",
"custom_context": "We focus on high-quality, non-spammy replies",
"default_llm_provider": "anthropic",
"default_llm_model": "claude-3-5-haiku-latest"
}
}
```
### PUT `/users/me/product_profiles` — 更新用户产品档案
**请求体**
接受与 GET 响应相同的 JSON 字段结构。
---
### GET `/users/me/strategies` — 获取自定义策略列表
**成功响应**
```json
{
"code": 200,
"message": "success",
"data": [
{
"id": "uuid-xxx",
"strategy_key": "funny",
"label": "幽默调侃型",
"icon": "🤣",
"description": "Make the reader laugh.",
"prompt_template": "Reply to this with a witty joke: {tweet_content}",
"few_shot_examples": "[]",
"sort_order": 1
}
]
}
```
### POST `/users/me/strategies` — 创建自定义策略
**请求体** 接受与 GET 列表中单项内容相同的结构(无需提供 id
### DELETE `/users/me/strategies/{id}` — 删除自定义策略
---
### GET `/monitors/competitors` — 获取竞品监控列表
**成功响应**
```json
{
"code": 200,
"message": "success",
"data": [
{
"id": "uuid-xxx",
"brand_name": "CompetitorA",
"x_handle": "@compa"
}
]
}
```
### POST `/monitors/competitors` — 添加竞品监控
**请求体**
```json
{
"brand_name": "CompetitorA",
"x_handle": "@compa"
}
```
### DELETE `/monitors/competitors/{id}` — 删除竞品监控
> 用户可定义自己正在推广的产品信息,系统会将其注入 Prompt生成与产品领域高度关联的评论。
### GET `/users/me/product` — 获取产品档案
**成功响应**
```json
{
"code": 200,
"message": "success",
"data": {
"product_name": "SwiftBiu",
"tagline": "AI-powered short video creation tool",
"domain": "AI Video Creation",
"key_features": ["视频生成", "多语言配音", "AI 脚本"],
"target_users": "内容创作者, 独立开发者, 出海团队",
"product_url": "https://apps.apple.com/app/swiftbiu",
"competitors": ["CapCut", "Descript", "Opus Clip"],
"relevance_keywords": ["short video", "content creation", "AI dubbing", "video editing"],
"custom_context": "We focus on multi-language video creation for global creators.",
"default_llm_provider": "anthropic",
"default_llm_model": "claude-3-5-haiku-latest"
}
}
```
### PUT `/users/me/product` — 更新产品档案
**请求体**:(所有字段可选,只传需要更新的字段)
```json
{
"product_name": "SwiftBiu",
"tagline": "AI-powered short video creation tool",
"domain": "AI Video Creation",
"key_features": ["视频生成", "多语言配音", "AI 脚本"],
"competitors": ["CapCut", "Descript"],
"relevance_keywords": ["short video", "content creation"],
"custom_context": "任意自定义的上下文信息,会被注入到生成 Prompt 中",
"default_llm_provider": "anthropic",
"default_llm_model": "claude-3-5-haiku-latest" // 支持前端列表选择,或用户手动输入自定义模型(如接入代理时的自有模型)
}
```
---
## 自定义策略 (需认证)
> 除系统内置的 5 种策略外,用户可创建自己的评论策略模板。
### GET `/users/me/strategies` — 获取策略列表(含系统内置 + 用户自定义)
**成功响应**
```json
{
"code": 200,
"message": "success",
"data": {
"builtin": [
{ "strategy_key": "cognitive_upgrade", "label": "认知升级型", "icon": "🧠" },
{ "strategy_key": "contrarian", "label": "反向观点型", "icon": "🔥" }
],
"custom": [
{
"id": "uuid-xxx",
"strategy_key": "builder_story",
"label": "创始人实战型",
"icon": "🚀",
"description": "以自身产品经验为论据,自然关联到我的产品",
"prompt_template": "用 {identity} 的身份,基于 {product_name} 的开发经验,对这条推文写一条有洞察力的评论...",
"few_shot_examples": ["We faced this exact problem building SwiftBiu..."]
}
]
}
}
```
### POST `/users/me/strategies` — 创建自定义策略
**请求体**
```json
{
"strategy_key": "builder_story",
"label": "创始人实战型",
"icon": "🚀",
"description": "以自身产品经验为论据,自然关联到产品",
"prompt_template": "用 {identity} 的身份,基于 {product_name} 的开发经验...",
"few_shot_examples": [
"We faced this exact problem building SwiftBiu. What worked for us was..."
]
}
```
### PUT `/users/me/strategies/{id}` — 更新自定义策略
### DELETE `/users/me/strategies/{id}` — 删除自定义策略
---
## 竞品监控 (需认证)
### POST `/monitors/competitors` — 添加竞品监控
**请求体**
```json
{
"brand_name": "CapCut",
"x_handle": "CapCut"
}
```
### GET `/monitors/competitors` — 获取竞品监控列表
### DELETE `/monitors/competitors/{id}` — 删除竞品监控
---
## 系统 (无需认证)
### GET `/sys/config/llms` — 获取支持的 LLM 模型列表
> 用于前端渲染「重写 AI 引擎」的下拉菜单,由后端统一从环境变量 (`OPENAI_AVAILABLE_MODELS` 等) 下发。
**成功响应**
```json
{
"code": 200,
"message": "success",
"data": {
"providers": [
{
"id": "openai",
"name": "OpenAI (或兼容接口)",
"models": ["gpt-4o", "gpt-4o-mini", "o1-mini"]
},
{
"id": "anthropic",
"name": "Anthropic Claude",
"models": ["claude-3-5-sonnet-latest", "claude-3-5-haiku-latest"]
},
{
"id": "deepseek",
"name": "DeepSeek",
"models": ["deepseek-chat", "deepseek-reasoner"]
},
{
"id": "gemini",
"name": "Google Gemini",
"models": ["gemini-2.5-flash", "gemini-2.5-pro"]
}
]
}
}
```
### GET `/health` — 健康检查
**成功响应**
```json
{
"code": 200,
"message": "ok",
"data": {
"status": "healthy",
"db": "connected",
"version": "1.0.0",
"uptime": "2h30m"
}
}
```

189
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,189 @@
# InsightReply 系统架构 (Architecture Overview)
> 本文档描述 InsightReply 的整体技术架构、各组件职责与数据流。
---
## 一、架构全景图
```
┌─────────────────────────────────────────────────────────────────┐
│ 用户浏览器 (Chrome / Edge) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ X (Twitter) 页面 │ │
│ │ ┌──────────────┐ ┌──────────────────────────────┐ │ │
│ │ │ Content │ │ InsightReply Sidebar │ │ │
│ │ │ Script │───▶│ (Shadow DOM 隔离) │ │ │
│ │ │ - DOM 感知 │ │ - 策略选择 │ │ │
│ │ │ - 按钮注入 │ │ - 评论展示 │ │ │
│ │ │ - 数据提取 │ │ - 一键复制 │ │ │
│ │ └──────────────┘ └──────────┬───────────────────┘ │ │
│ └───────────────────────────────────┼─────────────────────┘ │
│ │ chrome.runtime │
│ ┌───────────────────────────────────┼─────────────────────┐ │
│ │ Background Service Worker │ │
│ │ - 消息中转 │ │
│ │ - JWT Token 管理 │ │
│ │ - API 请求代理 │ │
│ └───────────────────────────────────┼─────────────────────┘ │
└──────────────────────────────────────┼──────────────────────────┘
│ HTTPS
┌──────────────────────────────────────────────────────────────────┐
│ Nginx / Caddy (TLS 终结) │
└──────────────────────────────────────┬───────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Go 后端服务 (Docker Container) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ Handler │─▶│ Service │─▶│Repository│─▶│ PostgreSQL │ │
│ │ (HTTP) │ │ (BizLogic│ │ (GORM) │ │ (Tailscale) │ │
│ └──────────┘ └────┬─────┘ └──────────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ LLM Gateway │──▶ OpenAI / Claude / DeepSeek │
│ │ (Timeout + │ │
│ │ Breaker + │ │
│ │ Retry) │ │
│ └──────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Radar Scheduler (定时任务) │ │
│ │ - Asynq (Redis Queue) │ │
│ │ - Nitter Scraper │ │
│ │ - 热度计算引擎 │ │
│ └──────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
---
## 二、分层架构 (Clean Architecture)
```
cmd/server/main.go ← 入口:配置加载、依赖注入、路由注册
├── internal/handler/ ← 表现层HTTP 请求解析 → 调用 Service → 返回 JSON
│ ├── ai_handler.go
│ ├── user_handler.go
│ └── common.go ← 统一 Response 工具函数
├── internal/service/ ← 业务层核心逻辑LLM 调用、热度计算、权限校验)
│ ├── ai_service.go
│ └── user_service.go
├── internal/repository/ ← 数据层:封装所有 SQL / GORM 操作
│ └── user_repository.go
├── internal/model/ ← 数据模型Go struct 定义
│ └── user.go
├── config/ ← 配置读取(环境变量 → 结构体)
├── pkg/ ← 跨模块共享工具包
└── prompts/ ← Prompt 模板文件 (规划中)
```
**调用链规则**`Handler → Service → Repository`,严禁跨层调用。
---
## 三、数据流
### 3.1 核心链路:用户生成评论
```mermaid
sequenceDiagram
participant U as 用户 (X 页面)
participant CS as Content Script
participant BG as Background SW
participant API as Go 后端
participant LLM as OpenAI
U->>CS: 点击 Insight 按钮
CS->>CS: 提取推文 DOM 数据
CS->>BG: chrome.runtime.sendMessage
BG->>API: POST /ai/generate (带 JWT)
API->>API: 验证 Token + Rate Limit
API->>LLM: ChatCompletion (含 Prompt)
LLM-->>API: 生成结果
API-->>BG: JSON 响应 (10 条候选)
BG-->>CS: sendResponse
CS->>U: Sidebar 展示评论
U->>U: 选择 → 一键复制 → 粘贴发布
```
### 3.2 雷达链路:后台监控推文 *(规划中)*
```mermaid
sequenceDiagram
participant SCH as Scheduler
participant Q as Asynq (Redis)
participant SC as Scraper Worker
participant NIT as Nitter 实例
participant DB as PostgreSQL
SCH->>Q: 定时投放抓取任务
Q->>SC: 消费任务
SC->>SC: Jitter 延迟 (1-5s)
SC->>NIT: HTTP GET (指纹轮换)
alt 正常响应
NIT-->>SC: HTML
SC->>SC: goquery 解析
SC->>DB: Batch UPSERT tweets
SC->>DB: 计算 heat_score
else 429/503
SC->>SC: 触发 Circuit Breaker
SC->>Q: 指数退避后重新入队
end
```
---
## 四、部署架构
```
Internet
┌────────┴────────┐
│ Nginx / Caddy │ ← TLS + 反代 → :8080
└────────┬────────┘
┌──────────────────┼──────────────────┐
│ Oracle ARM VPS │
│ 144.24.60.0 │
│ │
│ ┌────────────────────────────┐ │
│ │ Docker │ │
│ │ ├── insight-reply-server │ │
│ │ └── (未来: redis) │ │
│ └────────────────────────────┘ │
│ │
└──────────────────┼──────────────────┘
│ Tailscale VPN
┌────────────────┐
│ PostgreSQL │
│ 100.64.0.5 │
└────────────────┘
```
---
## 五、技术栈一览
| 层级 | 技术 | 版本 |
|------|------|------|
| **前端插件** | Vue 3 + Composition API | 3.5 |
| **样式** | Tailwind CSS | v4 |
| **打包** | Vite + CRXJS | 7.x |
| **后端** | Go (chi router) | 1.24 |
| **ORM** | GORM + pgx | 1.31 |
| **数据库** | PostgreSQL | 15+ |
| **LLM** | OpenAI (go-openai) | GPT-4o Mini |
| **任务队列** *(规划)* | Asynq (Redis) | - |
| **容器** | Docker + Docker Compose | 24.x |
| **CI/CD** | Gitea Actions | - |
| **VPN** | Tailscale | - |

View File

@@ -14,15 +14,21 @@
**触发条件**:当推送代码到 `main` 分支,且 `server/**` 目录有变更时触发。 **触发条件**:当推送代码到 `main` 分支,且 `server/**` 目录有变更时触发。
**执行流程** **执行流程**
1. **获取代码并安装 Go 1.22 环境** 1. **获取代码并安装 Go 1.24 环境**
2. **交叉编译**:在 Runner 上编译出适用于 Linux ARM64 的可执行文件 `server_bin` 2. **交叉编译**:在 Runner 上编译出适用于 Linux ARM64 的可执行文件 `server_bin`
3. **准备部署包**:将可执行文件、`Dockerfile``docker-compose.yml` 收集到部署文件夹。 3. **准备部署包**:将可执行文件、`Dockerfile``docker-compose.yml` 收集到部署文件夹。
4. **Rsync 同步**:将文件同步到生产服务器 (`144.24.60.0`) `/var/admin/InsightReply/server/` 目录下。 4. **Rsync 同步**:将文件同步到生产服务器的 `/var/admin/InsightReply/server/` 目录下。
5. **平滑重启服务**:通过 SSH 远程执行 `docker-compose up -d --build`,实现不宕机更新。 5. **平滑重启服务**:通过 SSH 远程执行 `docker-compose up -d --build`,实现不宕机更新。
> [!WARNING]
> **Rsync 安全提醒**`--delete` 参数会删除目标目录中不在源端的文件。务必配合 `--exclude` 排除 `.env` 和日志文件:
> ```yaml
> ARGS: -avz --delete --exclude '.env' --exclude '*.log'
> ```
**服务器端准备工作** **服务器端准备工作**
* 必须在目标服务器上安装 Docker 和 Docker Compose。 * 必须在目标服务器上安装 Docker 和 Docker Compose。
* (可选)如果在服务端需要提供环境变量给容器,请`/var/admin/InsightReply/server/` 目录下创建一个 `.env` 文件。 *`/var/admin/InsightReply/server/` 目录下创建 `.env` 文件(参考 `server/.env.example`
--- ---
@@ -34,20 +40,123 @@
1. **获取代码并安装 Node.js 20 环境** 1. **获取代码并安装 Node.js 20 环境**
2. **依赖与构建**:执行 `npm install``npm run build`,编译 Vite/Vue 产物。 2. **依赖与构建**:执行 `npm install``npm run build`,编译 Vite/Vue 产物。
3. **打包产物**:将生成的 `dist` 目录打包为 `insight-reply-extension.zip` 3. **打包产物**:将生成的 `dist` 目录打包为 `insight-reply-extension.zip`
4. **Gitea Artifacts**:将生成的 zip 包上传为当前构建的 Artifacts 4. **Gitea Release**:将生成的 zip 包发布到 Gitea Release
*(后续需要上架 Chrome Web Store 时,可在此流程增加 API 上传步骤。)*
> [!TIP]
> 后续需要上架 Chrome Web Store 时,可在此流程增加 Chrome Web Store API 上传步骤。
--- ---
### 3. 多环境与全局发布 (规划中) ### 3. 版本管理与发布策略
随着产品演进,当需要打 Tag 发版(如发布 `v1.0.0`)时,我们可以添加一个新的工作流:
**触发条件**:当推送符合 `v*` 规则的 Tag 时触发。 | 发布类型 | 触发条件 | 行为 |
**预期行为**:利用 Gitea Release 机制,自动附带当次的前后端编译产物,作为固定资产留存。 |---------|---------|------|
| **持续部署** | `push main` + 路径过滤 | 自动部署到生产/构建扩展 |
| **正式发版** *(规划中)* | 推送 `v*` Tag | 创建 Gitea Release + 编译产物附件 |
**推荐版本号规则**
- 后端:`v1.0.0``v1.1.0``v1.1.1`(语义化版本)
- 扩展:同步跟随后端大版本,`manifest.json` 中的 `version` 对齐
---
## 🔧 Docker 运维规范
### 容器配置要点
```yaml
# docker-compose.yml 推荐配置
services:
insight-reply-server:
build: .
container_name: insight-reply-server
restart: always
ports:
- "8080:8080"
env_file:
- .env
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/api/v1/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
- insight_network
```
> [!IMPORTANT]
> **Healthcheck 说明**:当健康检查连续失败 3 次后Docker 会将容器标记为 `unhealthy`。配合 `restart: always` 可自动恢复。
### Dockerfile 优化建议
```dockerfile
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata wget
ENV TZ=Asia/Shanghai
WORKDIR /app
COPY server_bin .
RUN chmod +x server_bin
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s \
CMD wget --spider -q http://localhost:8080/api/v1/health || exit 1
CMD ["./server_bin"]
```
---
## 🌐 HTTPS 配置 (反向代理)
生产环境必须使用 HTTPS。推荐使用 **Caddy** 自动签发 Let's Encrypt 证书:
```
# Caddyfile 示例
api.insightreply.com {
reverse_proxy localhost:8080
}
```
或使用 Nginx
```nginx
server {
listen 443 ssl;
server_name api.insightreply.com;
ssl_certificate /etc/ssl/certs/cert.pem;
ssl_certificate_key /etc/ssl/private/key.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
---
## 🔑 凭证管理 (Secrets) ## 🔑 凭证管理 (Secrets)
要使自动化部署正常运行,请在此 Gitea 仓库的 `Settings -> Actions -> Secrets` 中配置以下环境变量: 要使自动化部署正常运行,请在此 Gitea 仓库的 `Settings -> Actions -> Secrets` 中配置以下环境变量:
* `USAARMLOGIN_SSH_KEY`: 用于连接到部署目标服务器 (`144.24.60.0`) 的 SSH 私钥。
| Secret 名称 | 用途 |
|-------------|------|
| `USAARMLOGIN_SSH_KEY` | SSH 私钥,用于连接部署目标服务器 |
| `GITEA_TOKEN` | Gitea API Token用于发布 Release |
--- ---
@@ -64,6 +173,26 @@ docker-compose ps
# 查看应用最新日志 # 查看应用最新日志
docker-compose logs -f insight-reply-server docker-compose logs -f insight-reply-server
# 查看最近 100 行日志
docker-compose logs --tail 100 insight-reply-server
# 重启应用服务 # 重启应用服务
docker-compose restart insight-reply-server docker-compose restart insight-reply-server
# 强制重新构建并启动
docker-compose up -d --build
# 进入容器内部排查
docker exec -it insight-reply-server sh
``` ```
---
## 📊 监控与告警 *(规划中)*
| 维度 | 方案 |
|------|------|
| **应用日志** | Docker JSON 日志 + 未来接入 Loki 或 Datadog |
| **健康检查** | Docker healthcheck + Uptime Kuma |
| **APM** | 后续考虑接入 Sentry Go SDK |
| **成本监控** | `api_usage_logs` 表追踪 Token 消耗 |

View File

@@ -1,31 +1,67 @@
# InsightReply 开发任务分解版 # InsightReply 开发任务分解版
基于 PRDv1.0),我们将开发任务划分为以下核心模块与阶段,适合在 Notion/Jira 等任务管理系统中作为 Epic/Story 录入。 基于 PRDv1.0与产品功能路线图,我们将开发任务划分为以下核心模块与阶段,适合在 Notion/Jira 等任务管理系统中作为 Epic/Story 录入。
> 📌 功能完善度的详细分析与竞品对比见 → [`PRODUCT_ROADMAP.md`](./PRODUCT_ROADMAP.md)
> **状态标签说明**:🟢 已完成 | 🟡 进行中 | ⚪ 未开始
--- ---
## 🏁 第一阶段:核心 MVP预计耗时 2-4 周) ## 🏁 第一阶段:核心 MVP预计耗时 2-4 周)
**核心目标**:跑通获取推文 -> 生成多策略评论 -> 一键复制的核心业务流。 **核心目标**:跑通"获取推文 -> 生成多策略评论 -> 一键复制"的核心业务流。
### Epic 1: 项目基础设施搭建 ### Epic 1: 项目基础设施搭建
- [ ] **前端框架初始化**搭建浏览器插件Chrome Extension基础模版使用 Vue 3 + Tailwind CSS,并配置打包工具(如 Plasmo 或 Vite - [x] 🟢 **前端框架初始化**搭建浏览器插件Chrome Extension基础模版使用 Vue 3 + Tailwind CSS v4配置 Vite + CRXJS 打包工具
- [ ] **后端架构选型与初始化**:创建 Go (Golang) 后端服务基础框架。 - [x] 🟢 **后端架构选型与初始化**:创建 Go (Golang) 后端服务基础框架chi 路由 + GORM + Clean Architecture 分层)
- [ ] **数据库初始化**设计并创建基础表结构Users, MonitoredKeywords, Tweets, GeneratedReplies - [x] 🟢 **数据库初始化**设计并创建基础表结构Users, MonitoredKeywords, Tweets, GeneratedReplies
- [ ] **LLM API 接入**申请并联通 OpenAI (GPT-4) 或其他大模型 API建立接口通信链路。 - [x] 🟢 **LLM API 接入**:联通 OpenAI GPT-4o Mini API建立接口通信链路。
- [x] 🟢 **CI/CD 流水线**:搭建 Gitea Actions 自动部署(后端 Docker + 前端打包发布)。
### Epic 2: 浏览器插件核心开发 ### Epic 2: 浏览器插件核心开发
- [ ] **UI 侧边栏/弹窗实现**:在 XTwitter页面注入前端组件展示 InsightReply 面板。 - [x] 🟢 **UI 侧边栏实现**:在 XTwitter页面注入前端组件Shadow DOM 隔离),展示 InsightReply 面板。
- [ ] **推文内容提取**获取当前浏览中的相关推文文本内容及上下文 - [x] 🟢 **推文内容提取**Content Script 使用 MutationObserver 监听 DOM提取推文文本、作者、互动数据
- [ ] **手动生成交互**:用户手动点击“生成”,调用后端接口返回评论建议 - [x] 🟢 **手动生成交互**:用户点击"Insight"按钮 → Background → Go API → LLM → 返回结果
- [ ] **结果呈现与复制**:展示返回的备选评论,支持一键复制操作。 - [x] 🟢 **结果呈现与复制**:展示备选评论,支持一键复制操作。
### Epic 3: 评论生成引擎(基础版) ### Epic 3: 评论生成引擎(基础版)
- [ ] **提示词(Prompt工程调优**编写可稳定生成 5 种不同属性(认知升级型、反向观点型、数据补充型、共鸣型、创始人经验型)的底层提示词 - [x] 🟢 **基础 Prompt 工程**实现单条评论生成(支持策略与身份参数传入)
- [ ] **身份预设支持**:支持基础的用户预设身份(如 AI 创始人/SaaS Builder与推文内容一同传入 LLM - [ ] **多策略批量生成**:一次生成 5 种策略(认知升级型、反向观点型、数据补充型、共鸣型、创始人经验型)× 2 条备选
- [ ]**Prompt 深度优化**:引入 Hook+Position+Insight+Brevity 结构公式、Few-shot 示例、语言控制、Token 消耗追踪。
- [ ]**Prompt 模板化管理**:建立 `prompts/` 目录,每个策略独立模板文件。
### Epic 4: 基础关键词监控 ### Epic 4: 高可用后台监控系统 (Nitter Scraping)
- [ ] **监控规则配置**前端/后台页面支持用户录入最初的几个核心关键词 - [x] **监控规则配置**提供 API 允许用户录入核心监控关键词与目标账号
- [ ] **定时拉取脚本**:服务端定时通过 API/规则 拉取匹配关键词的相关推文缓存于数据 - [x] **高可用采集引擎 (Scraper)**:基于 `goquery` 解析自建 Nitter (`https://x.beenglish.eu.org/`) 的 DOM 获取数据。
- [x]**防封禁反风控 (Anti-Ban)**:实现请求抖动 (Jitter)、User-Agent 轮换与隔离会话 (Session Isolation)。
- [x]**熔断降级机制 (Circuit Breaker)**:在 Nitter 被限流报 429/503 时自动暂停抓取,触发指数退避 (Exponential Backoff)。
### Epic 11: 工程基础设施加固 *(新增)*
- [x]**JWT 认证中间件**:为 `/api/v1` 路由添加 Bearer Token 认证,绑定用户身份。
- [x]**Rate Limiting**:按用户 subscription_tier 分级限流Free: 10次/天, Pro: 无限)。
- [x]**CORS 配置**:添加 `go-chi/cors` 中间件,白名单 Chrome 扩展 Origin。
- [x]**Multi-LLM 路由与韧性设计**:支持 OpenAI / Anthropic / DeepSeek / Gemini 多模型动态路由,为调用添加 Timeout30s、Circuit Breaker`sony/gobreaker`)、指数退避重试。
- [x]**Graceful Shutdown**:使用 `signal.NotifyContext` 实现平滑关停。
- [x]**健康检查**:添加 `/health` 端点 + Docker healthcheck。
- [x]**DB Migration 机制**:引入 `golang-migrate/migrate`,建立 `migrations/` 目录。
- [x]**单元测试骨架**:覆盖 Handler/Service 层核心路径 + CI 自动运行测试。
- [x]**环境变量规范化**API 地址、LLM Provider 等全部走环境变量或配置文件。
### Epic 12: 用户体验与功能完善 *(新增 — 来自产品评审)*
- [x]**多条备选评论 (P0)**:单次返回 3 种策略 × 1 条 = 3 条备选Sidebar 改为卡片列表式展示;记录用户策略偏好。
- [x]**用户 Onboarding 流程 (P0)**:首次使用 3 步引导 → 身份标签 / 偏好语言 / 风格倾向 → 存入 `chrome.storage.sync` + 后端用户表。
- [x]**前端热度标签 (P1)**Content Script 基于 DOM 互动数注入 🔥 Trending / ⚡ Rising 徽章,帮用户判断评论时机。
- [x]**评论历史 Tab (P1)**:扩展 Popup 新增 History Tab本地存储最近 50 条记录,支持搜索和策略筛选。
- [x]**Quote Tweet 生成 (P1)**:新增第 6 种策略「引用评论型」,生成可独立发帖的引用转发内容。
- [x]**错误边界与离线处理**API 失败时显示重试按钮 + 明确错误提示,后端不可达时给出离线提示。
### Epic 15: 用户可配置系统 *(新增 — 可扩展性设计)*
> 所有与用户场景相关的能力均通过用户自定义设置实现,系统不硬编码任何特定产品/领域/策略,确保可扩展性。
- [x]**产品档案 (Product Profile)**:用户可配置产品名、领域、功能、竞品、相关关键词等;系统自动注入 Prompt 上下文。
- [x]**自定义策略模板 (Custom Strategies)**:除内置 5 种策略外,用户可创建私有策略(含 Prompt 模板 + Few-shot 示例);前端 Sidebar 动态合并展示。
- [x]**竞品监控 (Competitor Monitoring)**:用户添加竞品品牌名/X 账号,雷达自动监控竞品讨论推文。
- [x]**推文相关性评分 (Relevance Scoring)**Content Script 基于产品档案中的 `relevance_keywords` 做前端轻量匹配,高相关推文旁标注 🎯 标签。
- [x]**Profile 优化提醒**:用户复制评论后弹出 Tip提醒确保 X Bio 和 Pin Tweet 包含产品信息。
--- ---
@@ -33,18 +69,25 @@
**核心目标**:实现对账号的定点监控、推文的热度初步计算,并让评论策略更完善。 **核心目标**:实现对账号的定点监控、推文的热度初步计算,并让评论策略更完善。
### Epic 5: 账号与组合监控 ### Epic 5: 账号与组合监控
- [ ] **账号监控功能**:实现对重点账号的定点监控配置(支持实时抓取)。 - [x] **账号监控功能**:实现对重点账号的定点监控配置(支持实时抓取)。
- [ ] **多规则组合过滤**:支持指定账号 + 关键词、AND/OR 多条件的交叉过滤搜索。 - [x] **多规则组合过滤**:支持指定账号 + 关键词、AND/OR 多条件的交叉过滤搜索 API
### Epic 6: 热度评分系统 ### Epic 6: 数据飞轮与快照引擎 (Data Flywheel)
- [ ] **热度指标采集**:获取推文的点赞、转发、评论数量的变化速率 - [x] **快照入库 (UPSERT 逻辑)**:抓取的推文以 `x_tweet_id` 为唯一键存入 `tweets` 表,更新转评赞数据
- [ ] **热度公式落地**:实现 `热度 = 点赞增长率*0.4 + 转发增长率*0.3 + 评论增长率*0.3` 算法 - [x] **动态热度算法 (Heat Score)**计算两次抓取的增量Delta Likes/Retweets/Replies运用公式计算当前热度分 `heat_score`
- [ ] **增强因子计算**接入账号蓝V标识识别、粉丝数权重计算和热搜趋势匹配 - [x] **动态智能抓取频率 (Smart Crawling)**:热度飙升的推文升至 `high` 队列,长时间沉默推文降级至 `low` 队列
- [ ] **阈值提醒机制**:当分析出的推文热度超过设定阈值,出现在插件的“高潜爆款候选列”中 - [x] **高潜商机面板 (Hot Opportunities)**:提供 `GET /tweets/hot` 并在插件内实现商机大赏面板
### Epic 7: Web 管理后台 ### Epic 7: 独立 Web 管理控制台 (SaaS 面板)
- [ ] **Web 界面开发**Nuxt.js / Vue 3 等前端框架搭建完整数据看板 - [x] **基础设施 (P2)**:使用 Vue 3 (或 React) + Vite 搭建独立的管理页面,支持账号密码与 JWT 独立登录体系
- [ ] **策略调整与历史记录**:用户可查看所有生成过的历史评论,调整个人细分风格标签库 - [x] **Web 界面开发**Vite / Vue 3 前端框架搭建完整数据看板,暗黑玻璃态美学 UI (Dashboard / Radar / History)
- [x]**策略与历史接入**:实现了 /api/v1/replies 分析回溯接口,与监控拦截热点推文分发墙 (Pinterest Layout)。
### Epic 13: 性能数据追踪与个人风格学习 (AI Style Engine)
- [x]**动作埋点与入库**前端点击“Copy”时POST `/api/v1/replies/record` 存入 `generated_replies`。若为野生帖子,则静默补全伪造的关联 `Tweet` 防止外键错误。
- [x]**性能回查 Worker**:新增 `PerformanceWorker`,独立线程每 30 分钟轮询 24 小时前已拷贝但未检验成效的回复。通过 Nitter `/i/status/id` 结构爬取目标原帖楼层并使用字符串相似度算法寻找目标回复并采集点赞量。
- [x]**自动 AI 风格反推**:点赞突增 > 10 的神级评论,将会独立喂入 OpenAI / Anthropic提取指令Tone/Structure/Jargon固化至 `user_style_profiles.tone_preference` 供给日后的推文任务强制学习。注入风格画像。
- [ ]**评论时机智能提醒 (P3)**:后端监控到高潜推文时,通过 Chrome Notification 推送 → 点击跳转 → Sidebar 预加载备选评论。
--- ---
@@ -52,13 +95,17 @@
**核心目标**:验证效果以形成数据反馈,推出付费订阅,强化护城河。 **核心目标**:验证效果以形成数据反馈,推出付费订阅,强化护城河。
### Epic 8: 商业化支付与权限系统 ### Epic 8: 商业化支付与权限系统
- [ ] **支付系统接入**:集成 Stripe 等主流订阅支付平台。 - [ ] **支付系统接入**:集成 Stripe 等主流订阅支付平台。
- [ ] **多级版本控制**:根据 Free/Pro/Premium 版本,对生成次数限制”“监控关键词上限”“账号数量进行鉴权与隔离。 - [ ] **多级版本控制**:根据 Free/Pro/Premium 版本,对"生成次数限制""监控关键词上限""账号数量"进行鉴权与隔离。
### Epic 9: 评论效果数据反馈V2 ### Epic 9: 评论效果数据反馈V2
- [ ] **社交数据回拨检测**:定期查询用户发布评论后的真实点赞、回复数据。 - [ ] **社交数据回拨检测**:定期查询用户发布评论后的真实点赞、回复数据。
- [ ] **用户表现看板**:在 Web 后台提供最有效互动风格”“最佳发帖时间的数据可视化分析图表。 - [ ] **用户表现看板**:在 Web 后台提供"最有效互动风格""最佳发帖时间"的数据可视化分析图表。
### Epic 10: AI 模型个性化学习 ### Epic 10: AI 模型个性化学习
- [ ] **风格反馈微调**:针对高频点赞的回复风格,优化该用户的专属 Prompt。 - [ ] **风格反馈微调**:针对高频点赞的回复风格,优化该用户的专属 Prompt。
- [ ] **长期资产构建**:落地行业趋势资料包与垂直知识库,辅以 RAG 技术提高生成内容的深度。 - [ ] **长期资产构建**:落地行业趋势资料包与垂直知识库,辅以 RAG 技术提高生成内容的深度。
### Epic 14: 增长与传播引擎 *(新增)*
- [ ]**效果排行 & 成就系统**:每周评论表现报告 + 成就徽章Viral Reply、Growth Streak 等)。
- [ ]**社交裂变入口**Free 版评论卡片底部 "Generated by InsightReply" 水印Pro 可去除)+ 评论表现分享图。

554
docs/IMPLEMENTATION_PLAN.md Normal file
View File

@@ -0,0 +1,554 @@
# InsightReply 实施计划 (Implementation Plan)
> 本文档记录了 InsightReply 各模块的具体技术实现方案,是 `DEVELOPMENT_PLAN.md`(做什么)的落地补充(怎么做)。
> 功能完善度的产品策略分析见 → [`PRODUCT_ROADMAP.md`](./PRODUCT_ROADMAP.md)
---
## 一、数据抓取策略 (Data Acquisition Strategy)
InsightReply 采用 **"前端主动感知 + 后端规则引擎"** 的双模式来实现数据抓取:
### 1. 插件端交互式抓取 (User-Driven)
* **触发场景**:用户在 X (Twitter) 上浏览推文时,想针对某条推文生成评论。
* **实现逻辑**Content Script 直接从浏览器 DOM 中提取当前选定推文的作者、正文、互动数 (Like/Reply)。
* **优点**:零延迟,无需后端预先抓取该推文,即看即得。
### 2. 后端雷达监控抓取 (System-Driven / Radar)
* **方案选型对比**
| 方案 | 代表 | 优点 | 缺点 | 月费 |
|------|------|------|------|------|
| 付费 SaaS | Apify / RapidAPI | 即插即用,免维护 | 按次计费 | $30-$100+ |
| 自建开源 | **Nitter + Camoufox** | 几乎免费 | 技术门槛高 | 仅服务器费 |
* **最终选型**:自建 Nitter 开源方案。
* **Nitter 实例地址**`https://x.beenglish.eu.org/`
---
## 二、浏览器插件技术实现 (Extension Architecture)
### 1. Content Script 与 UI 注入
* **Content Script (`src/content/index.ts`)**:监听页面 URL 变化,使用 `MutationObserver` 实时检测新加载的推文 DOM 节点。
* **Shadow DOM 注入**:在推文操作栏注入"Insight"入口按钮。点击后展示主面板,面板挂载在 Shadow DOM 内,确保与 Twitter 原生样式完全隔离。
* **Background Service Worker**:作为中转站,处理与后端 Go API 的通信和认证。
### 2. UI 设计规范 (ui-ux-pro-max)
* **暗黑毛玻璃风格**:主色调 `#0A0A0A` ~ `#171717`,浮动面板使用 `backdrop-filter: blur()`
* **品牌渐变色**:紫蓝渐变 `#8B5CF6``#3B82F6`,用于 CTA 按钮和高亮标签。
* **字体**:优先使用 `Inter` / `Geist` / `SF Pro` 等现代无衬线字体。
* **微动画**:所有状态切换配 `ease-in-out` 过渡(`200ms-300ms`)。
### 3. 技术栈规范
* **Tailwind CSS v4**:所有间距、颜色从 Design Tokens 读取,禁止硬编码内联样式。
* **Vue 3 Composition API**:高频 UI 元素(`Button`, `Card`, `Badge`)封装为基础组件。
* **`cn()` 工具函数**:基于 `clsx` + `tailwind-merge` 统一处理动态 Class 冲突。
* **轻量化打包**:禁止引入完整组件库,采用按需加载。
### 4. Content Script 性能优化策略 *(新增)*
> 在 Twitter 重型 SPA 中,粗暴监听全文档 DOM 变更会产生严重性能问题。
* **收缩监听范围**:将 `MutationObserver``observe` 目标从 `document.body` 收缩到 `main[role="main"]` 或 Timeline 容器。
* **防抖扫描**`scanTweets()` 加入 `requestIdleCallback``debounce(200ms)`,避免高频回调。
* **已处理标记**:通过 `WeakSet<HTMLElement>` 记录已注入按钮的推文节点,避免重复扫描。
* **多选择器 Fallback**Twitter 的 `data-testid` 属性随时可能变更,已处理推文选择器应支持多层 Fallback
```typescript
const TWEET_SELECTORS = [
'article[data-testid="tweet"]',
'article[role="article"]',
'div[data-testid="cellInnerDiv"] article'
];
```
---
## 三、高可用后台抓取系统 (Industry Radar - Enterprise Architecture)
考虑到系统的**稳定性、安全性和反风控 (Anti-Ban)**,自建 Nitter 之上的抓取系统设计为一套健壮的企业级爬虫架构。
### 1. 终极反风控与防封禁策略 (Anti-Ban Mechanisms)
| 机制 | 说明 |
|------|------|
| **请求抖动 (Jitter)** | 每次请求间加入 `1s-5s` 强随机延迟,严禁整点并发 |
| **熔断降级 (Circuit Breaker)** | Nitter 连续返回 429/503 时自动暂停 30 分钟 |
| **Fallback 节点池** | 主节点熔断时自动切换到备用 Nitter 实例 |
| **指纹轮换 (Fingerprint Rotation)** | 同步轮换 UA + `Sec-Ch-Ua` + `Accept-Language` 等全套 Header |
| **会话隔离 (Session Isolation)** | 每个采集任务独立 Cookie Jar避免跨任务身份串联 |
### 2. 高可用采集架构 (Scraper & Queue)
* **异步任务队列**:基于 Redis 的 `Asynq`,解耦"触发"与"执行",控制最大并发数。
* **指数退避重试 (Exponential Backoff)**:失败后按 1min → 3min → 10min 递增重试间隔。
* **采集器健壮性**
* `goquery` 解析找不到元素时记录 Warn返回部分数据**绝不 Panic**。
* 关键报错时 **Dump HTML 快照**到日志表,方便排查 Nitter DOM 结构变更。
### 3. 数据计算与存储层
* **批量 Upsert**:使用 `ON CONFLICT DO UPDATE` 策略,降低 IO 压力,防止死锁。
* **热度公式**
```
heat_score = (Like增量 × 0.4) + (RT增量 × 0.3) + (Reply增量 × 0.3)
```
* **动态智能抓取频率 (Smart Crawling)**
* 🔥 热度飙升的推文 → **高频队列**(每 15 分钟抓一次)
* 🧊 24 小时无新动态 → **低频队列**(每 4 小时抓一次)
* 极大节省抓取资源,显著降低被封概率。
---
## 四、后端安全与认证体系 *(新增)*
### 1. JWT 认证方案
所有 `/api/v1` 路由(除 `/auth/*` 外)必须经过 JWT 认证中间件。
* **技术选型**`go-chi/jwtauth` + HMAC-SHA256 签名。
* **Token 结构**
```json
{
"sub": "user-uuid",
"tier": "Pro",
"exp": 1709164800
}
```
* **刷新策略**Access Token 有效期 24hRefresh Token 有效期 30d存于 `chrome.storage.local`。
### 2. Rate Limiting分级限流
| Tier | 评论生成 | 监控关键词 | 监控账号 |
|------|---------|-----------|---------|
| Free | 10 次/天 | 3 个 | 3 个 |
| Pro | 无限 | 20 个 | 20 个 |
| Premium | 无限 | 50 个 | 50 个 |
* **实现方案**:基于 Redis 的滑动窗口计数器Key 格式:`ratelimit:{user_id}:{action}:{window}`。
* **中间件挂载**:在 chi Router 中通过 `r.Use(RateLimitMiddleware)` 全局或分路由挂载。
### 3. CORS 配置
```go
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"chrome-extension://*", "https://x.com", "https://twitter.com"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Authorization", "Content-Type"},
AllowCredentials: true,
MaxAge: 300,
}))
```
---
## 五、LLM 服务韧性设计 *(新增)*
### 1. 调用链路防护
```
请求 → Timeout Guard (30s) → Circuit Breaker → Retry (max 2) → OpenAI API
```
* **Timeout**`context.WithTimeout(ctx, 30*time.Second)` 包裹每次 LLM 调用。
* **Circuit Breaker**:使用 `sony/gobreaker`,连续 5 次失败后半开,每 60s 试探一次恢复。
* **指数退避重试**:仅对 5xx / Timeout 重试4xx如 quota exceeded立即失败。
### 2. 多模型动态路由与 Fallback
系统重构为 **多 Provider 支持 (OpenAI / Anthropic / DeepSeek / Gemini)**,以应对不同用户的专属模型偏好和单一 Provider 宕机时的容灾。同时支持**代理接口与自定义模型**。
**路由与配置设计**
1. **环境变量驱动 (ENV)**:管理员可通过配置 `OPENAI_BASE_URL` 改写接口地址(完美兼容 Groq、vLLM、Ollama 等任意 OpenAI 兼容代理)。
2. **可用模型下发**:通过 `OPENAI_AVAILABLE_MODELS="gpt-4o,gpt-4o-mini"` 配置白名单,后端通过 `GET /sys/config/llms` 统一向前端下发。
3. **前端交互 (`default_llm_model`)**:前端展示为**「可输入的下拉列表 (Combobox)」**,用户既可从系统下发的列表中点选,也支持手动输入任意字符串,实现完全自定义。
4. **故障转移 (Fallback)**:当前尝试的 Provider 返回 5xx 时,按兜底顺序切换:`Anthropic → OpenAI → Gemini → DeepSeek`。
**统一适配器抽象**
建立统一的 `LLMProvider` 接口,抹平各平台 API 结构差异,内部集成对应 SDK
- OpenAI (含兼容代理): `sashabaranov/go-openai` (支持通过 Builder 传入 BaseURL)
- Anthropic: 直接构造 REST API 请求
- DeepSeek: 兼容 OpenAI SDK基于 `DEEPSEEK_BASE_URL` 重连
- Gemini: `google.golang.org/api/generativelanguage/v1beta`
### 3. Token 成本审计
每次 LLM 调用记录 `prompt_tokens + completion_tokens`,写入 `api_usage_logs` 表:
```sql
INSERT INTO api_usage_logs (user_id, provider, model, prompt_tokens, completion_tokens, cost_usd)
VALUES ($1, $2, $3, $4, $5, $6);
```
---
## 六、Prompt 工程规范 *(新增)*
### 1. 策略模板结构
每个评论策略维护独立的 Prompt 模板文件,存放于 `server/prompts/` 目录:
```
server/prompts/
├── system.txt # 全局 System Prompt
├── cognitive_upgrade.txt # 认知升级型
├── contrarian.txt # 反向观点型
├── data_supplement.txt # 数据补充型
├── empathy.txt # 共鸣型
└── founder_exp.txt # 创始人经验型
```
### 2. Prompt 结构公式 (必须遵守)
```
[System Prompt]
- 角色定义:专业社交媒体表达顾问
- 输出约束:字数、语言、格式
[User Prompt]
- 用户身份标签:{identity}
- 目标推文:{tweet_content}
- 策略指令:{strategy_specific_instruction}
- 结构公式Hook + Position + Insight + Brevity
- Few-shot 示例2-3 条高质量参考
[Constraints]
- 不超过 280 字符 (或用户指定长度)
- 语言:与原推文一致,或用户指定
- 语气与身份匹配
- 禁止引号包裹
```
### 3. 批量生成规范
API 单次返回 5 种策略 × 2 条备选 = **10 条候选评论**
* 方案 A单次 LLM 调用Prompt 中要求 JSON 数组输出
* 方案 B5 组并发调用,每组 n=2
* **推荐方案 A**,减少 API 调用次数
---
## 七、后端 Go 接口规范
* 采用 RESTful 标准。
* 所有接口返回统一 JSON 结构:
```json
{
"code": 200,
"message": "success",
"data": {}
}
```
* 错误码体系:`4xxx` 为客户端错误,`5xxx` 为服务端错误。
* 详细接口文档见 → [`docs/API.md`](./API.md)
---
## 八、环境变量与配置管理 *(新增)*
所有运行时配置统一通过环境变量注入,**禁止硬编码**。
| 变量名 | 说明 | 示例 |
|-------|------|------|
| `DATABASE_URL` | PostgreSQL 连接串 | `postgres://user:pass@host:5432/db` |
| `OPENAI_API_KEY` | OpenAI API Key | `sk-...` |
| `JWT_SECRET` | JWT 签名密钥 | 随机 32+ 字符串 |
| `SERVER_PORT` | 服务端口 | `8080` |
| `CORS_ORIGINS` | 允许的跨域来源 | `chrome-extension://*` |
| `LOG_LEVEL` | 日志级别 | `info` / `debug` / `warn` |
| `LLM_TIMEOUT_SEC` | LLM 调用超时秒数 | `30` |
模板文件 → [`server/.env.example`](../server/.env.example)
---
## 九、验证计划 (Verification Plan)
| # | 验证项 | 方法 |
|---|--------|------|
| 1 | 防封控链路 | 模拟 100 并发请求,验证熔断 + Jitter 能正确拦截 |
| 2 | HTML 解析容错 | 模拟账号冻结/推文被删等异常页面,确认不会 Panic |
| 3 | Shadow DOM 隔离 | 在 X 深色/浅色模式下验证插件样式不被污染 |
| 4 | 端到端生成链路 | 插件点击 → Background → Go API → LLM → 返回结果 |
| 5 | JWT 认证 *(新增)* | 无 Token / 过期 Token / 非法 Token 拒绝访问 |
| 6 | Rate Limiting *(新增)* | Free 用户超 10 次/天后返回 429 |
| 7 | LLM 韧性 *(新增)* | 模拟 OpenAI 超时 / 5xx验证熔断降级 + 重试 |
| 8 | Graceful Shutdown *(新增)* | 发送 SIGTERM验证在途请求完成后才退出 |
| 9 | Onboarding 流程 *(新增)* | 首次打开插件触发引导,设置存入 chrome.storage + 后端 |
| 10 | 多条备选评论 *(新增)* | 验证返回 3 条备选Sidebar 卡片式展示正确 |
| 11 | 热度标签 *(新增)* | 在高互动推文旁正确显示 🔥/⚡ 标签 |
| 12 | 效果追踪闭环 *(新增)* | 复制评论 → 24h 后回查 → 互动数据写入 DB |
---
## 十、用户 Onboarding 实现方案 *(新增)*
### 1. 流程设计
首次安装/首次打开插件时,检测 `chrome.storage.sync` 中是否存在 `onboarding_completed` 标志。若无,显示 3 步引导:
```typescript
// background/index.ts
chrome.runtime.onInstalled.addListener(({ reason }) => {
if (reason === 'install') {
chrome.storage.sync.set({ onboarding_completed: false });
}
});
```
### 2. 数据存储
| 字段 | 存储位置 | 说明 |
|------|---------|------|
| `identity_label` | `chrome.storage.sync` + `users` 表 | 身份标签 |
| `language_preference` | `chrome.storage.sync` + `users` 表 | 偏好语言 |
| `tone_preference` | `chrome.storage.sync` + `user_style_profiles` 表 | 风格倾向 |
| `onboarding_completed` | `chrome.storage.sync` | 是否已完成引导 |
### 3. 与生成链路的集成
用户设置完成后,每次生成评论时自动读取 `chrome.storage.sync` 中的偏好,附加到 `GENERATE_REPLY` 消息中:
```typescript
// 改造 Sidebar.vue 的 generate()
const prefs = await chrome.storage.sync.get(['identity_label', 'language_preference', 'tone_preference']);
chrome.runtime.sendMessage({
type: 'GENERATE_REPLY',
payload: {
tweetContent: props.tweetData.content,
strategy: 'all',
identity: prefs.identity_label,
language: prefs.language_preference,
tone: prefs.tone_preference
}
});
```
---
## 十一、前端热度标签实现方案 *(新增)*
### 1. 判断逻辑
```typescript
interface HeatLevel {
label: string;
emoji: string;
className: string;
}
const getHeatLevel = (likes: number, minutesAgo: number): HeatLevel | null => {
if (likes > 1000 && minutesAgo < 120) {
return { label: 'Trending', emoji: '🔥', className: 'heat-trending' };
}
if (likes > 100 && minutesAgo < 60) {
return { label: 'Rising', emoji: '⚡', className: 'heat-rising' };
}
return null;
};
```
### 2. 注入位置
在 `injectInsightButton()` 函数中,紧挨 Insight 按钮旁边注入热度标签:
* 🔥 Trending红色渐变 Badge (`background: linear-gradient(135deg, #ef4444, #f97316)`)
* ⚡ Rising黄色渐变 Badge (`background: linear-gradient(135deg, #f59e0b, #eab308)`)
### 3. 时间计算
从推文 DOM 中的 `<time datetime="...">` 元素提取发帖时间,计算与当前时间的差值:
```typescript
const timeEl = tweetElement.querySelector('time');
const postedAt = new Date(timeEl?.getAttribute('datetime') || '');
const minutesAgo = (Date.now() - postedAt.getTime()) / 60000;
```
---
## 十二、数据飞轮 — 效果追踪与风格学习 *(新增)*
### 1. 效果追踪数据流
```
用户复制评论
│ copyToClipboard() → 同时记录 hash(reply_text) 到 chrome.storage.local
chrome.storage.local:
tracked_replies: [{
hash: "a1b2c3",
text: "Most people miss...",
strategy: "contrarian",
copied_at: 1709164800,
tweet_url: "https://x.com/.../status/..."
}]
Content Script 延迟触发 (当用户访问自己 Profile 页时)
│ 条件: URL 匹配 x.com/{current_user}
扫描用户近期 Tweets/Replies
│ 对比 tracked_replies 中的 text hash
匹配成功 → 提取当前 Likes / Replies 数
chrome.runtime.sendMessage → Background → POST /api/v1/replies/{id}/performance
写入 reply_performance 表
```
### 2. 风格学习 Prompt 注入
当 `user_style_profiles` 表有足够数据(≥ 10 条追踪记录)后,生成评论时自动注入风格画像:
```
[附加上下文 - 个人风格画像]
基于你过去 30 天的高互动评论分析:
- 最有效策略: {top_strategies} (平均互动率 {avg_rate}%)
- 风格特征: {style_description}
- 高频高互动关键词: {keywords}
- 偏好评论长度: {avg_length} 字符
请参考以上风格特征生成评论。
```
### 3. 风格特征提取 (后端定时任务)
每周对每个用户的 `reply_performance` 数据运行一次 LLM 分析:
```
System: 你是社交媒体互动分析专家。
User: 以下是该用户最近 30 天互动率最高的 10 条评论:
{replies_list}
请总结其写作风格特征, 包括:
1. 最常用的句式结构
2. 高频关键词或短语
3. 语气倾向 (专业/幽默/犀利)
4. 平均有效长度
输出为 JSON 格式。
```
---
## 十三、用户可配置系统设计 *(新增)*
> **设计原则**:系统不硬编码任何特定产品/领域/策略。所有与用户场景相关的能力均通过用户自定义设置实现,确保对任意行业、任意产品类型的可扩展性。
### 1. 架构思想:零硬编码 (Zero Hardcoding)
```
传统做法 (❌):
Prompt 里写死 "你是一个 AI 创始人,正在推广 SwiftBiu..."
可扩展做法 (✅):
Prompt 运行时从 user_product_profiles 表 + user_custom_strategies 表
动态拼装上下文,适用于任何用户、任何产品
```
### 2. 产品档案 (Product Profile) 注入机制
当用户配置了产品档案后,生成评论时自动注入以下上下文块:
```
[产品上下文 — 自动注入,仅当用户已配置产品档案时]
你正在帮助一位 {identity} 撰写社交评论。
该用户正在推广的产品: {product_name} — {tagline}
产品所属领域: {domain}
核心功能: {key_features}
目标用户群: {target_users}
{custom_context}
注意:
- 不要直接提及产品名称或链接 (除非用户使用了"创始人实战型"策略)
- 评论应从 {domain} 领域专家的角度出发,让读者产生"这个人很懂行"的印象
- 引发好奇心 → 点击 Profile → 发现产品,而非硬推
```
### 3. 自定义策略运行时
用户自定义的策略模板支持以下**模板变量**,运行时自动替换:
| 变量 | 来源 | 说明 |
|------|------|------|
| `{tweet_content}` | 当次请求 | 目标推文原文 |
| `{identity}` | 用户设置 | 身份标签 |
| `{product_name}` | 产品档案 | 产品名称 |
| `{domain}` | 产品档案 | 所属领域 |
| `{key_features}` | 产品档案 | 核心功能 |
| `{competitors}` | 产品档案 | 竞品列表 |
| `{language}` | 用户设置 | 输出语言 |
| `{max_length}` | 当次请求 | 最大字符数 |
| `{custom_context}` | 产品档案 | 自定义上下文 |
**后端处理流程**
```go
// service/ai_service.go
func (s *AIService) buildPrompt(req GenerateRequest, profile *ProductProfile, strategies []Strategy) string {
// 1. 加载 System Prompt
// 2. 如果有产品档案 → 注入产品上下文块
// 3. 合并内置策略 + 用户自定义策略
// 4. 替换所有模板变量
// 5. 如果有风格画像 (user_style_profiles) → 注入风格上下文
// 6. 返回最终 Prompt
}
```
### 4. 前端推文相关性评分
Content Script 基于产品档案中的 `relevance_keywords` 对当前浏览的推文做实时相关性判断:
```typescript
// content/relevance.ts
const scoreRelevance = (tweetText: string, keywords: string[]): number => {
const text = tweetText.toLowerCase();
let score = 0;
for (const kw of keywords) {
if (text.includes(kw.toLowerCase())) score++;
}
return score;
};
// 注入标签
const relevance = scoreRelevance(tweetData.content, userProfile.relevance_keywords);
if (relevance >= 2) {
// 注入 🎯 "High Relevance" 标签
}
```
**关键词来源**`chrome.storage.sync` 缓存用户的 `relevance_keywords`(来自产品档案 API定期同步。
### 5. 前端策略合并展示
Sidebar 策略选择器同时展示系统内置策略和用户自定义策略:
```typescript
// Sidebar.vue
const allStrategies = computed(() => [
...builtinStrategies, // 系统内置 5 种
...userCustomStrategies.value // 用户自定义 N 种 (从 API 拉取)
]);
```
自定义策略用不同背景色或 `⚙️ Custom` 标签区分,方便用户识别。
### 6. Prompt 组装顺序
```
┌─────────────────────────────────────────────┐
│ System Prompt (角色定义 + 输出约束) │
├─────────────────────────────────────────────┤
│ [可选] 产品上下文 (如果用户配置了产品档案) │
├─────────────────────────────────────────────┤
│ [可选] 个人风格画像 (如果有 ≥10 条追踪数据) │
├─────────────────────────────────────────────┤
│ User Prompt: │
│ - 身份标签 │
│ - 目标推文 │
│ - 策略指令 (内置 or 用户模板,变量已替换) │
│ - 结构公式: Hook + Position + Insight │
│ - Few-shot 示例 (系统默认 or 用户自定义) │
├─────────────────────────────────────────────┤
│ Constraints (字数/语言/格式) │
└─────────────────────────────────────────────┘
```

348
docs/PRODUCT_ROADMAP.md Normal file
View File

@@ -0,0 +1,348 @@
# InsightReply 产品功能路线图 (Product Roadmap)
> 本文档从**用户为什么会用 → 为什么会留 → 为什么会付费 → 为什么会推荐**四个维度,规划功能完善方向。
> 与 `DEVELOPMENT_PLAN.md`(任务分解)配合使用,本文档侧重**产品策略与竞争力分析**。
---
## 一、核心链路断裂点修复 — 🔴 P0直接影响用户留存
### 1.1 多条备选评论(选择感)
**现状问题**:当前 API 一次只返回 1 条评论。用户看到一条不满意的结果,大概率会觉得"AI 不靠谱"然后关掉。
**目标体验**:每次生成至少 **3 种策略 × 1 条 = 3 条备选**,用户可以对比、选择、微调。
**实现方案**
* 后端单次 LLM 调用要求 JSON 数组输出(方案 A节省 API 开销)
* 前端 Sidebar 改为**卡片列表式展示**,每张卡显示策略标签 + 评论文本 + 复制按钮
* 用户选中某条后记录 `strategy_type` 偏好,为后续个性化打基础
**产品指标影响**预期将评论复制率Copy Rate提升 **2-3 倍**
---
### 1.2 用户 Onboarding 流程(个性化基石)
**现状问题**:身份标签硬编码为 `Independent Developer / Founder`,所有人生成的评论语气完全一样,"个人定位系统"形同虚设。
**目标体验**安装后首次打开时3 步引导完成个性化设置。
**Onboarding 流程设计**
```
Step 1: 你的身份是?
┌────────────────────────────────────┐
│ 🧑‍💻 AI 创始人 │
│ 🚀 SaaS Builder │
│ 💰 投资人 │
│ 🔧 独立开发者 │
│ 📊 技术分析者 │
└────────────────────────────────────┘
Step 2: 偏好语言?
┌────────────────────────────────────┐
│ 🇺🇸 English │
│ 🇨🇳 中文 │
│ 🌐 跟随原推文语言 (Auto) │
└────────────────────────────────────┘
Step 3: 默认风格倾向?
┌────────────────────────────────────┐
│ ⚖️ 专业严谨 │
│ 😄 轻松幽默 │
│ 🔥 犀利锐评 │
└────────────────────────────────────┘
```
**数据存储**`chrome.storage.sync`(跨设备同步) + 后端 `users` 表的 `identity_label` / `language_preference`
---
### 1.3 Prompt 工程深度优化
**现状问题**:当前 System Prompt + User Prompt 总共不到 100 个 token, 缺乏策略差异化指令、结构公式、Few-shot 示例、语言控制。
**目标 Prompt 结构**
```
[System Prompt]
角色:你是 X (Twitter) 高影响力评论撰写专家。
结构公式:每条评论必须遵循 Hook(抓注意力) + Position(表明立场) + Insight(独特洞察) + Brevity(简洁有力)。
约束:不超过 {max_length} 字符,使用 {language} 语言,语气匹配 {identity} 身份。
[User Prompt]
身份: {identity} (例: 连续创业者, AI SaaS 方向)
原推文: "{tweet_content}"
策略: {strategy_name}
策略详细定义: {strategy_instruction}
参考高互动评论 (Few-shot):
1. "Most people miss this — the real moat isn't the model, it's the data flywheel..."
2. "We faced this exact problem building [product]. Here's what actually worked..."
输出 3 种策略各 1 条, JSON 格式:
[{"strategy": "...", "content": "...", "hook_type": "..."}]
```
**关键改进对照表**
| 维度 | 改进前 | 改进后 |
|------|-------|-------|
| 策略区分 | 只传名称 | 每种策略含 5-10 行详细定义 |
| 结构公式 | 无 | Hook+Position+Insight+Brevity |
| Few-shot | 无 | 每种策略附 2-3 条真实高赞示例 |
| 语言控制 | 未指定 | 支持 en / zh / auto |
| 输出格式 | 纯文本 | JSON 结构化,可解析 |
| 字数约束 | "under 280" | 精确 `max_length` 参数 |
---
## 二、体验增强功能 — 🟠 P1提升产品吸引力
### 2.1 前端热度标签
**场景**:用户在 Timeline 浏览时,不知道哪条推文适合去评论。
**方案**Content Script 基于 DOM 中可读取的互动数据,在 Insight 按钮旁注入热度标签:
| 标签 | 条件 | 视觉 |
|------|------|------|
| 🔥 `Trending` | Likes > 1000 且发帖 < 2h | 红色渐变 Badge |
| ⚡ `Rising` | Likes > 100 且发帖 < 1h | 黄色渐变 Badge |
| 无标签 | 不符合上述条件 | 仅显示 Insight 按钮 |
**核心价值**:帮用户**快速判断评论时机**,降低决策成本。纯前端实现,不需要后端支持。
---
### 2.2 评论历史 Tab
**场景**:用户想回看之前生成/使用过的评论,总结什么风格效果好。
**方案**:在扩展 Popup 中新增 `History` Tab
* 本地存储最近 50 条生成记录(`chrome.storage.local`
* 每条显示:原推文摘要(截断 60 字)、策略标签、生成时间、已复制 / 已跳过状态
* 如果有效果数据V2还可标注 ❤️ 互动数
* 支持搜索和策略筛选
---
### 2.3 Quote Tweet引用评论生成
**场景**:很多创始人的高互动内容来自 Quote Tweet ——对别人的推文加上自己的分析后转发发出。
**方案**:策略列表新增第 6 种策略类型:
| 策略 | 说明 | 典型格式 |
|------|------|---------|
| `quote_thread` | 引用评论型 | [引用原推文] + 2-3 句独立观点 + 可选 takeaway |
* Prompt 中明确要求生成"可独立发帖"的长度(≤ 280 字)
* 前端复制按钮改为"Copy as Quote",提示用户在 X 中使用引用转发
---
## 三、核心护城河功能 — 🟡 P2从"可用"到"不可替代"
> [!IMPORTANT]
> P2 阶段的两个功能(效果追踪 + 风格学习)是 InsightReply 与所有竞品拉开差距的**核心壁垒**。
> 竞品只做"生成"InsightReply 做"生成 → 追踪 → 学习 → 越来越像你"的数据飞轮。
### 3.1 评论效果追踪闭环
**用户故事**:作为用户,我想知道我用 InsightReply 生成的评论发出后效果如何,这样我能知道哪种策略最有效。
**实现路径**
```
用户复制评论
Toast 提示:"评论发出后, InsightReply 将在 24h 后追踪互动数据"
Content Script 延迟检测 (24h 后)
│ 通过用户 Timeline DOM 回查
匹配到已发评论 → 提取 Likes / Replies 数
写入 reply_performance 表
"我的评论表现" 数据面板
```
**技术关键点**
* 用户复制评论时,将评论文本哈希存入 `chrome.storage.local` 作为追踪 Key
* Content Script 在用户访问自己 Profile 页时,扫描近期评论,匹配已存 Key
* 匹配成功后提取互动数据,通过 Background → API 写入数据库
* 无需爬虫,**完全基于用户自身浏览行为**触发
### 3.2 个人风格学习飞轮(核心差异化)
**数据飞轮模型**
```
用得越多 → 评论数据越多
高互动评论 → LLM 总结风格特征
│ "这条评论之所以表现好,是因为:
│ 1. 用了反问句开头
│ 2. 引用了数据
│ 3. 语气简洁有力"
更新 user_style_profiles
│ top_strategies: ["contrarian", "data_supplement"]
│ tone_preference: "provocative"
│ high_engagement_keywords: ["moat", "flywheel", "most people miss"]
下次生成 → Prompt 注入个人风格
│ "你的高互动风格倾向: 善用反问句, 喜欢引用数据..."
生成质量提升 → 互动率提升 → 更多数据 → 更懂你 (🔄 Flywheel)
```
**Prompt 注入示例**
```
[附加上下文 - 个人风格画像]
基于你过去 30 天的高互动评论分析:
- 最有效的策略: 反向观点型 (平均互动率 4.2%)
- 你的特征: 善用反问句开头, 喜欢引用具体数据, 语气简洁有力
- 高频出现的高互动关键词: "most people miss", "here's the counterpoint", "data shows"
- 平均最佳评论长度: 180 字符
请参考以上风格特征生成评论。
```
**为什么这是护城河**
* 用户用得越久AI 越懂自己 → **迁移成本极高**
* 竞品无法复制**你的用户的数据**
* 这不是技术壁垒,是**数据网络效应**
---
## 四、增长与传播功能 — 🔵 P3让产品"被看到"
### 4.1 评论时机智能提醒
**场景**:一条推文在发出后 30 分钟内评论的曝光量是 2 小时后的 5-10 倍。但用户不可能 24 小时盯着 Twitter。
**方案**(需要后端雷达+Push
* 后端监控到高潜推文时heat_score 突破阈值)
* 通过 Chrome Notification 推送:"@sama 刚发了一条关于 AI Agents 的推文,热度飙升中⚡"
* 用户点击通知 → 直接跳转到该推文 → Sidebar **已预加载好备选评论**
* 极速评论窗口:监控 → 通知 → 跳转 → 评论 < 60 秒
### 4.2 评论效果排行 & 成就系统
**场景**:给用户正反馈循环,激励持续使用。
**方案**
* 每周邮件/插件内报告:"本周你的评论共获得 XX Likes最佳评论是___"
* 成就徽章:
* 🌟 "First Insight" — 首次生成评论
* 🔥 "Viral Reply" — 单条评论 > 100 Likes
* 📈 "Growth Streak" — 连续 7 天使用
* 🧠 "Style Master" — 风格画像达到 30 条数据
### 4.3 社交裂变入口
* 评论卡片底部低调加入 "Generated by InsightReply" 小字水印Free 版)
* Pro 用户可去除水印
* 分享功能:"查看我本月的评论表现报告" → 生成分享图 → 发推
---
## 五、用户可配置系统 — 可扩展性设计 *(新增)*
> [!IMPORTANT]
> **设计原则:零硬编码 (Zero Hardcoding)**。系统不预设任何特定产品/领域/策略。
> 所有与用户使用场景相关的能力,均通过用户自定义设置实现,确保产品适用于**任何行业、任何创始人**。
### 5.1 用户可配置的产品档案 (Product Profile)
用户在设置中自由配置自己正在推广的产品信息,系统自动将其注入 Prompt 上下文:
| 配置项 | 说明 | 示例 |
|-------|------|------|
| 产品名称 | 用户自填 | "SwiftBiu" |
| 一句话介绍 | 用户自填 | "AI-powered short video creation tool" |
| 所属领域 | 用户自填 | "AI Video Creation" |
| 核心功能 | 列表,用户增删 | ["视频生成", "多语言配音"] |
| 竞品列表 | 列表,用户增删 | ["CapCut", "Descript"] |
| 相关关键词 | 用于推文相关性评分 | ["short video", "content creation"] |
| 自定义上下文 | 任意文本,原样注入 Prompt | "We focus on multi-language..." |
**效果**有了产品档案LLM 生成的评论会自然地从用户产品领域出发,既专业又有关联性。
### 5.2 用户自定义评论策略
系统内置 5 种通用策略(认知升级/反向观点/数据补充/共鸣/创始人经验),但用户可以创建**无限私有策略**
* **策略名称 + 描述**:告诉 LLM 这种策略的写法
* **Prompt 模板**:支持 `{product_name}` `{domain}` `{tweet_content}` 等变量
* **Few-shot 示例**:用户提供自己写过的高质量评论作为参考
* **排序权重**:控制显示顺序
**示例用户策略**
| 策略 | Prompt 核心指令 | 适用场景 |
|------|---------------|---------|
| 🚀 Builder Story | "以 {product_name} 的开发经验为论据..." | 竞品/痛点讨论 |
| 🎓 Domain Expert | "从 {domain} 领域专家角度分析..." | 行业趋势讨论 |
| 🤝 Community Helper | "以用户角度推荐解决方案..." | 用户求助推文 |
### 5.3 推文相关性评分 (Relevance Scoring)
Content Script 基于用户配置的 `relevance_keywords` 对 Timeline 推文做实时相关性判断,在高相关推文旁显示 **🎯 High Relevance** 标签。
纯前端实现,用户只需维护关键词列表即可自动生效。
### 5.4 竞品讨论捕捉
用户添加竞品品牌名(如 "CapCut"、"Descript"),系统两种途径识别竞品推文:
* **前端**Content Script 在 Timeline 中实时检测包含竞品关键词的推文 → 🎯 标签
* **后端**(雷达系统就绪后):定时抓取竞品讨论 → 推送通知
---
## 六、竞品差异化定位
### 当前赛道竞品分析2026
| 竞品 | 核心功能 | 定价 | InsightReply 差异化 |
|------|---------|------|-------------------|
| Reply Guy | 自动生成回复 | $39/mo | ❌ 偏自动化灰产,我们定位"写作增强" |
| TweetPik AI | AI 回复 + 截图 | $19/mo | ❌ 无个性化学习,不可配置 |
| XReply | 批量回复 | $29/mo | ❌ 无效果追踪,无产品档案 |
| Postwise | AI 写推文 | $49/mo | ❌ 不专注评论场景 |
| Hypefury | 排程 + AI | $29/mo | ❌ 重排程轻评论 |
| **InsightReply** | **可配置评论引擎 + 风格学习 + 效果闭环** | **$9-29/mo** | ✅ **唯一:产品档案 + 自定义策略 + 数据飞轮** |
### InsightReply 的核心叙事
> **不只是帮你写评论的 AI而是一个理解你的产品、学习你的风格、越用越懂你的社交表达引擎。**
---
## 七、功能优先级总览
| 优先级 | 功能 | 预估工时 | 留存影响 | 所在阶段 |
|-------|------|---------|---------|---------|
| 🔴 P0 | 多条备选评论3策略×1条 | 3h | ⭐⭐⭐⭐⭐ | Phase 1 |
| 🔴 P0 | Onboarding 设置流程 | 4h | ⭐⭐⭐⭐⭐ | Phase 1 |
| 🔴 P0 | Prompt 深度优化 | 4h | ⭐⭐⭐⭐⭐ | Phase 1 |
| 🟠 P1 | 前端热度标签(🔥/⚡) | 2h | ⭐⭐⭐⭐ | Phase 1 |
| 🟠 P1 | 评论历史 Tab | 4h | ⭐⭐⭐⭐ | Phase 1 |
| 🟠 P1 | Quote Tweet 生成 | 3h | ⭐⭐⭐ | Phase 1 |
| 🟠 P1 | 产品档案 (Product Profile) | 3h | ⭐⭐⭐⭐⭐ | Phase 1 |
| 🟠 P1 | 自定义策略模板 | 4h | ⭐⭐⭐⭐ | Phase 1 |
| 🟠 P1 | 推文相关性评分 (🎯) | 4h | ⭐⭐⭐⭐ | Phase 1 |
| 🟡 P2 | 评论效果追踪闭环 | 1-2w | ⭐⭐⭐⭐⭐ | Phase 2 |
| 🟡 P2 | 个人风格学习飞轮 | 1-2w | ⭐⭐⭐⭐⭐ | Phase 2 |
| 🟡 P2 | 竞品监控 + 推送 | 依赖雷达 | ⭐⭐⭐⭐ | Phase 2 |
| 🔵 P3 | 评论时机智能提醒 | 1w | ⭐⭐⭐⭐ | Phase 2 |
| 🔵 P3 | 效果排行 & 成就系统 | 1w | ⭐⭐⭐ | Phase 3 |
| 🔵 P3 | 社交裂变入口 | 3h | ⭐⭐⭐ | Phase 3 |

237
docs/USER_GUIDE.md Normal file
View File

@@ -0,0 +1,237 @@
# InsightReply 用户使用指南
> InsightReply 是一个帮助创始人和独立开发者在 X (Twitter) 行业热点中输出更有洞察力评论的 AI 助手。
> 它是**社交表达增强系统**,不是自动化机器人 —— 所有评论由你决定是否发布。
---
## 一、快速开始
### 1.1 安装扩展
1. 从 [Gitea Release](https://git.buildapp.eu.org/) 下载最新的 `insight-reply-extension.zip`
2. 打开 Chrome → 地址栏输入 `chrome://extensions/`
3. 开启右上角**开发者模式**
4. 点击「加载已解压的扩展程序」→ 选择解压后的文件夹
5. 扩展图标出现在工具栏中 ✅
> [!TIP]
> Edge 浏览器同样支持,操作路径为 `edge://extensions/`
### 1.2 首次设置 (Onboarding)
安装后首次点击扩展图标,会进入 **3 步引导**
| 步骤 | 设置内容 | 说明 |
|------|---------|------|
| Step 1 | 🧑‍💻 **你的身份** | AI 创始人 / SaaS Builder / 投资人 / 独立开发者 / 技术分析者 |
| Step 2 | 🌐 **偏好语言** | English / 中文 / 跟随原推文 (Auto) |
| Step 3 | 🎨 **风格倾向** | 专业严谨 / 轻松幽默 / 犀利锐评 |
这些设置会影响 AI 生成评论的语气和风格,后续可随时在设置中修改。
---
## 二、核心功能使用
### 2.1 生成评论(主流程)
```
打开 X (Twitter) → 浏览 Timeline → 找到想评论的推文
点击推文操作栏旁的紫色 ✦ Insight 按钮
右侧弹出 InsightReply 面板
选择评论策略 → 点击「Generate High-Quality Reply」
AI 返回多条备选评论 → 选择最满意的一条
点击「Copy」→ 粘贴到推文评论框 → 发布
```
### 2.2 评论策略一览
系统内置 5 种策略,适用于不同场景:
| 策略 | 图标 | 适用场景 | 典型开头 |
|------|------|---------|---------|
| **认知升级型** | 🧠 | 对话题提出更深层的洞察 | "Most people miss this part..." |
| **反向观点型** | 🔥 | 提出不同角度,引发讨论 | "Unpopular opinion:" |
| **数据补充型** | 📊 | 用数据/案例补充论点 | "Data shows that..." |
| **共鸣支持型** | ❤️ | 表达认同并延伸讨论 | "This resonates deeply..." |
| **创始人经验型** | 🚀 | 以自身实战经验为论据 | "We faced this building our product..." |
> [!TIP]
> **选择策略的技巧**
> - 大 V 发的行业观点 → 用「认知升级型」或「反向观点型」更容易获得关注
> - 有人吐槽痛点 → 用「创始人经验型」分享你的解决方案
> - 行业数据/报告 → 用「数据补充型」让你的评论更有说服力
### 2.3 识别高价值推文
InsightReply 会在推文旁显示提示标签,帮你快速判断:
| 标签 | 含义 | 建议 |
|------|------|------|
| 🔥 **Trending** | 高热度推文Likes > 1000发帖 < 2h | 快速评论,争取高曝光 |
| ⚡ **Rising** | 增长中的推文Likes > 100发帖 < 1h | 黄金窗口期,强烈建议评论 |
| 🎯 **High Relevance** | 与你的产品领域高度相关 | 最适合你评论的推文! |
---
## 三、产品推广进阶
> 如果你是创始人/独立开发者,正在用 InsightReply 提升产品曝光,以下功能专为你设计。
### 3.1 配置你的产品档案
在扩展设置中填写你的产品信息AI 会自动从你的产品领域出发生成评论:
| 配置项 | 填什么 | 作用 |
|-------|-------|------|
| 产品名称 | 例SwiftBiu | 创始人策略中可能自然提及 |
| 所属领域 | 例AI Video Creation | AI 以此领域专家角度撰写 |
| 核心功能 | 例:视频生成, 多语言配音 | 让评论与产品技术相关 |
| 竞品列表 | 例CapCut, Descript | 竞品被讨论时收到 🎯 提醒 |
| 相关关键词 | 例short video, AI dubbing | Timeline 推文自动标注 🎯 |
| 自定义上下文 | 任意补充说明 | 原样注入 AI 的 Prompt |
> [!IMPORTANT]
> **InsightReply 不会自动发布评论或硬推你的产品。**
> 产品档案的作用是让 AI 从你的领域视角出发写评论,引发读者好奇心 → 点击你的 Profile → 发现你的产品。
### 3.2 切换与自定义 AI 引擎 (多模型支持)
为了满足成本、速度、质量等不同维度的需求InsightReply 支持四大主流平台,并允许**完全自定义模型**。
在产品档案的「重写 AI 引擎」设置中:
1. **下拉选择**:你可以从系统管理员预设的模型列表中快速选择(如 `gpt-4o-mini`, `claude-3-5-haiku-latest`)。
2. **手动输入 (支持本地/代理)**:如果你使用的平台兼容 OpenAI如 Groq、vLLM、Ollama或者你想使用列表中没有的最新模型如刚刚发布的 `gpt-4.5-turbo`),可以直接在框内**手动打字输入任意模型名称**。
| 引擎 | 推荐模型 | 适用场景特点 |
|------|---------|-------------|
| **Anthropic** | `claude-3-5-haiku-latest` | **默认推荐**。响应速度极快,文本语气最自然、最像真人社交媒体发言,不易产生"AI味"。 |
| **OpenAI (或兼容接口)** | `gpt-4o-mini` | 表现稳定,成本极低,适合大批量生成。 |
| **DeepSeek** | `deepseek-chat` | 逻辑分析能力强,中文语感极佳,价格优势明显,适合技术长文讨论。 |
| **Google** | `gemini-2.5-flash` | 速度快,多语言处理能力强。 |
*注:切换平台需要系统后台配置了相应的 API Key 或 Base URL。管理员可将 OpenAI Base URL 指向任意兼容代理,实现模型的无限扩展。*
### 3.3 创建自定义策略
除了内置的 5 种策略,你可以创建专属策略:
**示例创建「Builder Story」策略**
```
策略名: 🚀 Builder Story
描述: 以自身产品经验为论据,自然关联到产品
Prompt 模板:
"以 {identity} 的身份,结合你在 {domain} 领域开发 {product_name} 的经验,
对这条推文写一条有价值的评论。
评论应让读者感受到你是该领域的实践者,而非理论派。"
Few-shot 示例:
- "We faced this exact problem building SwiftBiu. What worked..."
```
自定义策略的 Prompt 模板支持以下变量,运行时自动替换:
| 变量 | 说明 |
|------|------|
| `{tweet_content}` | 目标推文原文 |
| `{identity}` | 你的身份标签 |
| `{product_name}` | 你的产品名 |
| `{domain}` | 你的产品领域 |
| `{key_features}` | 产品核心功能 |
| `{competitors}` | 竞品列表 |
| `{language}` | 输出语言 |
| `{max_length}` | 最大字符数 |
### 3.3 推广效果最大化小贴士
1. **优化你的 X Profile**
- Bio 中明确写 "Building @YourProduct"
- Pin 一条产品介绍/演示推文
- InsightReply 让读者好奇 → 点击 Profile 后**必须能看到你的产品**
2. **评论时机很重要**
- 推文发出后 **30 分钟内**评论,曝光是 2 小时后的 5-10 倍
- 关注 🔥 Trending 和 ⚡ Rising 标签
3. **评论质量 > 数量**
- 一条有深度的评论 > 十条"Great post!"
- 用「反向观点型」或「数据补充型」更容易引发讨论 → 更多曝光
4. **不要硬推产品**
- ❌ "Check out my product SwiftBiu!"
- ✅ "We solved this by building a multi-language pipeline. The key insight was..."
---
## 四、评论历史与效果追踪
### 4.1 查看评论历史
点击扩展图标 → `History` Tab
- 查看所有生成过的评论
- 按策略类型筛选
- 搜索关键词
- 查看复制/跳过状态
### 4.2 效果追踪
InsightReply 会自动追踪你发布的评论效果:
1. 你复制并发布了一条评论
2. 24 小时后,当你再次浏览 X 时,系统自动回查该评论的互动数据
3. 互动数据Likes、Replies会记录到你的个人面板
> 效果数据积累越多 → AI 越懂你的风格 → 生成质量越高 → 形成正向飞轮 🔄
---
## 五、版本与定价
| 版本 | 价格 | 包含功能 |
|------|------|---------|
| **Free** | $0 | 每日 10 次生成 · 3 个监控关键词 · 3 个监控账号 · 内置策略 |
| **Pro** | $29/月 | 无限生成 · 20 个关键词 · 20 个账号 · 自定义策略 · 热度雷达 |
| **Premium** | $59/月 | Pro 全部功能 + 50 个关键词 · 效果分析看板 · 风格优化建议 · 个人品牌报告 |
---
## 六、常见问题 (FAQ)
### Q: InsightReply 会自动发布评论吗?
**不会**。InsightReply 只生成评论建议,你决定是否复制和发布。我们是 AI 写作增强工具,不是自动化机器人。
### Q: 支持哪些浏览器?
Chrome 和 Edge基于 Manifest V3。Firefox 支持规划中。
### Q: AI 生成的评论会不会都一样?
不会。评论基于推文内容、你的身份标签、风格偏好和产品档案动态生成 —— 每次都是独特的。随着效果数据积累AI 会越来越像你自己的写作风格。
### Q: 我的数据安全吗?
- 产品档案和策略模板存储在我们的加密数据库中
- 我们不会读取或存储你的 X 密码
- 我们不会代替你操作你的 X 账号
### Q: 如何修改已保存的设置?
点击扩展图标 → 齿轮图标 ⚙️ → 可修改身份标签、语言偏好、产品档案、自定义策略。
### Q: 竞品关键词如何工作?
在产品档案中添加竞品名称后,当 Timeline 中出现包含竞品关键词的推文InsightReply 会在推文旁显示 🎯 标签,提示你这是一条高价值评论机会。
---
## 七、快捷键
| 快捷键 | 功能 |
|-------|------|
| `Alt + I` | 打开/关闭 InsightReply 面板 |
| `Alt + G` | 快速生成评论 |
| `Alt + C` | 复制选中的评论 |
---
> **反馈与建议**:如果你有功能建议或遇到问题,欢迎在我们的 Gitea 仓库提交 Issue。

View File

@@ -1,3 +1,9 @@
-- ====================================================
-- InsightReply 数据库 Schema (PostgreSQL)
-- 版本: v1.1
-- 更新: 新增 api_usage_logs, subscriptions, user_style_profiles 表
-- ====================================================
-- users 表:存储业务用户 -- users 表:存储业务用户
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -5,6 +11,7 @@ CREATE TABLE IF NOT EXISTS users (
password_hash VARCHAR(255), password_hash VARCHAR(255),
subscription_tier VARCHAR(50) DEFAULT 'Free', -- Free, Pro, Premium subscription_tier VARCHAR(50) DEFAULT 'Free', -- Free, Pro, Premium
identity_label VARCHAR(100), -- AI 创始人, SaaS Builder 等 identity_label VARCHAR(100), -- AI 创始人, SaaS Builder 等
language_preference VARCHAR(10) DEFAULT 'auto', -- en, zh, auto
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
); );
@@ -42,21 +49,25 @@ CREATE TABLE IF NOT EXISTS tweets (
retweet_count INTEGER DEFAULT 0, retweet_count INTEGER DEFAULT 0,
reply_count INTEGER DEFAULT 0, reply_count INTEGER DEFAULT 0,
heat_score FLOAT DEFAULT 0.0, heat_score FLOAT DEFAULT 0.0,
crawl_queue VARCHAR(20) DEFAULT 'normal', -- high, normal, low (智能抓取频率)
is_processed BOOLEAN DEFAULT FALSE, is_processed BOOLEAN DEFAULT FALSE,
last_crawled_at TIMESTAMP WITH TIME ZONE, -- 上次抓取时间
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
); );
CREATE INDEX idx_tweets_x_tweet_id ON tweets(x_tweet_id); CREATE INDEX idx_tweets_x_tweet_id ON tweets(x_tweet_id);
CREATE INDEX idx_tweets_heat_score ON tweets(heat_score DESC); CREATE INDEX idx_tweets_heat_score ON tweets(heat_score DESC);
CREATE INDEX idx_tweets_crawl_queue ON tweets(crawl_queue, last_crawled_at);
-- generated_replies 表:生成的 AI 评论记录 -- generated_replies 表:生成的 AI 评论记录
CREATE TABLE IF NOT EXISTS generated_replies ( CREATE TABLE IF NOT EXISTS generated_replies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tweet_id UUID NOT NULL REFERENCES tweets(id) ON DELETE CASCADE, tweet_id UUID NOT NULL REFERENCES tweets(id) ON DELETE CASCADE,
strategy_type VARCHAR(100) NOT NULL, -- 认知升级型, 反向观点型, 数据补充型, 共鸣型, 创始人经验型 strategy_type VARCHAR(100) NOT NULL, -- cognitive_upgrade, contrarian, data_supplement, empathy, founder_exp
content TEXT NOT NULL, content TEXT NOT NULL,
status VARCHAR(50) DEFAULT 'draft', -- draft, copied, posted status VARCHAR(50) DEFAULT 'draft', -- draft, copied, posted
language VARCHAR(10) DEFAULT 'en', -- 生成语言
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
); );
@@ -67,6 +78,7 @@ CREATE INDEX idx_generated_replies_tweet_id ON generated_replies(tweet_id);
CREATE TABLE IF NOT EXISTS reply_performance ( CREATE TABLE IF NOT EXISTS reply_performance (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
reply_id UUID NOT NULL REFERENCES generated_replies(id) ON DELETE CASCADE, reply_id UUID NOT NULL REFERENCES generated_replies(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- 冗余字段,便于按用户维度查询
like_count_increase INTEGER DEFAULT 0, like_count_increase INTEGER DEFAULT 0,
reply_count_increase INTEGER DEFAULT 0, reply_count_increase INTEGER DEFAULT 0,
interaction_rate FLOAT DEFAULT 0.0, interaction_rate FLOAT DEFAULT 0.0,
@@ -74,8 +86,130 @@ CREATE TABLE IF NOT EXISTS reply_performance (
); );
CREATE INDEX idx_reply_performance_reply_id ON reply_performance(reply_id); CREATE INDEX idx_reply_performance_reply_id ON reply_performance(reply_id);
CREATE INDEX idx_reply_performance_user_id ON reply_performance(user_id);
-- ====================================================
-- 新增表 (v1.1)
-- ====================================================
-- api_usage_logs 表:记录 LLM API 调用量和成本
CREATE TABLE IF NOT EXISTS api_usage_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(50) NOT NULL, -- openai, anthropic, deepseek
model VARCHAR(100) NOT NULL, -- gpt-4o-mini, claude-3.5-haiku 等
prompt_tokens INTEGER DEFAULT 0,
completion_tokens INTEGER DEFAULT 0,
total_tokens INTEGER GENERATED ALWAYS AS (prompt_tokens + completion_tokens) STORED,
cost_usd NUMERIC(10, 6) DEFAULT 0.0, -- 精确到 $0.000001
endpoint VARCHAR(100), -- /ai/generate
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_api_usage_logs_user_id ON api_usage_logs(user_id);
CREATE INDEX idx_api_usage_logs_created_at ON api_usage_logs(created_at DESC);
-- subscriptions 表:用户订阅记录(支付历史)
CREATE TABLE IF NOT EXISTS subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tier VARCHAR(50) NOT NULL, -- Pro, Premium
stripe_subscription_id VARCHAR(255), -- Stripe 订阅 ID
status VARCHAR(50) DEFAULT 'active', -- active, cancelled, past_due, expired
started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP WITH TIME ZONE,
cancelled_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_subscriptions_user_id ON subscriptions(user_id);
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
-- user_style_profiles 表:用户风格画像(用于个性化 Prompt
CREATE TABLE IF NOT EXISTS user_style_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE,
top_strategies JSONB DEFAULT '[]', -- 最常选择的策略排序
avg_reply_length INTEGER DEFAULT 200, -- 平均偏好回复长度
high_engagement_keywords JSONB DEFAULT '[]', -- 高互动关键词
tone_preference VARCHAR(100) DEFAULT 'professional', -- casual, professional, witty, provocative
custom_prompt_suffix TEXT, -- 用户自定义的额外 Prompt 指令
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- crawl_snapshots 表:异常抓取时的 HTML 快照(排错用)
CREATE TABLE IF NOT EXISTS crawl_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
url TEXT NOT NULL,
http_status INTEGER,
html_content TEXT,
error_message TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_crawl_snapshots_created_at ON crawl_snapshots(created_at DESC);
-- ====================================================
-- 新增表 (v1.2) — 用户可配置系统
-- ====================================================
-- user_product_profiles 表:用户的产品档案(用于生成与产品相关联的评论)
-- 设计原则:所有字段用户自定义,系统不做任何硬编码
CREATE TABLE IF NOT EXISTS user_product_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE,
product_name VARCHAR(255), -- 产品名称 (如 "SwiftBiu")
tagline TEXT, -- 一句话介绍
domain VARCHAR(255), -- 所属领域 (如 "AI Video Creation")
key_features JSONB DEFAULT '[]', -- 核心功能列表 ["视频生成", "多语言配音"]
target_users TEXT, -- 目标用户描述
product_url VARCHAR(500), -- 官网或商店链接
competitors JSONB DEFAULT '[]', -- 竞品名称列表 ["CapCut", "Descript"]
relevance_keywords JSONB DEFAULT '[]', -- 相关领域关键词 ["short video", "content creation", "AI dubbing"]
custom_context TEXT, -- 用户自定义的额外上下文(自由文本,注入 Prompt
default_llm_provider VARCHAR(50), -- 用户专属模型偏好: openai, anthropic, deepseek, gemini (覆盖系统默认)
default_llm_model VARCHAR(100), -- 例如: claude-3-5-haiku-latest
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- user_custom_strategies 表:用户自定义评论策略
-- 除系统内置的 5 种策略外,用户可以创建自己的策略模板
CREATE TABLE IF NOT EXISTS user_custom_strategies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
strategy_key VARCHAR(100) NOT NULL, -- 策略标识 (如 "builder_story")
label VARCHAR(255) NOT NULL, -- 显示名称 (如 "创始人实战型")
icon VARCHAR(10), -- Emoji 图标
description TEXT, -- 策略描述(告诉 LLM 这种策略的写法)
prompt_template TEXT, -- 自定义 Prompt 模板(可包含 {tweet_content} {product_name} 等变量)
few_shot_examples JSONB DEFAULT '[]', -- 自定义 Few-shot 示例
is_active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0, -- 排序权重
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, strategy_key)
);
CREATE INDEX idx_user_custom_strategies_user_id ON user_custom_strategies(user_id);
-- competitor_monitors 表:竞品品牌监控(复用后端雷达,按品牌词自动抓取)
CREATE TABLE IF NOT EXISTS competitor_monitors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
brand_name VARCHAR(255) NOT NULL, -- 竞品品牌名
x_handle VARCHAR(255), -- 竞品官方 X 账号 (可选)
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, brand_name)
);
CREATE INDEX idx_competitor_monitors_user_id ON competitor_monitors(user_id);
-- ====================================================
-- 触发器:自动更新 updated_at
-- ====================================================
-- 更新 updated_at 的触发器函数
CREATE OR REPLACE FUNCTION update_modified_column() CREATE OR REPLACE FUNCTION update_modified_column()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
BEGIN BEGIN
@@ -84,8 +218,13 @@ BEGIN
END; END;
$$ language 'plpgsql'; $$ language 'plpgsql';
-- 为 users 表添加触发器 -- 为所有需要追踪更新时间的表添加触发器
CREATE TRIGGER update_users_modtime CREATE TRIGGER update_users_modtime
BEFORE UPDATE ON users BEFORE UPDATE ON users
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION update_modified_column(); EXECUTE FUNCTION update_modified_column();
CREATE TRIGGER update_user_style_profiles_modtime
BEFORE UPDATE ON user_style_profiles
FOR EACH ROW
EXECUTE FUNCTION update_modified_column();

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", "service_worker": "src/background/index.ts",
"type": "module" "type": "module"
}, },
"options_ui": {
"page": "options.html",
"open_in_tab": true
},
"permissions": [ "permissions": [
"storage", "storage",
"activeTab" "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"> <script setup lang="ts">
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import { cn } from './lib/utils'
const isLoading = ref(false) const activeTab = ref<'settings' | 'history'>('settings')
const triggerMockLoading = () => { // Settings State
isLoading.value = true 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(() => { setTimeout(() => {
isLoading.value = false isSaving.value = false
}, 1000) }, 600)
})
}
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text)
}
const clearHistory = () => {
chrome.storage.local.set({ history: [] }, () => {
historyList.value = []
})
} }
</script> </script>
<template> <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 --> <!-- Header -->
<div class="mb-6"> <div class="p-5 border-b border-white/10 bg-white/5 flex justify-between items-center">
<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"> <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 InsightReply
</h1> </h1>
<p class="text-xs text-zinc-400 mt-1">Social Insight Copilot</p> <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> </div>
<!-- Main Content Area --> <!-- 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 --> <!-- Settings Tab -->
<div class="bg-[#171717] rounded-xl p-4 border border-white/5 shadow-lg shadow-black/50"> <div v-if="activeTab === 'settings'" class="animate-in fade-in slide-in-from-right-4 duration-300 space-y-6">
<div class="flex justify-between items-center mb-2"> <div>
<span class="text-sm font-medium text-zinc-300">Current Tweet Heat</span> <h2 class="text-base font-medium text-white mb-1">Your Identity Profile</h2>
<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> <p class="text-xs text-zinc-400 mb-4">Set your background so AI can generate relevant, authentic replies matching your persona.</p>
</div>
<div class="flex items-end gap-2"> <div class="space-y-4">
<span class="text-3xl font-semibold tracking-tighter">87.5</span> <div class="space-y-1.5">
<span class="text-xs text-zinc-500 mb-1">/ 100</span> <label class="text-xs font-medium text-zinc-300">Identity Label</label>
</div> <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>
<!-- Action Button --> <div class="space-y-1.5">
<button <label class="text-xs font-medium text-zinc-300">Reply Language</label>
@click="triggerMockLoading" <select
:disabled="isLoading" v-model="language"
:class="cn( 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"
'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> <option value="auto">Auto (Match Tweet)</option>
{{ isLoading ? 'Generating Insights...' : 'Generate Replies' }} <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> </button>
</div>
<!-- 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>
</div> </div>
</template> </template>
<style>
/* Scoped overrides */
body {
margin: 0;
padding: 0;
width: 400px;
height: 600px;
}
</style>

View File

@@ -5,19 +5,56 @@
console.log('InsightReply Background Script Loaded'); 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) => { chrome.runtime.onMessage.addListener((message: { type: string; payload?: any }, _sender: chrome.runtime.MessageSender, sendResponse: (response?: any) => void) => {
if (message.type === 'SHOW_INSIGHT') { if (message.type === 'SHOW_INSIGHT') {
console.log('Received tweet data in background:', message.payload); 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') { if (message.type === 'GENERATE_REPLY') {
const { tweetContent, strategy, identity } = message.payload; const { tweetContent, strategy, identity } = message.payload;
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`, { fetch(`${API_BASE}/ai/generate`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ body: JSON.stringify({
tweet_content: tweetContent, tweet_content: tweetContent,
strategy: strategy, strategy: strategy,
@@ -26,13 +63,30 @@ chrome.runtime.onMessage.addListener((message: { type: string; payload?: any },
}) })
.then(resp => resp.json()) .then(resp => resp.json())
.then(data => { .then(data => {
sendResponse({ success: true, data: data.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 => { .catch(err => {
console.error('API Error:', err); console.error('API Error:', err);
sendResponse({ success: false, error: err.message }); sendResponse({ success: false, error: err.message });
}); });
});
return true; // Keep channel open for async response return true; // Keep channel open for async response
} }
return true; return true;

View File

@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, onMounted } from 'vue'
const props = defineProps<{ const props = defineProps<{
tweetData?: { tweetData?: {
id: string;
author: string; author: string;
content: string; content: string;
stats: { stats: {
@@ -15,40 +16,101 @@ const props = defineProps<{
const isVisible = ref(true) const isVisible = ref(true)
const selectedStrategy = ref('Insightful') const selectedStrategy = ref('Insightful')
const generatedReply = ref('') const generatedReplies = ref<Array<{strategy: string, content: string}>>([])
const isGenerating = ref(false) const isGenerating = ref(false)
const strategies = [ const defaultStrategies = [
{ id: 'Insightful', label: '认知升级型', icon: '🧠' }, { id: 'Insightful', label: '认知升级型', icon: '🧠' },
{ id: 'Humorous', label: '幽默风趣型', icon: '😄' }, { id: 'Humorous', label: '幽默风趣型', icon: '😄' },
{ id: 'Professional', label: '专业严谨型', icon: '⚖️' }, { id: 'Professional', label: '专业严谨型', icon: '⚖️' },
{ id: 'Supportive', 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 = () => { const generate = () => {
if (!props.tweetData) return if (!props.tweetData) return
isGenerating.value = true isGenerating.value = true
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({ chrome.runtime.sendMessage({
type: 'GENERATE_REPLY', type: 'GENERATE_REPLY',
payload: { payload: {
tweetContent: props.tweetData.content, tweetContent: props.tweetData!.content,
strategy: selectedStrategy.value, strategy: selectedStrategy.value,
identity: 'Independent Developer / Founder' // Could be dynamic later identity: finalIdentity
} }
}, (response) => { }, (response) => {
isGenerating.value = false isGenerating.value = false
if (response && response.success) { if (response && response.success) {
generatedReply.value = response.data.reply 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 { } else {
generatedReply.value = 'Failed to generate reply. Please check your connection or API key.' generatedReplies.value = [{ strategy: 'Error', content: response?.error || 'Failed to generate reply. Please check your connection or API key.' }]
} }
}) })
})
} }
const copyToClipboard = () => { const showProfileTip = ref(false)
navigator.clipboard.writeText(generatedReply.value)
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> </script>
@@ -98,20 +160,40 @@ const copyToClipboard = () => {
</div> </div>
<!-- Result Area --> <!-- 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"> <div class="flex justify-between items-center">
<span class="text-xs font-semibold text-zinc-500 uppercase tracking-widest">Assistant Suggestion</span> <span class="text-xs font-semibold text-zinc-500 uppercase tracking-widest">Assistant Suggestions</span>
<button @click="copyToClipboard" class="text-[10px] text-brand-primary hover:underline">Copy Result</button>
</div> </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>
<div class="text-sm leading-relaxed whitespace-pre-wrap text-[#E5E5E5]">
{{ reply.content }}
</div>
</div>
</div> </div>
</div> </div>
<!-- Footer Action --> <!-- 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 <button
@click="generate" @click="generate"
:disabled="isGenerating" :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. 提取推文内容的逻辑 // 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 => { const extractTweetData = (tweetElement: HTMLElement): TweetData | null => {
try { try {
// 根据 X 的 DOM 结构提取 (可能会随 Twitter 更新而变化)
const textElement = tweetElement.querySelector('[data-testid="tweetText"]'); const textElement = tweetElement.querySelector('[data-testid="tweetText"]');
const authorElement = tweetElement.querySelector('[data-testid="User-Name"]'); const authorElement = tweetElement.querySelector('[data-testid="User-Name"]');
const linkElement = tweetElement.querySelector('time')?.parentElement as HTMLAnchorElement; const linkElement = tweetElement.querySelector('time')?.parentElement as HTMLAnchorElement;
// 互动数据提取 if (!textElement || !authorElement || !linkElement) return null;
const getStat = (testid: string) => { const getStat = (testid: string) => {
const el = tweetElement.querySelector(`[data-testid="${testid}"]`); const el = tweetElement.querySelector(`[data-testid="${testid}"]`);
return el?.getAttribute('aria-label') || '0'; return el?.getAttribute('aria-label') || '0';
}; };
if (!textElement || !authorElement || !linkElement) return null;
const tweetId = linkElement.href.split('/').pop() || ''; const tweetId = linkElement.href.split('/').pop() || '';
return { 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”按钮 // 3. 注入“Insight”按钮
const injectInsightButton = (tweetElement: HTMLElement) => { const injectInsightButton = (tweetElement: HTMLElement) => {
// 查找操作栏 (Actions bar)
const actionBar = tweetElement.querySelector('[role="group"]'); const actionBar = tweetElement.querySelector('[role="group"]');
if (!actionBar || actionBar.querySelector('.insight-reply-btn')) return; if (!actionBar || actionBar.querySelector('.insight-reply-btn')) return;
// 创建按钮
const btnContainer = document.createElement('div'); const btnContainer = document.createElement('div');
btnContainer.className = 'insight-reply-btn'; btnContainer.className = 'insight-reply-btn';
btnContainer.style.display = 'flex'; btnContainer.style.display = 'flex';
@@ -65,7 +146,6 @@ const injectInsightButton = (tweetElement: HTMLElement) => {
btnContainer.style.marginLeft = '12px'; btnContainer.style.marginLeft = '12px';
btnContainer.style.cursor = 'pointer'; btnContainer.style.cursor = 'pointer';
// 按钮内部图标 (简易版)
btnContainer.innerHTML = ` 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'"> <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;"> <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) => { btnContainer.onclick = (e) => {
e.stopPropagation(); e.stopPropagation();
const data = extractTweetData(tweetElement); const data = extractTweetData(tweetElement);
console.log('Target Tweet Data:', data);
// 发送消息给插件侧边栏/Popup (后续实现)
if (data) { if (data) {
chrome.runtime.sendMessage({ type: 'SHOW_INSIGHT', payload: data }); chrome.runtime.sendMessage({ type: 'SHOW_INSIGHT', payload: data });
} }
@@ -92,7 +169,10 @@ const injectInsightButton = (tweetElement: HTMLElement) => {
const scanTweets = () => { const scanTweets = () => {
const tweets = document.querySelectorAll('article[data-testid="tweet"]'); const tweets = document.querySelectorAll('article[data-testid="tweet"]');
tweets.forEach((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')

49
server/.env.example Normal file
View File

@@ -0,0 +1,49 @@
# ====================================
# InsightReply 后端环境变量模板
# 复制此文件为 .env 并填入真实值
# cp .env.example .env
# ====================================
# ---- 数据库 ----
# DATABASE_URL=postgres://root:QG#7X*HHt3CqbZ@100.64.0.5:5432/InsightReply?sslmode=disable
DATABASE_URL=postgres://root:QG%237X%2AHHt3CqbZ@100.64.0.5:5432/InsightReply?sslmode=disable
# ---- LLM Configuration (Multi-Provider Support) ----
# Supported Providers: openai, anthropic, deepseek, gemini
LLM_PROVIDER=gemini
LLM_MODEL=gemini-2.5-flash
# Provider: OpenAI (or compatible: Groq, Ollama, vLLM)
OPENAI_API_KEY=sk-your-openai-api-key
OPENAI_BASE_URL=https://api.openai.com/v1 # 留空使用官方默认,可改写为代理地址
OPENAI_AVAILABLE_MODELS=gpt-4o,gpt-4o-mini,o1-mini # 前端下拉菜单展示的模型列表,逗号分隔
# Provider: Anthropic
ANTHROPIC_API_KEY=sk-ant-your-anthropic-api-key
ANTHROPIC_BASE_URL=
ANTHROPIC_AVAILABLE_MODELS=claude-3-5-sonnet-latest,claude-3-5-haiku-latest
# Provider: DeepSeek
DEEPSEEK_API_KEY=sk-your-deepseek-api-key
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_AVAILABLE_MODELS=deepseek-chat,deepseek-reasoner
# Provider: Gemini
GEMINI_API_KEY=AIzaSy-your-gemini-api-key
GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/models
GEMINI_AVAILABLE_MODELS=gemini-2.5-flash,gemini-2.5-pro
# ---- JWT Authentication ----
JWT_SECRET=your-random-32-char-secret-key-here
# ---- 服务器 ----
SERVER_PORT=8080
LOG_LEVEL=info
# ---- CORS ----
CORS_ORIGINS=chrome-extension://*
# ---- LLM 韧性参数 ----
LLM_TIMEOUT_SEC=30
LLM_MAX_RETRIES=2

View File

@@ -10,6 +10,9 @@ WORKDIR /app
COPY server_bin . COPY server_bin .
RUN chmod +x server_bin RUN chmod +x server_bin
# 拷贝数据库迁移文件 (服务启动时自动执行)
COPY migrations ./migrations
EXPOSE 8080 EXPOSE 8080
CMD ["./server_bin"] CMD ["./server_bin"]

BIN
server/bin/server Executable file

Binary file not shown.

View File

@@ -1,53 +1,214 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"io"
"log" "log"
"net/http" "net/http"
"os" "os"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" chiMiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/joho/godotenv"
"github.com/zs/InsightReply/internal/handler" "github.com/zs/InsightReply/internal/handler"
appMiddleware "github.com/zs/InsightReply/internal/middleware"
"github.com/zs/InsightReply/internal/repository" "github.com/zs/InsightReply/internal/repository"
"github.com/zs/InsightReply/internal/service" "github.com/zs/InsightReply/internal/service"
"github.com/zs/InsightReply/internal/worker"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/gorm" "gorm.io/gorm"
) )
func main() { func main() {
// Database connection (Using the provided string) // 1. Load environment variables
dsn := "postgres://root:QG%237X*HHt3CqbZ@100.64.0.5:5432/InsightReply?sslmode=disable" if err := godotenv.Load(); err != nil {
log.Println("No .env file found, relying on system environment variables")
}
// 1.5 Setup application file logging
if logPath := os.Getenv("LOG_FILE_PATH"); logPath != "" {
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err == nil {
multiWriter := io.MultiWriter(os.Stdout, logFile)
log.SetOutput(multiWriter)
log.Printf("Application logs are now being mirrored to: %s", logPath)
} else {
log.Printf("Failed to open log file %s: %v", logPath, err)
}
}
// 2. Database connection setup
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
log.Fatal("DATABASE_URL environment variable is required")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil { if err != nil {
log.Fatal("failed to connect database:", err) log.Fatal("failed to connect database:", err)
} }
fmt.Println("Database connection established") fmt.Println("Database connection established")
// Initialize Layers // 2.1 Run Database Migrations
log.Println("Running database migrations...")
m, err := migrate.New("file://migrations", dsn)
if err != nil {
log.Printf("Failed to initialize migrate, skipping: %v", err)
} else {
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
log.Printf("Failed to run migrate (maybe tables already exist), continuing: %v", err)
} else {
log.Println("Database migrations applied successfully")
}
}
// 3. Initialize Layers
userRepo := repository.NewUserRepository(db) userRepo := repository.NewUserRepository(db)
userSvc := service.NewUserService(userRepo) userSvc := service.NewUserService(userRepo)
userHandler := handler.NewUserHandler(userSvc) userHandler := handler.NewUserHandler(userSvc)
// AI Service (Using Env Var for API Key) profileRepo := repository.NewProductProfileRepository(db)
apiKey := os.Getenv("OPENAI_API_KEY") profileSvc := service.NewProductProfileService(profileRepo)
aiSvc := service.NewAIService(apiKey) profileHandler := handler.NewProductProfileHandler(profileSvc)
aiHandler := handler.NewAIHandler(aiSvc)
// Router setup strategyRepo := repository.NewCustomStrategyRepository(db)
strategySvc := service.NewCustomStrategyService(strategyRepo)
strategyHandler := handler.NewCustomStrategyHandler(strategySvc)
monitorRepo := repository.NewCompetitorMonitorRepository(db)
monitorSvc := service.NewCompetitorMonitorService(monitorRepo)
monitorHandler := handler.NewCompetitorMonitorHandler(monitorSvc)
// AI Service (Multi-LLM Routing Support)
aiSvc := service.NewAIService()
aiHandler := handler.NewAIHandler(aiSvc, profileSvc, strategySvc)
// Start Background Workers & Tweet Handling
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // Ensure cleanup on exit
tweetRepo := repository.NewTweetRepository(db)
replyRepo := repository.NewReplyRepository(db)
tweetHandler := handler.NewTweetHandler(tweetRepo) // Mount API handler
replyHandler := handler.NewReplyHandler(replyRepo)
monitorWorker := worker.NewMonitorWorker(monitorRepo, tweetRepo)
go monitorWorker.Start(ctx, 15*time.Minute)
performanceWorker := worker.NewPerformanceWorker(replyRepo, aiSvc)
go performanceWorker.Start(ctx, 30*time.Minute) // Runs every 30 minutes to check 24h+ old replies
// 4. Router setup
r := chi.NewRouter() r := chi.NewRouter()
r.Use(middleware.Logger) r.Use(chiMiddleware.Logger)
r.Use(middleware.Recoverer) r.Use(chiMiddleware.Recoverer)
r.Route("/api/v1", func(r chi.Router) { // CORS Configuration
r.Post("/users/register", userHandler.Register) corsOrigins := os.Getenv("CORS_ORIGINS")
r.Get("/ai/test", aiHandler.Test) if corsOrigins == "" {
r.Post("/ai/generate", aiHandler.Generate) corsOrigins = "*" // default fallback
}
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{corsOrigins},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300,
}))
// 5. Routes
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"code":200,"message":"ok","data":{"status":"healthy","version":"1.0.0"}}`))
}) })
fmt.Println("Server starting on :8080") r.Route("/api/v1", func(r chi.Router) {
if err := http.ListenAndServe(":8080", r); err != nil { // Public routes
log.Fatal(err) r.Post("/users/register", userHandler.Register)
// Protected routes
r.Group(func(r chi.Router) {
r.Use(appMiddleware.JWTAuth)
r.Use(appMiddleware.RateLimit(db))
// User and Profile APIs
r.Get("/users/me", userHandler.GetProfile)
r.Put("/users/me/preferences", userHandler.UpdatePreferences)
r.Get("/users/me/product_profiles", profileHandler.GetProfile)
r.Put("/users/me/product_profiles", profileHandler.SaveProfile)
// Strategy APIs
r.Get("/users/me/strategies", strategyHandler.ListStrategies)
r.Post("/users/me/strategies", strategyHandler.CreateStrategy)
r.Delete("/users/me/strategies/{id}", strategyHandler.DeleteStrategy)
// Monitor APIs
r.Get("/monitors/competitors", monitorHandler.ListMonitors)
r.Post("/monitors/competitors", monitorHandler.CreateMonitor)
r.Delete("/monitors/competitors/{id}", monitorHandler.DeleteMonitor)
// Hot Opportunity Tweers API
r.Get("/tweets/hot", tweetHandler.GetHotTweets)
r.Get("/tweets/search", tweetHandler.GetSearchTweets)
// AI APIs
r.Get("/ai/test", aiHandler.Test)
r.Post("/ai/generate", aiHandler.Generate)
r.Post("/replies/record", replyHandler.RecordReply)
r.Get("/replies", replyHandler.GetReplies)
})
})
// 6. Graceful Shutdown Setup
port := os.Getenv("SERVER_PORT")
if port == "" {
port = "8080"
} }
srv := &http.Server{
Addr: ":" + port,
Handler: r,
}
serverErrors := make(chan error, 1)
// Start the server
go func() {
fmt.Printf("Server starting on :%s\n", port)
serverErrors <- srv.ListenAndServe()
}()
// Channel to listen for an interrupt or terminate signal from the OS.
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)
// Blocking main and waiting for shutdown.
select {
case err := <-serverErrors:
log.Fatalf("Error starting server: %v", err)
case sig := <-shutdown:
log.Printf("Start shutdown... signal: %v", sig)
// Create context with timeout for shutdown
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("Graceful shutdown did not complete in time: %v", err)
if err := srv.Close(); err != nil {
log.Fatalf("Could not stop server gracefully: %v", err)
}
}
}
log.Println("Server stopped")
} }

View File

@@ -6,10 +6,14 @@ services:
container_name: insight-reply-server container_name: insight-reply-server
restart: always restart: always
ports: ports:
- "8080:8080" - "8009:8080"
env_file:
- .env
environment: environment:
# 这里可以读取宿主机的环境变量或 .env 文件内容 - LOG_FILE_PATH=/app/logs/InsightReply.log
- OPENAI_API_KEY=${OPENAI_API_KEY} volumes:
# Map the host's /root/logs to the container's /app/logs directory
- /root/logs:/app/logs
networks: networks:
- insight_network - insight_network

View File

@@ -1,9 +1,14 @@
module github.com/zs/InsightReply module github.com/zs/InsightReply
go 1.24.0 go 1.25.0
require ( require (
github.com/PuerkitoBio/goquery v1.11.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-chi/cors v1.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang-migrate/migrate/v4 v4.19.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -11,10 +16,16 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/sashabaranov/go-openai v1.41.2 // indirect github.com/sashabaranov/go-openai v1.41.2 // indirect
github.com/sony/gobreaker/v2 v2.4.0 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.14.0 // indirect
gorm.io/driver/postgres v1.6.0 // indirect gorm.io/driver/postgres v1.6.0 // indirect
gorm.io/gorm v1.31.1 // indirect gorm.io/gorm v1.31.1 // indirect
) )

View File

@@ -1,6 +1,17 @@
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -15,18 +26,94 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM= github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM=
github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/sony/gobreaker/v2 v2.4.0 h1:g2KJRW1Ubty3+ZOcSEUN7K+REQJdN6yo6XvaML+jptg=
github.com/sony/gobreaker/v2 v2.4.0/go.mod h1:pTyFJgcZ3h2tdQVLZZruK2C0eoFL1fb/G83wK1ZQl+s=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=

View File

@@ -1,22 +1,35 @@
package handler package handler
import ( import (
"encoding/json"
"net/http" "net/http"
"os" "strings"
"github.com/zs/InsightReply/internal/service" "github.com/zs/InsightReply/internal/service"
) )
type AIHandler struct { type AIHandler struct {
svc *service.AIService svc *service.AIService
profileSvc *service.ProductProfileService
strategySvc *service.CustomStrategyService
} }
func NewAIHandler(svc *service.AIService) *AIHandler { func NewAIHandler(svc *service.AIService, profileSvc *service.ProductProfileService, strategySvc *service.CustomStrategyService) *AIHandler {
return &AIHandler{svc: svc} return &AIHandler{
svc: svc,
profileSvc: profileSvc,
strategySvc: strategySvc,
}
} }
func (h *AIHandler) Test(w http.ResponseWriter, r *http.Request) { func (h *AIHandler) Test(w http.ResponseWriter, r *http.Request) {
// ... ctx := r.Context()
msg, err := h.svc.TestConnection(ctx)
if err != nil {
SendError(w, http.StatusInternalServerError, 5000, err.Error())
return
}
SendSuccess(w, map[string]string{"status": msg})
} }
func (h *AIHandler) Generate(w http.ResponseWriter, r *http.Request) { func (h *AIHandler) Generate(w http.ResponseWriter, r *http.Request) {
@@ -24,6 +37,8 @@ func (h *AIHandler) Generate(w http.ResponseWriter, r *http.Request) {
TweetContent string `json:"tweet_content"` TweetContent string `json:"tweet_content"`
Strategy string `json:"strategy"` Strategy string `json:"strategy"`
Identity string `json:"identity"` Identity string `json:"identity"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
@@ -37,13 +52,56 @@ func (h *AIHandler) Generate(w http.ResponseWriter, r *http.Request) {
} }
ctx := r.Context() ctx := r.Context()
reply, err := h.svc.GenerateReply(ctx, body.TweetContent, body.Strategy, body.Identity) userID := ctx.Value("userID").(string)
// Fetch Product Profile Context
var productContext string
if profile, err := h.profileSvc.GetProfile(userID); err == nil && profile.IsActive {
productContext = "Product Context: " + profile.ProductName
if profile.Tagline != "" {
productContext += " - " + profile.Tagline
}
if profile.KeyFeatures != "" && profile.KeyFeatures != "[]" {
productContext += ". Key Features: " + profile.KeyFeatures
}
if profile.CustomContext != "" {
productContext += ". Context: " + profile.CustomContext
}
}
// Fetch Custom Strategies Context
if strategies, err := h.strategySvc.ListStrategies(userID); err == nil && len(strategies) > 0 {
productContext += "\n\nAvailable User Custom Strategies:\n"
for _, s := range strategies {
productContext += "- " + s.StrategyKey + " (" + s.Label + "): " + s.Description + "\n"
}
}
replyString, err := h.svc.GenerateReply(ctx, body.TweetContent, productContext, body.Identity, body.Provider, body.Model)
if err != nil { if err != nil {
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
} }
SendSuccess(w, map[string]string{ // Clean up potential markdown wrappers from LLM output
"reply": reply, cleanReply := strings.TrimSpace(replyString)
cleanReply = strings.TrimPrefix(cleanReply, "```json")
cleanReply = strings.TrimPrefix(cleanReply, "```")
cleanReply = strings.TrimSuffix(cleanReply, "```")
cleanReply = strings.TrimSpace(cleanReply)
var replies []map[string]interface{}
if err := json.Unmarshal([]byte(cleanReply), &replies); err != nil {
// Fallback: return as single string object if parsing totally fails
replies = []map[string]interface{}{
{
"strategy": "Fallback",
"content": replyString,
},
}
}
SendSuccess(w, map[string]interface{}{
"replies": replies,
}) })
} }

View File

@@ -0,0 +1,67 @@
package handler
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/zs/InsightReply/internal/model"
"github.com/zs/InsightReply/internal/service"
)
type CompetitorMonitorHandler struct {
svc *service.CompetitorMonitorService
}
func NewCompetitorMonitorHandler(svc *service.CompetitorMonitorService) *CompetitorMonitorHandler {
return &CompetitorMonitorHandler{svc: svc}
}
func (h *CompetitorMonitorHandler) ListMonitors(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("userID").(string)
monitors, err := h.svc.ListMonitors(userID)
if err != nil {
SendError(w, http.StatusInternalServerError, 5001, "Failed to list monitors")
return
}
SendSuccess(w, monitors)
}
func (h *CompetitorMonitorHandler) CreateMonitor(w http.ResponseWriter, r *http.Request) {
userIDStr := r.Context().Value("userID").(string)
userID, err := uuid.Parse(userIDStr)
if err != nil {
SendError(w, http.StatusUnauthorized, 4010, "Invalid user ID")
return
}
var monitor model.CompetitorMonitor
if err := json.NewDecoder(r.Body).Decode(&monitor); err != nil {
SendError(w, http.StatusBadRequest, 4001, "Invalid request body")
return
}
monitor.UserID = userID
if err := h.svc.CreateMonitor(&monitor); err != nil {
SendError(w, http.StatusInternalServerError, 5001, "Failed to create monitor")
return
}
SendSuccess(w, monitor)
}
func (h *CompetitorMonitorHandler) DeleteMonitor(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("userID").(string)
monitorID := chi.URLParam(r, "id")
if err := h.svc.DeleteMonitor(monitorID, userID); err != nil {
SendError(w, http.StatusInternalServerError, 5001, "Failed to delete monitor")
return
}
SendSuccess(w, map[string]string{"status": "deleted"})
}

View File

@@ -0,0 +1,67 @@
package handler
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/zs/InsightReply/internal/model"
"github.com/zs/InsightReply/internal/service"
)
type CustomStrategyHandler struct {
svc *service.CustomStrategyService
}
func NewCustomStrategyHandler(svc *service.CustomStrategyService) *CustomStrategyHandler {
return &CustomStrategyHandler{svc: svc}
}
func (h *CustomStrategyHandler) ListStrategies(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("userID").(string)
strategies, err := h.svc.ListStrategies(userID)
if err != nil {
SendError(w, http.StatusInternalServerError, 5001, "Failed to list strategies")
return
}
SendSuccess(w, strategies)
}
func (h *CustomStrategyHandler) CreateStrategy(w http.ResponseWriter, r *http.Request) {
userIDStr := r.Context().Value("userID").(string)
userID, err := uuid.Parse(userIDStr)
if err != nil {
SendError(w, http.StatusUnauthorized, 4010, "Invalid user ID")
return
}
var strategy model.UserCustomStrategy
if err := json.NewDecoder(r.Body).Decode(&strategy); err != nil {
SendError(w, http.StatusBadRequest, 4001, "Invalid request body")
return
}
strategy.UserID = userID
if err := h.svc.CreateStrategy(&strategy); err != nil {
SendError(w, http.StatusInternalServerError, 5001, "Failed to create strategy")
return
}
SendSuccess(w, strategy)
}
func (h *CustomStrategyHandler) DeleteStrategy(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("userID").(string)
strategyID := chi.URLParam(r, "id")
if err := h.svc.DeleteStrategy(strategyID, userID); err != nil {
SendError(w, http.StatusInternalServerError, 5001, "Failed to delete strategy")
return
}
SendSuccess(w, map[string]string{"status": "deleted"})
}

View File

@@ -0,0 +1,54 @@
package handler
import (
"encoding/json"
"net/http"
"github.com/google/uuid"
"github.com/zs/InsightReply/internal/model"
"github.com/zs/InsightReply/internal/service"
)
type ProductProfileHandler struct {
svc *service.ProductProfileService
}
func NewProductProfileHandler(svc *service.ProductProfileService) *ProductProfileHandler {
return &ProductProfileHandler{svc: svc}
}
func (h *ProductProfileHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("userID").(string)
profile, err := h.svc.GetProfile(userID)
if err != nil {
SendError(w, http.StatusNotFound, 4004, "Product profile not found")
return
}
SendSuccess(w, profile)
}
func (h *ProductProfileHandler) SaveProfile(w http.ResponseWriter, r *http.Request) {
userIDStr := r.Context().Value("userID").(string)
userID, err := uuid.Parse(userIDStr)
if err != nil {
SendError(w, http.StatusUnauthorized, 4010, "Invalid user ID")
return
}
var profile model.UserProductProfile
if err := json.NewDecoder(r.Body).Decode(&profile); err != nil {
SendError(w, http.StatusBadRequest, 4001, "Invalid request body")
return
}
profile.UserID = userID // Ensure user cannot overwrite other's profile
if err := h.svc.SaveProfile(&profile); err != nil {
SendError(w, http.StatusInternalServerError, 5001, "Failed to save product profile")
return
}
SendSuccess(w, profile)
}

View File

@@ -0,0 +1,97 @@
package handler
import (
"encoding/json"
"log"
"net/http"
"github.com/google/uuid"
"github.com/zs/InsightReply/internal/model"
"github.com/zs/InsightReply/internal/repository"
)
type ReplyHandler struct {
repo *repository.ReplyRepository
}
func NewReplyHandler(repo *repository.ReplyRepository) *ReplyHandler {
return &ReplyHandler{repo: repo}
}
func (h *ReplyHandler) RecordReply(w http.ResponseWriter, r *http.Request) {
var body struct {
TweetID string `json:"tweet_id"`
StrategyType string `json:"strategy_type"`
Content string `json:"content"`
Language string `json:"language"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
SendError(w, http.StatusBadRequest, 4001, "Invalid request body")
return
}
ctx := r.Context()
userID, ok := ctx.Value("userID").(string)
if !ok || userID == "" {
SendError(w, http.StatusUnauthorized, 4002, "Unauthorized")
return
}
userUUID, err := uuid.Parse(userID)
if err != nil {
SendError(w, http.StatusBadRequest, 4003, "Invalid user ID format")
return
}
// Resolve the raw string X_Tweet_ID into our internal UUID
// Create a dummy tweet entry via Upsert if it doesn't exist yet so foreign keys don't panic
tweet := &model.Tweet{
XTweetID: body.TweetID,
Content: body.Content, // Temporarily store AI content as a placeholder if original is missing
IsProcessed: false,
}
err = h.repo.UpsertDummyTweet(tweet)
if err != nil {
SendError(w, http.StatusInternalServerError, 5001, "Failed to resolve tweet reference")
return
}
reply := &model.GeneratedReply{
UserID: userUUID,
TweetID: tweet.ID,
StrategyType: body.StrategyType,
Content: body.Content,
Status: "copied",
Language: body.Language,
}
if err := h.repo.CreateGeneratedReply(reply); err != nil {
SendError(w, http.StatusInternalServerError, 5002, "Failed to log generated reply")
return
}
SendSuccess(w, map[string]string{ "message": "Reply recorded successfully",
})
}
// GetReplies handles GET /api/v1/replies
func (h *ReplyHandler) GetReplies(w http.ResponseWriter, r *http.Request) {
userIDStr := r.Header.Get("X-User-ID")
userID, err := uuid.Parse(userIDStr)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
replies, err := h.repo.GetGeneratedRepliesByUser(userID)
if err != nil {
log.Printf("Failed to get replies: %v", err)
http.Error(w, "Failed to get replies", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(replies)
}

View File

@@ -0,0 +1,48 @@
package handler
import (
"encoding/json"
"net/http"
"github.com/zs/InsightReply/internal/repository"
)
type TweetHandler struct {
repo *repository.TweetRepository
}
func NewTweetHandler(repo *repository.TweetRepository) *TweetHandler {
return &TweetHandler{repo: repo}
}
// GetHotTweets returns the top heating tweets spanning across all tracking targets
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
tweets, err := h.repo.GetTopHeatingTweets(50)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]interface{}{"error": "failed to retrieve hot tweets"})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tweets)
}
// SearchTweets provides the multi-rule filtering API for Epic 5
func (h *TweetHandler) GetSearchTweets(w http.ResponseWriter, r *http.Request) {
keyword := r.URL.Query().Get("keyword")
handle := r.URL.Query().Get("handle")
tweets, err := h.repo.SearchTweets(keyword, handle, 50)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]interface{}{"error": "failed to search tweets"})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tweets)
}

View File

@@ -34,3 +34,38 @@ func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
SendSuccess(w, user) SendSuccess(w, user)
} }
func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
// Assumes JWTAuth middleware has placed userID in context
userID := r.Context().Value("userID").(string)
user, err := h.svc.GetUserByID(userID)
if err != nil {
SendError(w, http.StatusNotFound, 4004, "User not found")
return
}
SendSuccess(w, user)
}
func (h *UserHandler) UpdatePreferences(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("userID").(string)
var body struct {
IdentityLabel string `json:"identity_label"`
LanguagePreference string `json:"language_preference"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
SendError(w, http.StatusBadRequest, 4001, "Invalid request body")
return
}
user, err := h.svc.UpdatePreferences(userID, body.IdentityLabel, body.LanguagePreference)
if err != nil {
SendError(w, http.StatusInternalServerError, 5001, "Failed to update preferences")
return
}
SendSuccess(w, user)
}

View File

@@ -0,0 +1,65 @@
package middleware
import (
"context"
"net/http"
"os"
"strings"
"github.com/golang-jwt/jwt/v5"
)
type contextKey string
const UserIDKey contextKey = "userID"
func JWTAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, `{"code":401, "message":"Unauthorized: missing token"}`, http.StatusUnauthorized)
return
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
http.Error(w, `{"code":401, "message":"Unauthorized: invalid token format"}`, http.StatusUnauthorized)
return
}
tokenString := parts[1]
secret := os.Getenv("JWT_SECRET")
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(secret), nil
})
if err != nil || !token.Valid {
http.Error(w, `{"code":401, "message":"Unauthorized: invalid or expired token"}`, http.StatusUnauthorized)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http.Error(w, `{"code":401, "message":"Unauthorized: invalid token claims"}`, http.StatusUnauthorized)
return
}
userID, ok := claims["sub"].(string)
if !ok || userID == "" {
// fallback check inside user_id or id if sub doesn't exist
userID, _ = claims["user_id"].(string)
}
if userID == "" {
http.Error(w, `{"code":401, "message":"Unauthorized: user ID not found in token"}`, http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), UserIDKey, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -0,0 +1,82 @@
package middleware
import (
"net/http"
"sync"
"time"
"golang.org/x/time/rate"
"gorm.io/gorm"
)
var (
limiters = make(map[string]*rate.Limiter)
limiterMux sync.RWMutex
)
// getLimiter retrieves or creates a rate limiter for a specific user.
// Uses a simple token bucket. For strict "10 per day" with distributed persistence,
// this should be refactored to use Redis or DB API usage counters.
func getLimiter(userID string, tier string) *rate.Limiter {
limiterMux.Lock()
defer limiterMux.Unlock()
if limiter, exists := limiters[userID]; exists {
return limiter
}
var limiter *rate.Limiter
if tier == "Pro" || tier == "Premium" {
// Unlimited (e.g., 20 requests per second burst)
limiter = rate.NewLimiter(rate.Limit(20), 100)
} else {
// Free: 10 per day -> replenishes 1 token every 2.4 hours, bucket size 10
limiter = rate.NewLimiter(rate.Every(24*time.Hour/10), 10)
}
limiters[userID] = limiter
return limiter
}
// RateLimit middleware enforces rate limits based on user tier.
// It expects JWTAuth to have already populated UserIDKey in the context.
func RateLimit(db *gorm.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userIDVal := r.Context().Value(UserIDKey)
if userIDVal == nil {
// Allow if not authenticated strictly, or rate limit by IP
// For now, fallback to generic tight limit for anonymous usage
ipLimiter := getLimiter(r.RemoteAddr, "Free")
if !ipLimiter.Allow() {
http.Error(w, `{"code":429, "message":"Too Many Requests: Rate limit exceeded"}`, http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
return
}
userID := userIDVal.(string)
// Fast DB query to get user tier (ideally cached in Redis in prod)
var tier string
// Look up active subscription for this user
err := db.Table("subscriptions").
Select("tier").
Where("user_id = ? AND status = 'active'", userID).
Scan(&tier).Error
if err != nil || tier == "" {
tier = "Free" // defaults to Free if no active sub
}
limiter := getLimiter(userID, tier)
if !limiter.Allow() {
http.Error(w, `{"code":429, "message":"Too Many Requests: Daily quota or rate limit exceeded"}`, http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,16 @@
package model
import (
"time"
"github.com/google/uuid"
)
type CompetitorMonitor struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
UserID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_user_competitor" json:"user_id"`
BrandName string `gorm:"type:varchar(255);not null;uniqueIndex:idx_user_competitor" json:"brand_name"`
XHandle string `gorm:"type:varchar(255)" json:"x_handle"`
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `json:"created_at"`
}

View File

@@ -0,0 +1,18 @@
package model
import (
"time"
"github.com/google/uuid"
)
type GeneratedReply struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_generated_replies_user_id" json:"user_id"`
TweetID uuid.UUID `gorm:"type:uuid;not null;index:idx_generated_replies_tweet_id" json:"tweet_id"`
StrategyType string `gorm:"type:varchar(100);not null" json:"strategy_type"`
Content string `gorm:"type:text;not null" json:"content"`
Status string `gorm:"type:varchar(50);default:'draft'" json:"status"` // draft, copied, posted
Language string `gorm:"type:varchar(10);default:'en'" json:"language"`
CreatedAt time.Time `json:"created_at"`
}

View File

@@ -0,0 +1,17 @@
package model
import (
"time"
"github.com/google/uuid"
)
type ReplyPerformance struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
ReplyID uuid.UUID `gorm:"type:uuid;not null;index:idx_reply_performance_reply_id" json:"reply_id"`
UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_reply_performance_user_id" json:"user_id"`
LikeCountIncrease int `gorm:"default:0" json:"like_count_increase"`
ReplyCountIncrease int `gorm:"default:0" json:"reply_count_increase"`
InteractionRate float64 `gorm:"default:0.0" json:"interaction_rate"`
CheckTime time.Time `json:"check_time"`
}

View File

@@ -0,0 +1,24 @@
package model
import (
"time"
"github.com/google/uuid"
)
type Tweet struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
XTweetID string `gorm:"type:varchar(255);uniqueIndex:idx_tweets_x_tweet_id;not null" json:"x_tweet_id"`
AuthorID string `gorm:"type:varchar(255)" json:"author_id"`
AuthorHandle string `gorm:"type:varchar(255)" json:"author_handle"`
Content string `gorm:"type:text;not null" json:"content"`
PostedAt time.Time `json:"posted_at"`
LikeCount int `gorm:"default:0" json:"like_count"`
RetweetCount int `gorm:"default:0" json:"retweet_count"`
ReplyCount int `gorm:"default:0" json:"reply_count"`
HeatScore float64 `gorm:"default:0.0;index:idx_tweets_heat_score" json:"heat_score"`
CrawlQueue string `gorm:"type:varchar(20);default:'normal';index:idx_tweets_crawl_queue" json:"crawl_queue"`
IsProcessed bool `gorm:"default:false" json:"is_processed"`
LastCrawledAt time.Time `gorm:"index:idx_tweets_crawl_queue" json:"last_crawled_at"`
CreatedAt time.Time `json:"created_at"`
}

View File

@@ -12,6 +12,7 @@ type User struct {
PasswordHash string `json:"-"` PasswordHash string `json:"-"`
SubscriptionTier string `gorm:"default:'Free'" json:"subscription_tier"` SubscriptionTier string `gorm:"default:'Free'" json:"subscription_tier"`
IdentityLabel string `json:"identity_label"` IdentityLabel string `json:"identity_label"`
LanguagePreference string `gorm:"default:'auto'" json:"language_preference"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }

View File

@@ -0,0 +1,21 @@
package model
import (
"time"
"github.com/google/uuid"
)
type UserCustomStrategy struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
UserID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_user_strategy_key" json:"user_id"`
StrategyKey string `gorm:"type:varchar(100);not null;uniqueIndex:idx_user_strategy_key" json:"strategy_key"`
Label string `gorm:"type:varchar(255);not null" json:"label"`
Icon string `gorm:"type:varchar(10)" json:"icon"`
Description string `gorm:"type:text" json:"description"`
PromptTemplate string `gorm:"type:text" json:"prompt_template"`
FewShotExamples string `gorm:"type:jsonb;default:'[]'" json:"few_shot_examples"` // Stored as JSON string
IsActive bool `gorm:"default:true" json:"is_active"`
SortOrder int `gorm:"default:0" json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
}

View File

@@ -0,0 +1,26 @@
package model
import (
"time"
"github.com/google/uuid"
)
type UserProductProfile struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
UserID uuid.UUID `gorm:"type:uuid;unique;not null" json:"user_id"`
ProductName string `gorm:"type:varchar(255)" json:"product_name"`
Tagline string `gorm:"type:text" json:"tagline"`
Domain string `gorm:"type:varchar(255)" json:"domain"`
KeyFeatures string `gorm:"type:jsonb;default:'[]'" json:"key_features"` // Stored as JSON string
TargetUsers string `gorm:"type:text" json:"target_users"`
ProductUrl string `gorm:"type:varchar(500)" json:"product_url"`
Competitors string `gorm:"type:jsonb;default:'[]'" json:"competitors"` // Stored as JSON string
RelevanceKeywords string `gorm:"type:jsonb;default:'[]'" json:"relevance_keywords"` // Stored as JSON string
CustomContext string `gorm:"type:text" json:"custom_context"`
DefaultLLMProvider string `gorm:"type:varchar(50)" json:"default_llm_provider"` // User preferred LLM provider
DefaultLLMModel string `gorm:"type:varchar(100)" json:"default_llm_model"` // User preferred LLM model
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@@ -0,0 +1,34 @@
package repository
import (
"github.com/zs/InsightReply/internal/model"
"gorm.io/gorm"
)
type CompetitorMonitorRepository struct {
db *gorm.DB
}
func NewCompetitorMonitorRepository(db *gorm.DB) *CompetitorMonitorRepository {
return &CompetitorMonitorRepository{db: db}
}
func (r *CompetitorMonitorRepository) ListByUserID(userID string) ([]model.CompetitorMonitor, error) {
var monitors []model.CompetitorMonitor
err := r.db.Where("user_id = ? AND is_active = ?", userID, true).Order("created_at desc").Find(&monitors).Error
return monitors, err
}
func (r *CompetitorMonitorRepository) ListAllActive() ([]model.CompetitorMonitor, error) {
var monitors []model.CompetitorMonitor
err := r.db.Where("is_active = ?", true).Find(&monitors).Error
return monitors, err
}
func (r *CompetitorMonitorRepository) Create(monitor *model.CompetitorMonitor) error {
return r.db.Create(monitor).Error
}
func (r *CompetitorMonitorRepository) Delete(id string, userID string) error {
return r.db.Where("id = ? AND user_id = ?", id, userID).Delete(&model.CompetitorMonitor{}).Error
}

View File

@@ -0,0 +1,38 @@
package repository
import (
"github.com/zs/InsightReply/internal/model"
"gorm.io/gorm"
)
type CustomStrategyRepository struct {
db *gorm.DB
}
func NewCustomStrategyRepository(db *gorm.DB) *CustomStrategyRepository {
return &CustomStrategyRepository{db: db}
}
func (r *CustomStrategyRepository) ListByUserID(userID string) ([]model.UserCustomStrategy, error) {
var strategies []model.UserCustomStrategy
err := r.db.Where("user_id = ? AND is_active = ?", userID, true).Order("sort_order asc, created_at desc").Find(&strategies).Error
return strategies, err
}
func (r *CustomStrategyRepository) Create(strategy *model.UserCustomStrategy) error {
return r.db.Create(strategy).Error
}
func (r *CustomStrategyRepository) Update(strategy *model.UserCustomStrategy) error {
return r.db.Save(strategy).Error
}
func (r *CustomStrategyRepository) Delete(id string, userID string) error {
return r.db.Where("id = ? AND user_id = ?", id, userID).Delete(&model.UserCustomStrategy{}).Error
}
func (r *CustomStrategyRepository) GetByIDAndUser(id string, userID string) (*model.UserCustomStrategy, error) {
var strategy model.UserCustomStrategy
err := r.db.Where("id = ? AND user_id = ?", id, userID).First(&strategy).Error
return &strategy, err
}

View File

@@ -0,0 +1,25 @@
package repository
import (
"github.com/zs/InsightReply/internal/model"
"gorm.io/gorm"
)
type ProductProfileRepository struct {
db *gorm.DB
}
func NewProductProfileRepository(db *gorm.DB) *ProductProfileRepository {
return &ProductProfileRepository{db: db}
}
func (r *ProductProfileRepository) GetByUserID(userID string) (*model.UserProductProfile, error) {
var profile model.UserProductProfile
err := r.db.Where("user_id = ?", userID).First(&profile).Error
return &profile, err
}
func (r *ProductProfileRepository) Save(profile *model.UserProductProfile) error {
// Use Save to either create or update based on primary key
return r.db.Save(profile).Error
}

View File

@@ -0,0 +1,74 @@
package repository
import (
"github.com/google/uuid"
"github.com/zs/InsightReply/internal/model"
"gorm.io/gorm"
)
type ReplyRepository struct {
db *gorm.DB
}
func NewReplyRepository(db *gorm.DB) *ReplyRepository {
return &ReplyRepository{db: db}
}
// CreateGeneratedReply logs an AI generated response when it is copied/used by the user
func (r *ReplyRepository) CreateGeneratedReply(reply *model.GeneratedReply) error {
return r.db.Create(reply).Error
}
// GetPendingPerformanceChecks returns copied replies that need their performance checked (e.g. older than 24h)
func (r *ReplyRepository) GetPendingPerformanceChecks() ([]model.GeneratedReply, error) {
var replies []model.GeneratedReply
// Complex: Fetch replies that are "copied", created more than 24 hours ago,
// and DO NOT already have a corresponding entry in reply_performance.
err := r.db.Table("generated_replies").
Select("generated_replies.*").
Joins("LEFT JOIN reply_performance rp ON rp.reply_id = generated_replies.id").
Where("generated_replies.status = ?", "copied").
Where("generated_replies.created_at < NOW() - INTERVAL '1 day'").
Where("rp.id IS NULL").
Find(&replies).Error
return replies, err
}
// SaveReplyPerformance persists the checked engagement scores of a generated reply
func (r *ReplyRepository) SaveReplyPerformance(perf *model.ReplyPerformance) error {
return r.db.Create(perf).Error
}
// UpsertDummyTweet acts as a safety hook to guarantee foreign key integrity exists before recording a reply onto an un-crawled Tweet.
func (r *ReplyRepository) UpsertDummyTweet(tweet *model.Tweet) error {
return r.db.Where("x_tweet_id = ?", tweet.XTweetID).FirstOrCreate(tweet).Error
}
// GetTweetXTweetID returns the string identifier string X uses, converting backwards from the postgres UUID
func (r *ReplyRepository) GetTweetXTweetID(tweetID uuid.UUID) (string, error) {
var tweet model.Tweet
err := r.db.Where("id = ?", tweetID).First(&tweet).Error
return tweet.XTweetID, err
}
// SaveStyleExtraction commits an AI-learned writing style profile against the user for future inference injection
func (r *ReplyRepository) SaveStyleExtraction(userID uuid.UUID, styleDesc string) error {
// user_style_profiles might not exist yet; use raw SQL or Gorm Upsert
return r.db.Exec(`
INSERT INTO user_style_profiles (user_id, tone_preference)
VALUES (?, ?)
ON CONFLICT (user_id)
DO UPDATE SET tone_preference = EXCLUDED.tone_preference, updated_at = NOW()
`, userID, styleDesc).Error
}
// GetGeneratedRepliesByUser retrieves all AI replies for a user to display in the History dashboard
func (r *ReplyRepository) GetGeneratedRepliesByUser(userID uuid.UUID) ([]model.GeneratedReply, error) {
var replies []model.GeneratedReply
// Preload the performance data if it exists. Preloading "Performance" requires GORM association.
// We'll just fetch replies and order by newest first.
err := r.db.Where("user_id = ?", userID).Order("created_at desc").Limit(100).Find(&replies).Error
return replies, err
}

View File

@@ -0,0 +1,82 @@
package repository
import (
"github.com/zs/InsightReply/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type TweetRepository struct {
db *gorm.DB
}
func NewTweetRepository(db *gorm.DB) *TweetRepository {
return &TweetRepository{db: db}
}
// Upsert intelligently inserts a new tweet or updates an existing one.
// Crucially, on conflict, it dynamically calculates the 'heat_score' by
// comparing the new metrics against the old metrics currently in the database.
func (r *TweetRepository) Upsert(tweet *model.Tweet) error {
// For new tweets being inserted, their base heat score evaluates to their current absolute metrics.
// For existing tweets, we calculate the delta and add it to their existing heat score.
tweet.HeatScore = float64(tweet.LikeCount*1 + tweet.RetweetCount*2 + tweet.ReplyCount*3)
err := r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "x_tweet_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"author_id": clause.Column{Table: "excluded", Name: "author_id"},
"author_handle": clause.Column{Table: "excluded", Name: "author_handle"},
"content": clause.Column{Table: "excluded", Name: "content"},
"posted_at": clause.Column{Table: "excluded", Name: "posted_at"},
"last_crawled_at": clause.Column{Table: "excluded", Name: "last_crawled_at"},
"like_count": clause.Column{Table: "excluded", Name: "like_count"},
"retweet_count": clause.Column{Table: "excluded", Name: "retweet_count"},
"reply_count": clause.Column{Table: "excluded", Name: "reply_count"},
// Calculate delta only if the old values exist and are lower than the new values (to prevent negative spikes from X UI glitches).
// heatTracker = old.heat_score + MAX(0, new.like - old.like)*1 + MAX(0, new.rt - old.rt)*2 + MAX(0, new.reply - old.reply)*3
"heat_score": gorm.Expr("tweets.heat_score + GREATEST(0, EXCLUDED.like_count - tweets.like_count) * 1.0 + GREATEST(0, EXCLUDED.retweet_count - tweets.retweet_count) * 2.0 + GREATEST(0, EXCLUDED.reply_count - tweets.reply_count) * 3.0"),
// Smart Crawling logic: If heat score breaches threshold (e.g. 50), promote to high. If old & cold, demote.
"crawl_queue": gorm.Expr(`
CASE
WHEN tweets.heat_score + GREATEST(0, EXCLUDED.like_count - tweets.like_count) * 1.0 + GREATEST(0, EXCLUDED.retweet_count - tweets.retweet_count) * 2.0 + GREATEST(0, EXCLUDED.reply_count - tweets.reply_count) * 3.0 > 50 THEN 'high'
WHEN EXCLUDED.last_crawled_at - tweets.posted_at > INTERVAL '7 days' THEN 'low'
ELSE 'normal'
END
`),
}),
}).Create(tweet).Error
return err
}
// GetTopHeatingTweets returns unprocessed tweets ordered by their generated heat score
func (r *TweetRepository) GetTopHeatingTweets(limit int) ([]model.Tweet, error) {
var tweets []model.Tweet
err := r.db.Where("is_processed = ?", false).Order("heat_score desc").Limit(limit).Find(&tweets).Error
return tweets, err
}
// MarkAsProcessed tags a tweet so we don't present it to the user repeatedly
func (r *TweetRepository) MarkAsProcessed(id string) error {
return r.db.Model(&model.Tweet{}).Where("id = ?", id).Update("is_processed", true).Error
}
// SearchTweets allows dynamic multi-rule filtering
func (r *TweetRepository) SearchTweets(keyword, handle string, limit int) ([]model.Tweet, error) {
var tweets []model.Tweet
query := r.db.Model(&model.Tweet{})
if keyword != "" {
// PostgreSQL ILIKE for case-insensitive keyword searching
query = query.Where("content ILIKE ?", "%"+keyword+"%")
}
if handle != "" {
query = query.Where("author_handle = ?", handle)
}
err := query.Order("heat_score desc, posted_at desc").Limit(limit).Find(&tweets).Error
return tweets, err
}

View File

@@ -22,3 +22,13 @@ func (r *UserRepository) GetByEmail(email string) (*model.User, error) {
err := r.db.Where("email = ?", email).First(&user).Error err := r.db.Where("email = ?", email).First(&user).Error
return &user, err return &user, err
} }
func (r *UserRepository) GetByID(id string) (*model.User, error) {
var user model.User
err := r.db.Where("id = ?", id).First(&user).Error
return &user, err
}
func (r *UserRepository) Update(user *model.User) error {
return r.db.Save(user).Error
}

View File

@@ -0,0 +1,133 @@
package scraper
import (
"crypto/tls"
"errors"
"fmt"
"net/http"
"sync"
"time"
"github.com/sony/gobreaker/v2"
"golang.org/x/exp/rand"
)
var (
ErrCircuitOpen = errors.New("scraper circuit breaker is open")
ErrRateLimited = errors.New("scraper hit rate limit (429)")
ErrUnavailable = errors.New("scraper target unavailable (503)")
)
type ScraperClient struct {
http *http.Client
breaker *gobreaker.CircuitBreaker[[]byte]
mu sync.Mutex
rng *rand.Rand
}
var userAgents = []string{
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/112.0",
}
func NewScraperClient() *ScraperClient {
// Custom transport to mask TLS fingerprints somewhat and set timeouts
tr := &http.Transport{
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
}
client := &http.Client{
Transport: tr,
Timeout: 15 * time.Second,
}
// Circuit Breaker: Trip on 5 consecutive failures, wait 60 seconds (Exponential behavior is often custom, but standard half-open helps)
st := gobreaker.Settings{
Name: "NitterScraperCB",
MaxRequests: 1,
Interval: 0,
Timeout: 60 * time.Second, // Wait 60s before allowing retry if Open
ReadyToTrip: func(counts gobreaker.Counts) bool {
return counts.ConsecutiveFailures >= 3
},
}
return &ScraperClient{
http: client,
breaker: gobreaker.NewCircuitBreaker[[]byte](st),
rng: rand.New(rand.NewSource(uint64(time.Now().UnixNano()))),
}
}
func (c *ScraperClient) getRandomUserAgent() string {
c.mu.Lock()
defer c.mu.Unlock()
return userAgents[c.rng.Intn(len(userAgents))]
}
func (c *ScraperClient) JitterDelay(minMs, maxMs int) {
c.mu.Lock()
delay := minMs + c.rng.Intn(maxMs-minMs)
c.mu.Unlock()
time.Sleep(time.Duration(delay) * time.Millisecond)
}
// Fetch returns the raw body byte stream while handling Circuit Breaking and Status checking.
func (c *ScraperClient) Fetch(url string) ([]byte, error) {
respBody, err := c.breaker.Execute(func() ([]byte, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", c.getRandomUserAgent())
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
return nil, ErrRateLimited
}
if resp.StatusCode == http.StatusServiceUnavailable {
return nil, ErrUnavailable
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
// Read to memory in Execute block so if it fails, circuit tracks it. ReadAll is fine for HTML scrapes.
var data []byte
buf := make([]byte, 1024)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
data = append(data, buf[:n]...)
}
if err != nil {
break
}
}
return data, nil
})
if err != nil {
if err == gobreaker.ErrOpenState {
return nil, ErrCircuitOpen
}
return nil, err
}
return respBody, nil
}

View File

@@ -0,0 +1,146 @@
package scraper
import (
"bytes"
"fmt"
"strconv"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
)
type ParsedTweet struct {
ID string
Author string
Handle string
Content string
Likes int
Retweets int
Replies int
CreatedAt time.Time
}
// ParseTimeline extracts all tweets from a Nitter timeline HTML page.
func ParseTimeline(htmlData []byte) ([]ParsedTweet, error) {
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(htmlData))
if err != nil {
return nil, fmt.Errorf("failed to load HTML document: %w", err)
}
var tweets []ParsedTweet
doc.Find(".timeline-item").Each(func(i int, s *goquery.Selection) {
// Only parse actual tweets (not "Show thread" links or "Load more")
if s.HasClass("show-more") || s.HasClass("more-replies") {
return
}
tweet := ParsedTweet{}
// Author and Handle
authorBlock := s.Find(".fullname")
if authorBlock.Length() > 0 {
tweet.Author = strings.TrimSpace(authorBlock.Text())
}
handleBlock := s.Find(".username")
if handleBlock.Length() > 0 {
tweet.Handle = strings.TrimSpace(handleBlock.Text())
}
// Content
contentBlock := s.Find(".tweet-content")
if contentBlock.Length() > 0 {
tweet.Content = strings.TrimSpace(contentBlock.Text())
}
// Link (to get ID)
linkBlock := s.Find("a.tweet-link")
if linkBlock.Length() > 0 {
href, _ := linkBlock.Attr("href")
parts := strings.Split(href, "/")
if len(parts) > 0 {
tweet.ID = parts[len(parts)-1]
// Nitter sometimes adds #m at the end of links
tweet.ID = strings.TrimSuffix(tweet.ID, "#m")
}
}
// Date
dateBlock := s.Find(".tweet-date a[title]")
if dateBlock.Length() > 0 {
titleAttr, _ := dateBlock.Attr("title")
// Nitter format: "Feb 28, 2026 · 1:23 PM UTC"
// A rough parsing could be done here, or we just rely on standard formats.
// For simplicity, we just leave it default Time if we can't parse it quickly.
if titleAttr != "" {
parsedTime, err := time.Parse("Jan 2, 2006 · 3:04 PM MST", titleAttr)
if err == nil {
tweet.CreatedAt = parsedTime
} else {
tweet.CreatedAt = time.Now() // Fallback
}
}
}
// Stats
statBlock := s.Find(".tweet-stat")
statBlock.Each(func(j int, statSel *goquery.Selection) {
iconContainer := statSel.Find("span.icon-container > span")
class, exists := iconContainer.Attr("class")
if !exists {
return
}
// Find the text value beside the icon
valStr := strings.TrimSpace(statSel.Text())
val := parseStatString(valStr)
if strings.Contains(class, "icon-comment") {
tweet.Replies = val
} else if strings.Contains(class, "icon-retweet") {
tweet.Retweets = val
} else if strings.Contains(class, "icon-heart") {
tweet.Likes = val
}
})
// Only append if it's a valid parsed tweet
if tweet.ID != "" && tweet.Content != "" {
tweets = append(tweets, tweet)
}
})
return tweets, nil
}
// parseStatString converts string representations like "15.4K" to integer 15400
func parseStatString(s string) int {
if s == "" {
return 0
}
s = strings.ReplaceAll(s, ",", "")
s = strings.ReplaceAll(s, " ", "")
multiplier := 1.0
lower := strings.ToLower(s)
if strings.HasSuffix(lower, "k") {
multiplier = 1000.0
s = s[:len(s)-1]
} else if strings.HasSuffix(lower, "m") {
multiplier = 1000000.0
s = s[:len(s)-1]
} else if strings.HasSuffix(lower, "b") {
multiplier = 1000000000.0
s = s[:len(s)-1]
}
val, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0
}
return int(val * multiplier)
}

View File

@@ -3,57 +3,210 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"os"
"time"
"github.com/sashabaranov/go-openai" "github.com/sony/gobreaker/v2"
"github.com/zs/InsightReply/internal/service/llm"
) )
type AIService struct { type AIService struct {
client *openai.Client providers map[string]llm.Provider
breakers map[string]*gobreaker.CircuitBreaker[string]
defaultProvider string
defaultModel string
} }
func NewAIService(apiKey string) *AIService { func NewAIService() *AIService {
return &AIService{ s := &AIService{
client: openai.NewClient(apiKey), providers: make(map[string]llm.Provider),
breakers: make(map[string]*gobreaker.CircuitBreaker[string]),
} }
// 1. Initialize Providers based on ENV
if key := os.Getenv("OPENAI_API_KEY"); key != "" {
s.providers["openai"] = llm.NewOpenAIProvider(key, os.Getenv("OPENAI_BASE_URL"), "openai")
}
if key := os.Getenv("ANTHROPIC_API_KEY"); key != "" {
s.providers["anthropic"] = llm.NewAnthropicProvider(key, os.Getenv("ANTHROPIC_BASE_URL"))
}
if key := os.Getenv("DEEPSEEK_API_KEY"); key != "" {
baseURL := os.Getenv("DEEPSEEK_BASE_URL")
if baseURL == "" {
baseURL = "https://api.deepseek.com/v1" // Add v1 as expected by OpenAI SDK compatibility
}
s.providers["deepseek"] = llm.NewOpenAIProvider(key, baseURL, "deepseek")
}
if key := os.Getenv("GEMINI_API_KEY"); key != "" {
s.providers["gemini"] = llm.NewGeminiProvider(key, os.Getenv("GEMINI_BASE_URL"))
}
// 2. Initialize Circuit Breakers for each provider
for name := range s.providers {
st := gobreaker.Settings{
Name: name + "_cb",
MaxRequests: 3, // Requests allowed in half-open state
Interval: 10 * time.Minute, // Cyclic period for closed state counters
Timeout: 60 * time.Second, // Open state duration
ReadyToTrip: func(counts gobreaker.Counts) bool {
failureRatio := float64(counts.TotalFailures) / float64(counts.Requests)
return counts.Requests >= 5 && failureRatio >= 0.6 // Trip if 60% fail after 5 reqs
},
}
s.breakers[name] = gobreaker.NewCircuitBreaker[string](st)
}
s.defaultProvider = os.Getenv("LLM_PROVIDER")
if s.defaultProvider == "" {
s.defaultProvider = "openai"
}
s.defaultModel = os.Getenv("LLM_MODEL")
if s.defaultModel == "" {
s.defaultModel = "gpt-4o-mini"
}
return s
} }
func (s *AIService) TestConnection(ctx context.Context) (string, error) { func (s *AIService) TestConnection(ctx context.Context) (string, error) {
// ... (same as before) if len(s.providers) == 0 {
return "Ready", nil // Simplified for brevity in this edit, but I'll keep the logic if needed return "", fmt.Errorf("no LLM providers configured")
}
return "Ready (Multi-LLM configured)", nil
} }
func (s *AIService) GenerateReply(ctx context.Context, tweetContent string, strategy string, userIdentity string) (string, error) { // GenerateReply dynamically routes to the preferred LLM and uses a fallback chain if it fails.
prompt := fmt.Sprintf(` func (s *AIService) GenerateReply(ctx context.Context, tweetContent, productContext, userIdentity string, preferredProvider, preferredModel string) (string, error) {
systemPrompt := "You are a professional X (Twitter) ghostwriter. You MUST respond with valid JSON."
userPrompt := fmt.Sprintf(`
You are a social media expert. You are a social media expert.
User Identity: %s User Identity: %s
%s
Target Tweet: "%s" Target Tweet: "%s"
Strategy: %s
Generate a high-quality reply for X (Twitter). Generate 3 high-quality, distinct replies for X (Twitter) using different strategic angles.
Keep it natural, engaging, and under 280 characters. Suggested angles depending on context: Contrarian, Analytical, Supportive, Data-driven, Founder's Experience, Quote Tweet.
Do not use quotes around the reply. IMPORTANT: If "Available User Custom Strategies" are provided above, you MUST prioritize using those custom strategy angles for your replies.
`, userIdentity, tweetContent, strategy) IMPORTANT: If a specific "IMMITATE STYLE" instruction is provided in the Identity or Context, you MUST perfectly clone that linguistic tone.
resp, err := s.client.CreateChatCompletion( Keep each reply natural, engaging, and under 280 characters. No hashtags unless highly relevant.
ctx,
openai.ChatCompletionRequest{
Model: openai.GPT4oMini,
Messages: []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleSystem,
Content: "You are a professional X (Twitter) ghostwriter.",
},
{
Role: openai.ChatMessageRoleUser,
Content: prompt,
},
},
},
)
if err != nil { Respond ONLY with a JSON array in the exact following format, without any markdown formatting wrappers (like markdown code blocks):
return "", fmt.Errorf("failed to generate reply: %w", err) [
{"strategy": "Name of Strategy 1", "content": "Reply content 1"},
{"strategy": "Name of Strategy 2", "content": "Reply content 2"},
{"strategy": "Name of Strategy 3", "content": "Reply content 3"}
]
`, userIdentity, productContext, tweetContent)
targetProvider := preferredProvider
if targetProvider == "" {
targetProvider = s.defaultProvider
}
targetModel := preferredModel
if targetModel == "" {
targetModel = s.defaultModel
} }
return resp.Choices[0].Message.Content, nil // Fallback chain (as designed in IMPLEMENTATION_PLAN: current -> Anthropic -> OpenAI -> Gemini -> DeepSeek)
fallbackChain := []string{targetProvider, "anthropic", "openai", "gemini", "deepseek"}
for _, pName := range fallbackChain {
provider, ok := s.providers[pName]
if !ok {
log.Printf("Provider %s bypassed (not configured)", pName)
continue
}
breaker, ok := s.breakers[pName]
if !ok {
continue // Should never happen
}
// Use the target model only on the initially requested provider. For fallbacks, use a safe default model.
modelToUse := targetModel
if pName != targetProvider {
modelToUse = getDefaultModelFor(pName)
}
log.Printf("Routing request to LLM Provider: %s (Model: %s)", pName, modelToUse)
// Execute through circuit breaker
reply, err := breaker.Execute(func() (string, error) {
// Add a simple 30s timeout per call
callCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
return provider.GenerateReply(callCtx, modelToUse, systemPrompt, userPrompt)
})
if err == nil {
return reply, nil // Success
}
log.Printf("Provider %s failed: %v. Attempting next in fallback chain...", pName, err)
}
return "", fmt.Errorf("all providers failed to generate reply")
}
// ExtractStyle consumes a viral AI reply and uses the LLM to reverse-engineer its linguistic fingerprint
func (s *AIService) ExtractStyle(ctx context.Context, viralReplyContent string) (string, error) {
systemPrompt := "You are a master linguistic analyst and copywriter."
userPrompt := fmt.Sprintf(`
Analyze the following highly successful social media reply:
"%s"
Extract the core stylistic elements that made it successful. Focus on:
1. Tone (e.g., witty, provocative, deadpan, empathetic)
2. Sentence structure (e.g., short punchy sentences, questions, bullet points)
3. Key jargon or vocabulary patterns
Provide ONLY a concise, 2-3 sentence description of the style profile that another AI should imitate in the future.
No conversational filler, just the exact instruction string to append to future system prompts.
`, viralReplyContent)
// Route through our Multi-LLM fallback logic
// Try OpenAI first, fallback to Anthropic
providers := []string{"openai", "anthropic", "gemini", "deepseek"}
for _, pName := range providers {
pConf, exists := s.providers[pName]
cb, cbExists := s.breakers[pName]
if !exists || !cbExists {
continue
}
styleDesc, err := cb.Execute(func() (string, error) {
// Use a default model for style extraction, as it's not user-facing and can be optimized for cost/speed
modelToUse := getDefaultModelFor(pName)
if modelToUse == "" { // Fallback if getDefaultModelFor doesn't have an entry
modelToUse = "gpt-4o-mini" // A safe default
}
return pConf.GenerateReply(ctx, modelToUse, systemPrompt, userPrompt)
})
if err == nil && styleDesc != "" {
return styleDesc, nil
}
log.Printf("Provider %s failed to extract style: %v. Attempting next...", pName, err)
}
return "", fmt.Errorf("failed to extract style from any provider")
}
func getDefaultModelFor(provider string) string {
switch provider {
case "openai":
return "gpt-4o-mini"
case "anthropic":
return "claude-3-5-haiku-latest"
case "deepseek":
return "deepseek-chat"
case "gemini":
return "gemini-2.5-flash"
default:
return ""
}
} }

View File

@@ -0,0 +1,41 @@
package service
import (
"os"
"testing"
)
// TestAIService_Initialization verifies that the AIService parses environment variables
// correctly and initializes the required fallback strategies and default settings.
func TestAIService_Initialization(t *testing.T) {
// Temporarily set testing ENVs to avoid depending on local .env
os.Setenv("LLM_PROVIDER", "anthropic")
os.Setenv("LLM_MODEL", "claude-3-5-haiku-latest")
os.Setenv("OPENAI_API_KEY", "test-key-openai")
defer os.Clearenv() // Clean up after test
svc := NewAIService()
if svc == nil {
t.Fatal("Expected AIService to be initialized, got nil")
}
if svc.defaultProvider != "anthropic" {
t.Errorf("Expected default provider 'anthropic', got '%s'", svc.defaultProvider)
}
if svc.defaultModel != "claude-3-5-haiku-latest" {
t.Errorf("Expected default model 'claude-3-5-haiku-latest', got '%s'", svc.defaultModel)
}
// Verify that OpenAI provider was initialized because OPENAI_API_KEY was present
_, hasOpenAI := svc.providers["openai"]
if !hasOpenAI {
t.Error("Expected OpenAI provider to be initialized, but it was not found")
}
// Verify that circuit breakers were initialized
_, hasBreaker := svc.breakers["openai"]
if !hasBreaker {
t.Error("Expected circuit breaker for setup provider, but it was not found")
}
}

View File

@@ -0,0 +1,26 @@
package service
import (
"github.com/zs/InsightReply/internal/model"
"github.com/zs/InsightReply/internal/repository"
)
type CompetitorMonitorService struct {
repo *repository.CompetitorMonitorRepository
}
func NewCompetitorMonitorService(repo *repository.CompetitorMonitorRepository) *CompetitorMonitorService {
return &CompetitorMonitorService{repo: repo}
}
func (s *CompetitorMonitorService) ListMonitors(userID string) ([]model.CompetitorMonitor, error) {
return s.repo.ListByUserID(userID)
}
func (s *CompetitorMonitorService) CreateMonitor(monitor *model.CompetitorMonitor) error {
return s.repo.Create(monitor)
}
func (s *CompetitorMonitorService) DeleteMonitor(id string, userID string) error {
return s.repo.Delete(id, userID)
}

View File

@@ -0,0 +1,30 @@
package service
import (
"github.com/zs/InsightReply/internal/model"
"github.com/zs/InsightReply/internal/repository"
)
type CustomStrategyService struct {
repo *repository.CustomStrategyRepository
}
func NewCustomStrategyService(repo *repository.CustomStrategyRepository) *CustomStrategyService {
return &CustomStrategyService{repo: repo}
}
func (s *CustomStrategyService) ListStrategies(userID string) ([]model.UserCustomStrategy, error) {
return s.repo.ListByUserID(userID)
}
func (s *CustomStrategyService) CreateStrategy(strategy *model.UserCustomStrategy) error {
return s.repo.Create(strategy)
}
func (s *CustomStrategyService) UpdateStrategy(strategy *model.UserCustomStrategy) error {
return s.repo.Update(strategy)
}
func (s *CustomStrategyService) DeleteStrategy(id string, userID string) error {
return s.repo.Delete(id, userID)
}

View File

@@ -0,0 +1,75 @@
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
type AnthropicProvider struct {
apiKey string
baseURL string
client *http.Client
}
func NewAnthropicProvider(apiKey, baseURL string) *AnthropicProvider {
if baseURL == "" {
baseURL = "https://api.anthropic.com/v1"
}
return &AnthropicProvider{
apiKey: apiKey,
baseURL: baseURL,
client: &http.Client{},
}
}
func (p *AnthropicProvider) Name() string {
return "anthropic"
}
func (p *AnthropicProvider) GenerateReply(ctx context.Context, model string, systemPrompt, userPrompt string) (string, error) {
reqBody := map[string]interface{}{
"model": model,
"max_tokens": 1024,
"system": systemPrompt,
"messages": []map[string]string{
{"role": "user", "content": userPrompt},
},
}
bs, _ := json.Marshal(reqBody)
req, err := http.NewRequestWithContext(ctx, "POST", p.baseURL+"/messages", bytes.NewReader(bs))
if err != nil {
return "", err
}
req.Header.Set("x-api-key", p.apiKey)
req.Header.Set("anthropic-version", "2023-06-01")
req.Header.Set("content-type", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("anthropic error %d: %s", resp.StatusCode, string(body))
}
var result struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if len(result.Content) == 0 {
return "", fmt.Errorf("anthropic returned empty content")
}
return result.Content[0].Text, nil
}

View File

@@ -0,0 +1,86 @@
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
type GeminiProvider struct {
apiKey string
baseURL string
client *http.Client
}
func NewGeminiProvider(apiKey, baseURL string) *GeminiProvider {
if baseURL == "" {
baseURL = "https://generativelanguage.googleapis.com/v1beta/models"
}
return &GeminiProvider{
apiKey: apiKey,
baseURL: baseURL,
client: &http.Client{},
}
}
func (p *GeminiProvider) Name() string {
return "gemini"
}
func (p *GeminiProvider) GenerateReply(ctx context.Context, model string, systemPrompt, userPrompt string) (string, error) {
url := fmt.Sprintf("%s/%s:generateContent?key=%s", p.baseURL, model, p.apiKey)
reqBody := map[string]interface{}{
"systemInstruction": map[string]interface{}{
"parts": []map[string]interface{}{
{"text": systemPrompt},
},
},
"contents": []map[string]interface{}{
{
"role": "user",
"parts": []map[string]interface{}{
{"text": userPrompt},
},
},
},
}
bs, _ := json.Marshal(reqBody)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(bs))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("gemini error %d: %s", resp.StatusCode, string(body))
}
var result struct {
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if len(result.Candidates) == 0 || len(result.Candidates[0].Content.Parts) == 0 {
return "", fmt.Errorf("gemini returned empty content")
}
return result.Candidates[0].Content.Parts[0].Text, nil
}

View File

@@ -0,0 +1,50 @@
package llm
import (
"context"
"fmt"
"github.com/sashabaranov/go-openai"
)
type OpenAIProvider struct {
client *openai.Client
name string
}
// NewOpenAIProvider creates a new provider that uses the official or compatible OpenAI API.
// It can also handle DeepSeek via a custom BaseURL.
func NewOpenAIProvider(apiKey, baseURL, name string) *OpenAIProvider {
config := openai.DefaultConfig(apiKey)
if baseURL != "" {
config.BaseURL = baseURL
}
return &OpenAIProvider{
client: openai.NewClientWithConfig(config),
name: name,
}
}
func (p *OpenAIProvider) Name() string {
return p.name
}
func (p *OpenAIProvider) GenerateReply(ctx context.Context, model string, systemPrompt, userPrompt string) (string, error) {
resp, err := p.client.CreateChatCompletion(
ctx,
openai.ChatCompletionRequest{
Model: model,
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
{Role: openai.ChatMessageRoleUser, Content: userPrompt},
},
},
)
if err != nil {
return "", fmt.Errorf("%s api error: %w", p.name, err)
}
if len(resp.Choices) == 0 {
return "", fmt.Errorf("%s returned no choices", p.name)
}
return resp.Choices[0].Message.Content, nil
}

View File

@@ -0,0 +1,8 @@
package llm
import "context"
type Provider interface {
Name() string
GenerateReply(ctx context.Context, model string, systemPrompt, userPrompt string) (string, error)
}

View File

@@ -0,0 +1,22 @@
package service
import (
"github.com/zs/InsightReply/internal/model"
"github.com/zs/InsightReply/internal/repository"
)
type ProductProfileService struct {
repo *repository.ProductProfileRepository
}
func NewProductProfileService(repo *repository.ProductProfileRepository) *ProductProfileService {
return &ProductProfileService{repo: repo}
}
func (s *ProductProfileService) GetProfile(userID string) (*model.UserProductProfile, error) {
return s.repo.GetByUserID(userID)
}
func (s *ProductProfileService) SaveProfile(profile *model.UserProductProfile) error {
return s.repo.Save(profile)
}

View File

@@ -25,3 +25,24 @@ func (s *UserService) Register(email string, identity string) (*model.User, erro
func (s *UserService) GetUser(email string) (*model.User, error) { func (s *UserService) GetUser(email string) (*model.User, error) {
return s.repo.GetByEmail(email) return s.repo.GetByEmail(email)
} }
func (s *UserService) GetUserByID(id string) (*model.User, error) {
return s.repo.GetByID(id)
}
func (s *UserService) UpdatePreferences(id string, identity string, language string) (*model.User, error) {
user, err := s.repo.GetByID(id)
if err != nil {
return nil, err
}
if identity != "" {
user.IdentityLabel = identity
}
if language != "" {
user.LanguagePreference = language
}
err = s.repo.Update(user)
return user, err
}

View File

@@ -0,0 +1,147 @@
package worker
import (
"context"
"fmt"
"log"
"time"
"github.com/zs/InsightReply/internal/model"
"github.com/zs/InsightReply/internal/repository"
"github.com/zs/InsightReply/internal/scraper"
)
type MonitorWorker struct {
repo *repository.CompetitorMonitorRepository
tweetRepo *repository.TweetRepository
client *scraper.ScraperClient
baseUrl string
}
func NewMonitorWorker(repo *repository.CompetitorMonitorRepository, tweetRepo *repository.TweetRepository) *MonitorWorker {
return &MonitorWorker{
repo: repo,
tweetRepo: tweetRepo,
client: scraper.NewScraperClient(),
baseUrl: "https://x.beenglish.eu.org", // Self-hosted Nitter instance
}
}
// Start begins the background job loop. This should be run in a goroutine.
func (w *MonitorWorker) Start(ctx context.Context, interval time.Duration) {
log.Printf("[MonitorWorker] Starting background scraping loop every %v", interval)
ticker := time.NewTicker(interval)
defer ticker.Stop()
// Initial run
w.runCycle(ctx)
for {
select {
case <-ctx.Done():
log.Println("[MonitorWorker] Stopping background scraping loop")
return
case <-ticker.C:
w.runCycle(ctx)
}
}
}
func (w *MonitorWorker) runCycle(ctx context.Context) {
log.Println("[MonitorWorker] Starting scrape cycle...")
monitors, err := w.repo.ListAllActive()
if err != nil {
log.Printf("[MonitorWorker] Error fetching active monitors: %v", err)
return
}
if len(monitors) == 0 {
log.Println("[MonitorWorker] No active monitors found. Skipping cycle.")
return
}
for _, monitor := range monitors {
// Stop processing if context cancelled (e.g., app shutdown)
select {
case <-ctx.Done():
return
default:
}
// Determine Scraping Strategy
var url string
// URL encode the brand name which acts as our keyword
keyword := monitor.BrandName
if monitor.XHandle != "" {
if monitor.XHandle == keyword || keyword == "" {
// Standard profile timeline scraping
log.Printf("[MonitorWorker] Scraping timeline for account @%s", monitor.XHandle)
url = fmt.Sprintf("%s/%s", w.baseUrl, monitor.XHandle)
} else {
// Combo scraping: Keyword + Specific Account
log.Printf("[MonitorWorker] Scraping combo: '%s' from @%s", keyword, monitor.XHandle)
url = fmt.Sprintf("%s/search?f=tweets&q=%s+from%%3A%s", w.baseUrl, keyword, monitor.XHandle)
}
} else if keyword != "" {
// Global search for Keyword across X
log.Printf("[MonitorWorker] Scraping global search for keyword: '%s'", keyword)
url = fmt.Sprintf("%s/search?f=tweets&q=%s", w.baseUrl, keyword)
} else {
continue // Invalid monitor config
}
w.scrapeAndLog(url)
// Anti-Ban: Jitter delay between requests (3s to 8s)
w.client.JitterDelay(3000, 8000)
}
log.Println("[MonitorWorker] Scrape cycle completed.")
}
func (w *MonitorWorker) scrapeAndLog(url string) {
htmlData, err := w.client.Fetch(url)
if err != nil {
log.Printf("[MonitorWorker] Error scraping %s: %v", url, err)
return
}
tweets, err := scraper.ParseTimeline(htmlData)
if err != nil {
log.Printf("[MonitorWorker] Error parsing HTML for %s: %v", url, err)
return
}
log.Printf("[MonitorWorker] Extracted %d tweets from %s", len(tweets), url)
// Epic 6: Upsert into tracking database
upsertCount := 0
for _, rawTweet := range tweets {
tweet := &model.Tweet{
XTweetID: rawTweet.ID,
AuthorHandle: rawTweet.Handle,
Content: rawTweet.Content,
PostedAt: rawTweet.CreatedAt,
LikeCount: rawTweet.Likes,
RetweetCount: rawTweet.Retweets,
ReplyCount: rawTweet.Replies,
CrawlQueue: "normal",
IsProcessed: false,
LastCrawledAt: time.Now(),
}
// Save/Update in DB
err := w.tweetRepo.Upsert(tweet)
if err != nil {
log.Printf("[MonitorWorker] Error UPSERTing tweet %s: %v", tweet.XTweetID, err)
} else {
upsertCount++
}
}
log.Printf("[MonitorWorker] Successfully Upserted %d/%d tweets to the database.", upsertCount, len(tweets))
}

View File

@@ -0,0 +1,142 @@
package worker
import (
"context"
"fmt"
"log"
"strings"
"time"
"github.com/zs/InsightReply/internal/model"
"github.com/zs/InsightReply/internal/repository"
"github.com/zs/InsightReply/internal/scraper"
"github.com/zs/InsightReply/internal/service"
)
type PerformanceWorker struct {
repo *repository.ReplyRepository
client *scraper.ScraperClient
aiSvc *service.AIService
baseUrl string
}
func NewPerformanceWorker(repo *repository.ReplyRepository, aiSvc *service.AIService) *PerformanceWorker {
return &PerformanceWorker{
repo: repo,
client: scraper.NewScraperClient(),
aiSvc: aiSvc,
baseUrl: "https://x.beenglish.eu.org",
}
}
// Start begins the 24h retroactive performance checking loop
func (w *PerformanceWorker) Start(ctx context.Context, interval time.Duration) {
log.Printf("[PerformanceWorker] Starting retroactive engagement tracking every %v", interval)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Println("[PerformanceWorker] Stopping background performance loop")
return
case <-ticker.C:
w.runCycle(ctx)
}
}
}
func (w *PerformanceWorker) runCycle(ctx context.Context) {
pending, err := w.repo.GetPendingPerformanceChecks()
if err != nil {
log.Printf("[PerformanceWorker] Error fetching pending checks: %v", err)
return
}
for _, reply := range pending {
// Stop processing if context cancelled
select {
case <-ctx.Done():
return
default:
}
xTweetID, err := w.repo.GetTweetXTweetID(reply.TweetID)
if err != nil {
continue
}
// Scrape the specific thread
// Nitter handles /i/status/12345 generic routes
url := fmt.Sprintf("%s/i/status/%s", w.baseUrl, xTweetID)
log.Printf("[PerformanceWorker] Checking thread %s for user's AI reply performance", url)
htmlData, err := w.client.Fetch(url)
if err != nil {
w.client.JitterDelay(2000, 5000)
continue
}
threadReplies, err := scraper.ParseTimeline(htmlData)
if err != nil {
continue
}
// Search for the user's generated text within the thread replies
found := false
for _, threadReply := range threadReplies {
// Basic similarity check: if 50% of the AI sentence is present
// Real implementation might use Levenshtein distance, but strings.Contains on chunks works for MVP
snippet := reply.Content
if len(snippet) > 20 {
snippet = snippet[:20]
}
if strings.Contains(threadReply.Content, snippet) {
found = true
// WE FOUND OUR REPLY! Record its metrics
perf := &model.ReplyPerformance{
ReplyID: reply.ID,
UserID: reply.UserID,
LikeCountIncrease: threadReply.Likes,
ReplyCountIncrease: threadReply.Replies,
CheckTime: time.Now(),
}
w.repo.SaveReplyPerformance(perf)
log.Printf("[PerformanceWorker] 🎯 Verified AI reply in wild! Likes: %d, Replies: %d", perf.LikeCountIncrease, perf.ReplyCountIncrease)
// Epic 13 AI Tone Engine: Autonomous Style Cloning for proven viral comments
if perf.LikeCountIncrease >= 10 {
log.Printf("[PerformanceWorker] Reply went viral! Asking AI to reverse-engineer linguistic styling.")
styleProfile, err := w.aiSvc.ExtractStyle(ctx, reply.Content)
if err == nil && styleProfile != "" {
err = w.repo.SaveStyleExtraction(reply.UserID, styleProfile)
if err != nil {
log.Printf("[PerformanceWorker] Error saving style database mapping: %v", err)
} else {
log.Printf("[PerformanceWorker] Successfully built user style clone: %s", styleProfile)
}
}
}
break
}
}
// Even if not found (maybe they edited heavily or didn't actually post it), we mark it as checked to prevent infinite re-checking
if !found {
perf := &model.ReplyPerformance{
ReplyID: reply.ID,
UserID: reply.UserID,
CheckTime: time.Now(),
}
w.repo.SaveReplyPerformance(perf)
}
w.client.JitterDelay(3000, 8000)
}
}

View File

@@ -0,0 +1,16 @@
DROP TRIGGER IF EXISTS update_users_modtime ON users;
DROP TRIGGER IF EXISTS update_user_style_profiles_modtime ON user_style_profiles;
DROP FUNCTION IF EXISTS update_modified_column;
DROP TABLE IF EXISTS competitor_monitors CASCADE;
DROP TABLE IF EXISTS user_custom_strategies CASCADE;
DROP TABLE IF EXISTS user_product_profiles CASCADE;
DROP TABLE IF EXISTS crawl_snapshots CASCADE;
DROP TABLE IF EXISTS subscriptions CASCADE;
DROP TABLE IF EXISTS api_usage_logs CASCADE;
DROP TABLE IF EXISTS reply_performance CASCADE;
DROP TABLE IF EXISTS generated_replies CASCADE;
DROP TABLE IF EXISTS tweets CASCADE;
DROP TABLE IF EXISTS monitored_keywords CASCADE;
DROP TABLE IF EXISTS monitored_accounts CASCADE;
DROP TABLE IF EXISTS users CASCADE;

View File

@@ -0,0 +1,230 @@
-- ====================================================
-- InsightReply 数据库 Schema (PostgreSQL)
-- 版本: v1.1
-- 更新: 新增 api_usage_logs, subscriptions, user_style_profiles 表
-- ====================================================
-- users 表:存储业务用户
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255),
subscription_tier VARCHAR(50) DEFAULT 'Free', -- Free, Pro, Premium
identity_label VARCHAR(100), -- AI 创始人, SaaS Builder 等
language_preference VARCHAR(10) DEFAULT 'auto', -- en, zh, auto
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- monitored_accounts 表:存储用户重点监控的 X 账号
CREATE TABLE IF NOT EXISTS monitored_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
x_account_id VARCHAR(255),
x_handle VARCHAR(255) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, x_handle)
);
-- monitored_keywords 表:存储用户重点监控的关键词
CREATE TABLE IF NOT EXISTS monitored_keywords (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
keyword VARCHAR(255) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, keyword)
);
-- tweets 表共享的推文数据池AI 评论生成的上下文
CREATE TABLE IF NOT EXISTS tweets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
x_tweet_id VARCHAR(255) UNIQUE NOT NULL,
author_id VARCHAR(255),
author_handle VARCHAR(255),
content TEXT NOT NULL,
posted_at TIMESTAMP WITH TIME ZONE,
like_count INTEGER DEFAULT 0,
retweet_count INTEGER DEFAULT 0,
reply_count INTEGER DEFAULT 0,
heat_score FLOAT DEFAULT 0.0,
crawl_queue VARCHAR(20) DEFAULT 'normal', -- high, normal, low (智能抓取频率)
is_processed BOOLEAN DEFAULT FALSE,
last_crawled_at TIMESTAMP WITH TIME ZONE, -- 上次抓取时间
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_tweets_x_tweet_id ON tweets(x_tweet_id);
CREATE INDEX idx_tweets_heat_score ON tweets(heat_score DESC);
CREATE INDEX idx_tweets_crawl_queue ON tweets(crawl_queue, last_crawled_at);
-- generated_replies 表:生成的 AI 评论记录
CREATE TABLE IF NOT EXISTS generated_replies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tweet_id UUID NOT NULL REFERENCES tweets(id) ON DELETE CASCADE,
strategy_type VARCHAR(100) NOT NULL, -- cognitive_upgrade, contrarian, data_supplement, empathy, founder_exp
content TEXT NOT NULL,
status VARCHAR(50) DEFAULT 'draft', -- draft, copied, posted
language VARCHAR(10) DEFAULT 'en', -- 生成语言
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_generated_replies_user_id ON generated_replies(user_id);
CREATE INDEX idx_generated_replies_tweet_id ON generated_replies(tweet_id);
-- reply_performance 表:针对已发布评论的效果数据回拨
CREATE TABLE IF NOT EXISTS reply_performance (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
reply_id UUID NOT NULL REFERENCES generated_replies(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- 冗余字段,便于按用户维度查询
like_count_increase INTEGER DEFAULT 0,
reply_count_increase INTEGER DEFAULT 0,
interaction_rate FLOAT DEFAULT 0.0,
check_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_reply_performance_reply_id ON reply_performance(reply_id);
CREATE INDEX idx_reply_performance_user_id ON reply_performance(user_id);
-- ====================================================
-- 新增表 (v1.1)
-- ====================================================
-- api_usage_logs 表:记录 LLM API 调用量和成本
CREATE TABLE IF NOT EXISTS api_usage_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(50) NOT NULL, -- openai, anthropic, deepseek
model VARCHAR(100) NOT NULL, -- gpt-4o-mini, claude-3.5-haiku 等
prompt_tokens INTEGER DEFAULT 0,
completion_tokens INTEGER DEFAULT 0,
total_tokens INTEGER GENERATED ALWAYS AS (prompt_tokens + completion_tokens) STORED,
cost_usd NUMERIC(10, 6) DEFAULT 0.0, -- 精确到 $0.000001
endpoint VARCHAR(100), -- /ai/generate
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_api_usage_logs_user_id ON api_usage_logs(user_id);
CREATE INDEX idx_api_usage_logs_created_at ON api_usage_logs(created_at DESC);
-- subscriptions 表:用户订阅记录(支付历史)
CREATE TABLE IF NOT EXISTS subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tier VARCHAR(50) NOT NULL, -- Pro, Premium
stripe_subscription_id VARCHAR(255), -- Stripe 订阅 ID
status VARCHAR(50) DEFAULT 'active', -- active, cancelled, past_due, expired
started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP WITH TIME ZONE,
cancelled_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_subscriptions_user_id ON subscriptions(user_id);
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
-- user_style_profiles 表:用户风格画像(用于个性化 Prompt
CREATE TABLE IF NOT EXISTS user_style_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE,
top_strategies JSONB DEFAULT '[]', -- 最常选择的策略排序
avg_reply_length INTEGER DEFAULT 200, -- 平均偏好回复长度
high_engagement_keywords JSONB DEFAULT '[]', -- 高互动关键词
tone_preference VARCHAR(100) DEFAULT 'professional', -- casual, professional, witty, provocative
custom_prompt_suffix TEXT, -- 用户自定义的额外 Prompt 指令
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- crawl_snapshots 表:异常抓取时的 HTML 快照(排错用)
CREATE TABLE IF NOT EXISTS crawl_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
url TEXT NOT NULL,
http_status INTEGER,
html_content TEXT,
error_message TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_crawl_snapshots_created_at ON crawl_snapshots(created_at DESC);
-- ====================================================
-- 新增表 (v1.2) — 用户可配置系统
-- ====================================================
-- user_product_profiles 表:用户的产品档案(用于生成与产品相关联的评论)
-- 设计原则:所有字段用户自定义,系统不做任何硬编码
CREATE TABLE IF NOT EXISTS user_product_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE,
product_name VARCHAR(255), -- 产品名称 (如 "SwiftBiu")
tagline TEXT, -- 一句话介绍
domain VARCHAR(255), -- 所属领域 (如 "AI Video Creation")
key_features JSONB DEFAULT '[]', -- 核心功能列表 ["视频生成", "多语言配音"]
target_users TEXT, -- 目标用户描述
product_url VARCHAR(500), -- 官网或商店链接
competitors JSONB DEFAULT '[]', -- 竞品名称列表 ["CapCut", "Descript"]
relevance_keywords JSONB DEFAULT '[]', -- 相关领域关键词 ["short video", "content creation", "AI dubbing"]
custom_context TEXT, -- 用户自定义的额外上下文(自由文本,注入 Prompt
default_llm_provider VARCHAR(50), -- 用户专属模型偏好: openai, anthropic, deepseek, gemini (覆盖系统默认)
default_llm_model VARCHAR(100), -- 例如: claude-3-5-haiku-latest
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- user_custom_strategies 表:用户自定义评论策略
-- 除系统内置的 5 种策略外,用户可以创建自己的策略模板
CREATE TABLE IF NOT EXISTS user_custom_strategies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
strategy_key VARCHAR(100) NOT NULL, -- 策略标识 (如 "builder_story")
label VARCHAR(255) NOT NULL, -- 显示名称 (如 "创始人实战型")
icon VARCHAR(10), -- Emoji 图标
description TEXT, -- 策略描述(告诉 LLM 这种策略的写法)
prompt_template TEXT, -- 自定义 Prompt 模板(可包含 {tweet_content} {product_name} 等变量)
few_shot_examples JSONB DEFAULT '[]', -- 自定义 Few-shot 示例
is_active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0, -- 排序权重
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, strategy_key)
);
CREATE INDEX idx_user_custom_strategies_user_id ON user_custom_strategies(user_id);
-- competitor_monitors 表:竞品品牌监控(复用后端雷达,按品牌词自动抓取)
CREATE TABLE IF NOT EXISTS competitor_monitors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
brand_name VARCHAR(255) NOT NULL, -- 竞品品牌名
x_handle VARCHAR(255), -- 竞品官方 X 账号 (可选)
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, brand_name)
);
CREATE INDEX idx_competitor_monitors_user_id ON competitor_monitors(user_id);
-- ====================================================
-- 触发器:自动更新 updated_at
-- ====================================================
CREATE OR REPLACE FUNCTION update_modified_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- 为所有需要追踪更新时间的表添加触发器
CREATE TRIGGER update_users_modtime
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_modified_column();
CREATE TRIGGER update_user_style_profiles_modtime
BEFORE UPDATE ON user_style_profiles
FOR EACH ROW
EXECUTE FUNCTION update_modified_column();

Binary file not shown.

1
web/.env Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://insight.buildapp.eu.org/api/v1

2
web/.env.production Normal file
View File

@@ -0,0 +1,2 @@
# Handled by Nginx reverse proxy in production
VITE_API_BASE_URL=/api/v1

24
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
web/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
web/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2973
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
web/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.13.6",
"lucide-vue-next": "^0.575.0",
"pinia": "^3.0.4",
"vue": "^3.5.25",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.27",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vue-tsc": "^3.1.5"
}
}

6
web/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
web/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

78
web/src/App.vue Normal file
View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { Activity, RadioReceiver, Clock, Settings, LogOut } from 'lucide-vue-next'
const route = useRoute()
const navItems = [
{ name: 'Dashboard', path: '/dashboard', icon: Activity },
{ name: 'Radar', path: '/radar', icon: RadioReceiver },
{ name: 'History', path: '/history', icon: Clock }
]
</script>
<template>
<div class="flex h-screen w-full bg-[#0B1120] text-zinc-300 font-sans overflow-hidden">
<!-- Sidebar -->
<aside v-if="route.path !== '/login'" class="w-64 border-r border-white/10 bg-[#0F172A] flex flex-col shrink-0">
<div class="p-6 flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-brand-primary to-blue-600 flex items-center justify-center shrink-0">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
</div>
<span class="font-bold text-lg text-white tracking-wide">InsightReply</span>
</div>
<nav class="flex-1 px-4 py-6 space-y-2">
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200"
:class="[
route.path === item.path
? 'bg-brand-primary/10 text-brand-primary font-medium'
: 'text-zinc-400 hover:text-white hover:bg-white/5'
]"
>
<component :is="item.icon" :size="18" :stroke-width="route.path === item.path ? 2.5 : 2" />
{{ item.name }}
</router-link>
</nav>
<div class="p-4 border-t border-white/10">
<button class="flex items-center gap-3 px-4 py-3 w-full rounded-xl text-zinc-400 hover:text-white hover:bg-white/5 transition-all">
<Settings :size="18" />
<span>Settings</span>
</button>
<button class="flex items-center gap-3 px-4 py-3 w-full rounded-xl text-red-400/80 hover:text-red-400 hover:bg-red-500/10 transition-all mt-1">
<LogOut :size="18" />
<span>Logout</span>
</button>
</div>
</aside>
<!-- Main Content Area -->
<main class="flex-1 flex flex-col h-full overflow-hidden relative">
<div class="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-[0.03] mix-blend-overlay pointer-events-none"></div>
<!-- Topbar (Optional, currently just acts as spacing/header area handled inside views) -->
<!-- Page Content -->
<div class="flex-1 overflow-y-auto px-8 py-8 relative z-10">
<router-view v-slot="{ Component }">
<transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="transform translate-y-4 opacity-0"
enter-to-class="transform translate-y-0 opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="transform translate-y-0 opacity-100"
leave-to-class="transform translate-y-4 opacity-0"
mode="out-in"
>
<component :is="Component" />
</transition>
</router-view>
</div>
</main>
</div>
</template>

1
web/src/assets/vue.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

10
web/src/main.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import './style.css'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

33
web/src/router/index.ts Normal file
View File

@@ -0,0 +1,33 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue')
},
{
path: '/',
redirect: '/dashboard',
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('../views/Dashboard.vue')
},
{
path: '/radar',
name: 'Radar',
component: () => import('../views/Radar.vue')
},
{
path: '/history',
name: 'History',
component: () => import('../views/History.vue')
}
]
})
export default router

24
web/src/style.css Normal file
View File

@@ -0,0 +1,24 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-[#0f172a] text-zinc-200 antialiased;
}
}
/* Glassmorphism utility */
.glass-panel {
@apply bg-[#1e293b]/80 backdrop-blur-md border border-[#334155] rounded-2xl shadow-xl;
}
/* Animations */
@keyframes fade-in-up {
0% { opacity: 0; transform: translateY(10px); }
100% { opacity: 1; transform: translateY(0); }
}
.animate-fade-in-up {
animation: fade-in-up 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}

149
web/src/views/Dashboard.vue Normal file
View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
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(true)
const error = ref('')
const loadHotTweets = async () => {
const token = localStorage.getItem('jwt_token')
if (!token) {
router.push('/login')
return
}
loading.value = true
error.value = ''
try {
const response = await fetch(`${API_BASE}/tweets/hot`, {
headers: { 'Authorization': `Bearer ${token}` }
})
if (response.status === 401) {
localStorage.removeItem('jwt_token')
router.push('/login')
return
}
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) => {
window.open(`https://twitter.com/${handle}/status/${tweetId}`, '_blank')
}
onMounted(() => {
loadHotTweets()
})
</script>
<template>
<div class="max-w-[1400px] mx-auto space-y-8 animate-fade-in-up">
<!-- Header -->
<header class="flex justify-between items-end pb-4 border-b border-white/5">
<div>
<h1 class="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
🔥 Pipeline
<span class="text-sm font-medium px-2.5 py-1 bg-brand-primary/20 text-brand-primary rounded-full">{{ tweets.length }} leads</span>
</h1>
<p class="text-zinc-500 mt-2">Prioritized X threads bubbling up from your monitoring radar.</p>
</div>
<button
@click="loadHotTweets"
class="px-4 py-2 bg-white/5 border border-white/10 text-white rounded-lg hover:bg-white/10 transition-colors flex items-center gap-2"
>
<span v-if="loading" class="animate-spin inline-block"></span>
<span v-else> Refresh</span>
</button>
</header>
<!-- 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-12 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-xl text-white font-medium mb-2">No hot tweets found</h3>
<p class="text-sm text-zinc-400 max-w-sm">
The scraper is building radar momentum. Check back later to see rising opportunities.
</p>
</div>
<!-- Pinterest Style Grid -->
<div v-else class="columns-1 md:columns-2 lg:columns-3 xl:columns-4 gap-6 space-y-6">
<div
v-for="tweet in tweets"
:key="tweet.id"
class="break-inside-avoid glass-panel p-5 relative group flex flex-col transition-all duration-300 hover:border-brand-primary/50 hover:shadow-[0_10px_40px_rgba(59,130,246,0.1)] hover:-translate-y-1"
>
<!-- Header -->
<div class="flex justify-between items-start mb-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-zinc-700 to-zinc-800 flex items-center justify-center font-bold text-white shadow-inner">
{{ tweet.author_handle.charAt(0).toUpperCase() }}
</div>
<div class="flex flex-col">
<span class="font-bold text-white tracking-wide text-sm">@{{ tweet.author_handle }}</span>
<span class="text-[11px] text-zinc-500 font-mono">{{ new Date(tweet.posted_at).toLocaleDateString() }}</span>
</div>
</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-full text-xs font-bold font-mono shadow-sm">
<span>🔥</span> {{ tweet.heat_score }}
</div>
</div>
<!-- Body -->
<p class="text-zinc-300 text-sm leading-relaxed mb-6">
{{ tweet.content }}
</p>
<!-- Footer -->
<div class="mt-auto flex items-center justify-between border-t border-white/5 pt-4">
<div class="flex gap-4 text-xs text-zinc-400 font-mono">
<span title="Replies" class="flex items-center gap-1"><span class="opacity-50">💬</span> {{ tweet.reply_count }}</span>
<span title="Retweets" class="flex items-center gap-1"><span class="opacity-50">🔁</span> {{ tweet.retweet_count }}</span>
<span title="Likes" class="flex items-center gap-1"><span class="opacity-50"></span> {{ tweet.like_count }}</span>
</div>
<button
@click="openTweet(tweet.author_handle, tweet.x_tweet_id)"
class="px-4 py-1.5 bg-white/5 hover:bg-brand-primary text-white text-xs font-medium rounded-lg transition-all duration-300 border border-white/10 hover:border-transparent hover:shadow-lg hover:shadow-brand-primary/20"
>
Reply
</button>
</div>
</div>
</div>
</div>
</template>

116
web/src/views/History.vue Normal file
View File

@@ -0,0 +1,116 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
interface GeneratedReply {
id: string
tweet_id: string
strategy_type: string
content: string
status: string
created_at: string
// Assuming a join or performance data is available, for now, we'll mock metrics or show pending status
}
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'
const replies = ref<GeneratedReply[]>([])
const loading = ref(true)
const error = ref('')
const loadHistory = async () => {
const token = localStorage.getItem('jwt_token')
if (!token) return
loading.value = true
try {
const res = await fetch(`${API_BASE}/replies`, {
headers: { 'Authorization': `Bearer ${token}` }
})
if (!res.ok) throw new Error('Failed to fetch history')
replies.value = await res.json()
} catch (err: any) {
error.value = err.message
} finally {
loading.value = false
}
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'copied': return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/20'
case 'posted': return 'bg-green-500/20 text-green-400 border-green-500/20'
default: return 'bg-zinc-500/20 text-zinc-400 border-zinc-500/20'
}
}
onMounted(() => {
loadHistory()
})
</script>
<template>
<div class="max-w-[1000px] mx-auto space-y-8 animate-fade-in-up">
<!-- Header -->
<header class="flex justify-between items-end pb-4 border-b border-white/5">
<div>
<h1 class="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
AI Generation History
<span class="text-sm font-medium px-2.5 py-1 bg-brand-primary/20 text-brand-primary rounded-full">{{ replies.length }} Entries</span>
</h1>
<p class="text-zinc-500 mt-2">Track the live performance of your automated X replies.</p>
</div>
<button
@click="loadHistory"
class="px-4 py-2 bg-white/5 border border-white/10 text-white rounded-lg hover:bg-white/10 transition-colors flex items-center gap-2"
>
<span v-if="loading" class="animate-spin inline-block"></span>
<span v-else> Refresh</span>
</button>
</header>
<div v-if="loading" class="text-zinc-500 py-12 text-center">Reconstructing timeline...</div>
<!-- Timeline List -->
<div v-else-if="replies.length > 0" class="space-y-4">
<div
v-for="r in replies"
:key="r.id"
class="glass-panel p-5 border border-white/10 hover:border-brand-primary/30 transition-all flex flex-col gap-3"
>
<div class="flex justify-between items-center">
<div class="flex items-center gap-3">
<span class="text-xs font-bold font-mono tracking-wider bg-brand-primary/10 text-brand-primary px-2 py-1 rounded">
{{ r.strategy_type }}
</span>
<span class="text-xs text-zinc-500 border border-white/10 px-2 py-1 rounded-full border-dashed">
Target ID: <span class="font-mono text-zinc-400">{{ r.tweet_id.substring(0,8) }}...</span>
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-[11px] text-zinc-500 font-mono">{{ new Date(r.created_at).toLocaleString() }}</span>
<span :class="`border px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider ${getStatusBadge(r.status)}`">
{{ r.status }}
</span>
</div>
</div>
<p class="text-zinc-300 text-sm leading-relaxed border-l-2 border-brand-primary/20 pl-4 py-1 italic bg-white/[0.02] rounded-r-lg">
"{{ r.content }}"
</p>
<div class="flex items-center justify-between border-t border-white/5 pt-3 mt-1">
<div class="flex gap-6 text-xs text-zinc-500 font-mono">
<span class="flex items-center gap-1"> <span class="text-zinc-300">Evaluating in 24h</span></span>
<span class="flex items-center gap-1">💬 <span class="text-zinc-300">Pending Worker</span></span>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="flex flex-col items-center justify-center py-20 bg-white/5 border border-white/10 rounded-2xl border-dashed">
<span class="text-4xl mb-4 opacity-50">📝</span>
<h3 class="text-lg font-medium text-white mb-2">No generations yet</h3>
<p class="text-zinc-400 text-sm max-w-sm text-center">Copies made from the browser extension will be logged here autonomously for performance tracking.</p>
</div>
</div>
</template>

66
web/src/views/Login.vue Normal file
View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { Activity } from 'lucide-vue-next'
const router = useRouter()
const email = ref('')
const password = ref('')
const errorMsg = ref('')
const loading = ref(false)
const handleLogin = async () => {
errorMsg.value = ''
loading.value = true
const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'
try {
const res = await fetch(`${apiBase}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value, password: password.value })
})
if (!res.ok) throw new Error('Invalid credentials')
const data = await res.json()
if (data.token) {
localStorage.setItem('jwt_token', data.token)
router.push('/dashboard')
}
} catch (err: any) {
errorMsg.value = err.message || 'Login failed'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="h-screen w-full flex items-center justify-center relative bg-[#0B1120]">
<div class="glass-panel w-full max-w-sm p-8 z-10 flex flex-col items-center">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-brand-primary to-blue-600 flex items-center justify-center mb-6">
<Activity color="white" :size="24" />
</div>
<h2 class="text-2xl font-bold text-white mb-2">Welcome Back</h2>
<p class="text-zinc-400 text-sm text-center mb-8">Sign in to orchestrate your AI reply strategy.</p>
<form @submit.prevent="handleLogin" class="w-full space-y-4">
<div v-if="errorMsg" class="bg-red-500/10 text-red-400 p-3 rounded-lg text-sm text-center">
{{ errorMsg }}
</div>
<div>
<label class="text-xs font-semibold text-zinc-500 uppercase tracking-widest block mb-2">Email</label>
<input v-model="email" type="email" required placeholder="founder@startup.com" class="w-full bg-black/20 border border-white/10 rounded-lg px-4 py-3 text-white placeholder-zinc-600 focus:outline-none focus:border-brand-primary/50 focus:ring-1 focus:ring-brand-primary/50 transition-all cursor-text pointer-events-auto" style="pointer-events: auto;" />
</div>
<div>
<label class="text-xs font-semibold text-zinc-500 uppercase tracking-widest block mb-2">Password</label>
<input v-model="password" type="password" required placeholder="••••••••" class="w-full bg-black/20 border border-white/10 rounded-lg px-4 py-3 text-white placeholder-zinc-600 focus:outline-none focus:border-brand-primary/50 focus:ring-1 focus:ring-brand-primary/50 transition-all cursor-text pointer-events-auto" style="pointer-events: auto;" />
</div>
<button type="submit" :disabled="loading" class="w-full bg-brand-primary hover:bg-blue-500 disabled:opacity-50 text-white font-medium rounded-lg px-4 py-3 mt-4 transition-all hover:shadow-[0_0_20px_rgba(59,130,246,0.3)] duration-300">
{{ loading ? 'Signing In...' : 'Sign In' }}
</button>
</form>
</div>
</div>
</template>

190
web/src/views/Radar.vue Normal file
View File

@@ -0,0 +1,190 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
interface Monitor {
id: string
brand_name: string
x_handle: string
is_active: boolean
created_at: string
}
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'
const monitors = ref<Monitor[]>([])
const loading = ref(true)
const error = ref('')
const newKeyword = ref('')
const newHandle = ref('')
const adding = ref(false)
const loadMonitors = async () => {
const token = localStorage.getItem('jwt_token')
if (!token) return
loading.value = true
try {
const res = await fetch(`${API_BASE}/monitors`, {
headers: { 'Authorization': `Bearer ${token}` }
})
if (!res.ok) throw new Error('Failed to fetch monitors')
monitors.value = await res.json()
} catch (err: any) {
error.value = err.message
} finally {
loading.value = false
}
}
const addMonitor = async () => {
if (!newKeyword.value && !newHandle.value) return
const token = localStorage.getItem('jwt_token')
adding.value = true
try {
const res = await fetch(`${API_BASE}/monitors`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
brand_name: newKeyword.value,
x_handle: newHandle.value.replace('@', '')
})
})
if (!res.ok) throw new Error('Failed to add target')
newKeyword.value = ''
newHandle.value = ''
await loadMonitors()
} catch (err: any) {
alert(err.message)
} finally {
adding.value = false
}
}
const toggleMonitor = async (m: Monitor) => {
const token = localStorage.getItem('jwt_token')
try {
await fetch(`${API_BASE}/monitors/${m.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ is_active: !m.is_active })
})
m.is_active = !m.is_active
} catch (err) {
console.error(err)
}
}
const deleteMonitor = async (id: string) => {
if (!confirm('Remove this radar target?')) return
const token = localStorage.getItem('jwt_token')
try {
await fetch(`${API_BASE}/monitors/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
})
monitors.value = monitors.value.filter(m => m.id !== id)
} catch (err) {
console.error(err)
}
}
onMounted(() => {
loadMonitors()
})
</script>
<template>
<div class="max-w-[1400px] mx-auto space-y-8 animate-fade-in-up">
<!-- Header & Add Form -->
<header class="flex flex-col md:flex-row md:justify-between items-start md:items-end gap-6 pb-6 border-b border-white/5">
<div>
<h1 class="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
📡 X Radar Targets
<span class="text-sm font-medium px-2.5 py-1 bg-indigo-500/20 text-indigo-400 rounded-full">{{ monitors.length }} Active</span>
</h1>
<p class="text-zinc-500 mt-2 text-sm max-w-lg">
Configure Nitter scraper targets. The engine will autonomously monitor these accounts and keywords for high-potential engagement opportunities.
</p>
</div>
<form @submit.prevent="addMonitor" class="flex items-center gap-3 bg-white/5 p-2 rounded-xl border border-white/10 w-full md:w-auto">
<input
v-model="newHandle"
placeholder="X Handle (e.g. elonmusk)"
class="bg-black/20 text-white text-sm rounded-lg px-3 py-2 border border-white/10 w-40 focus:outline-none focus:border-brand-primary/50 pointer-events-auto" style="pointer-events: auto;"
/>
<span class="text-zinc-600 text-sm font-bold">+</span>
<input
v-model="newKeyword"
placeholder="Keyword Combo (Optional)"
class="bg-black/20 text-white text-sm rounded-lg px-3 py-2 border border-white/10 w-48 focus:outline-none focus:border-brand-primary/50 pointer-events-auto" style="pointer-events: auto;"
/>
<button
type="submit"
:disabled="adding || (!newKeyword && !newHandle)"
class="bg-brand-primary hover:bg-blue-500 disabled:opacity-50 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap"
>
Add Target
</button>
</form>
</header>
<div v-if="loading" class="text-zinc-500 py-12 text-center">Loading radar grid...</div>
<!-- Active Grid -->
<div v-else-if="monitors.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div
v-for="m in monitors"
:key="m.id"
class="glass-panel p-5 border border-white/10 relative group transition-all"
:class="m.is_active ? 'hover:border-indigo-500/30' : 'opacity-60 grayscale'"
>
<div class="flex justify-between items-start mb-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-indigo-500/20 to-purple-600/20 border border-indigo-500/20 flex items-center justify-center font-bold text-indigo-400">
{{ m.x_handle ? m.x_handle.charAt(0).toUpperCase() : '#' }}
</div>
<div class="flex flex-col">
<span class="font-bold text-white tracking-wide text-sm truncate max-w-[120px]" :title="m.x_handle || 'Global Search'">
{{ m.x_handle ? '@' + m.x_handle : 'Global Search' }}
</span>
<span class="text-xs text-zinc-500">Target</span>
</div>
</div>
<!-- Toggle -->
<button @click="toggleMonitor(m)" class="relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none" :class="m.is_active ? 'bg-indigo-500' : 'bg-white/10'">
<span aria-hidden="true" class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" :class="m.is_active ? 'translate-x-4' : 'translate-x-0'"></span>
</button>
</div>
<div class="bg-black/20 rounded-lg p-3 border border-white/5 mb-4">
<span class="text-xs font-semibold text-zinc-500 uppercase tracking-widest block mb-1">Intersection Keyword</span>
<span class="text-sm text-zinc-300 font-mono">{{ m.brand_name || '— (All Posts)' }}</span>
</div>
<div class="flex justify-between items-center border-t border-white/5 pt-4 mt-auto">
<span class="text-[10px] text-zinc-500 font-mono">{{ new Date(m.created_at).toLocaleDateString() }}</span>
<button @click="deleteMonitor(m.id)" class="text-xs text-red-400/50 hover:text-red-400 transition-colors pointer-events-auto" style="pointer-events: auto;">Delete</button>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="flex flex-col items-center justify-center py-20 bg-white/5 border border-white/10 rounded-2xl border-dashed">
<span class="text-4xl mb-4 opacity-50">🛰</span>
<h3 class="text-lg font-medium text-white mb-2">Radar is offline</h3>
<p class="text-zinc-400 text-sm max-w-sm text-center">Add a competitor's X handle or an intersection keyword to initialize the autonomous scraping engine.</p>
</div>
</div>
</template>

24
web/tailwind.config.js Normal file
View File

@@ -0,0 +1,24 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
brand: {
primary: '#3b82f6', // Bright professional blue
secondary: '#10b981', // Success green
dark: '#0f172a', // Deep slate
panel: '#1e293b', // Slightly lighter slate
border: '#334155'
}
},
fontFamily: {
sans: ['Inter', 'system-ui', 'Avenir', 'Helvetica', 'Arial', 'sans-serif'],
}
},
},
plugins: [],
}

16
web/tsconfig.app.json Normal file
View File

@@ -0,0 +1,16 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
web/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
web/vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})