├── report.pdf ├── .gitmodules ├── extension ├── images │ └── page-ai-line.png ├── manifest.json ├── popup.html ├── popup.css ├── background.js └── popup.js ├── backend ├── utils │ ├── utils.go │ ├── crypto.go │ └── logs │ │ └── logs.go ├── controllers │ ├── health.go │ ├── index.go │ ├── middleware │ │ └── auth.go │ ├── auth │ │ └── auth.go │ ├── controllers.go │ ├── user.go │ └── collection.go ├── Dockerfile ├── .gitignore ├── scripts │ └── toc.pl ├── shared │ ├── server │ │ └── server.go │ ├── yamlconfig │ │ └── yamlconfig.go │ ├── miniprogram │ │ └── miniprogram.go │ └── llmprocessor │ │ └── llmprocessor.go ├── main.go ├── model │ ├── model.go │ ├── database.go │ ├── tag.go │ ├── user.go │ ├── collection_with_tag.go │ └── collection.go ├── go.mod ├── config-default.yml ├── router │ └── router.go ├── docs │ └── api.md └── go.sum ├── frontend ├── src │ ├── index.tsx │ ├── components │ │ ├── Common │ │ │ ├── ErrorMessage.tsx │ │ │ ├── Loading.tsx │ │ │ └── NoticeDialog.tsx │ │ ├── Collection │ │ │ ├── Summary.tsx │ │ │ ├── AddCollection.tsx │ │ │ └── CollectionList.tsx │ │ ├── Layout │ │ │ └── Header.tsx │ │ └── Auth │ │ │ ├── Login.tsx │ │ │ └── Register.tsx │ ├── services │ │ └── api.ts │ ├── App.tsx │ └── context │ │ └── AuthContext.tsx ├── setup-commands.sh ├── tsconfig.json ├── public │ ├── index.html │ └── electron.js ├── .gitignore └── package.json ├── README.md └── LICENSE /report.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligen131/ToRead/HEAD/report.pdf -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "docs"] 2 | path = docs 3 | url = https://github.com/ligen131/undergraduate-thesis.git 4 | -------------------------------------------------------------------------------- /extension/images/page-ai-line.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligen131/ToRead/HEAD/extension/images/page-ai-line.png -------------------------------------------------------------------------------- /backend/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "time" 4 | 5 | func Now() string { 6 | return time.Now().Format("2006-01-02 03:04:05 PM") 7 | } 8 | -------------------------------------------------------------------------------- /backend/controllers/health.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "to-read/utils/logs" 5 | 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | func HealthGET(c echo.Context) error { 10 | logs.Debug("GET /health") 11 | 12 | return ResponseOK(c, "ok") 13 | } 14 | -------------------------------------------------------------------------------- /backend/utils/crypto.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | ) 7 | 8 | // GetMD5 calculates the MD5 hash of a given string 9 | func GetMD5(str string) string { 10 | h := md5.New() 11 | h.Write([]byte(str)) 12 | return hex.EncodeToString(h.Sum(nil)) 13 | } 14 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.20-alpine3.17 as builder 2 | COPY . /src 3 | WORKDIR /src 4 | ENV GOPROXY "https://goproxy.cn" 5 | RUN go build -o /build/to-read . 6 | 7 | FROM alpine:3.17 as prod 8 | COPY --from=builder /build/to-read /usr/bin/to-read 9 | WORKDIR /app 10 | ENTRYPOINT [ "to-read" ] 11 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | 5 | const root = ReactDOM.createRoot( 6 | document.getElementById('root') as HTMLElement 7 | ); 8 | 9 | root.render( 10 | 11 | 12 | 13 | ); -------------------------------------------------------------------------------- /frontend/setup-commands.sh: -------------------------------------------------------------------------------- 1 | # 创建项目目录 2 | mkdir toread-app 3 | cd toread-app 4 | 5 | # 初始化npm项目 6 | npm init -y 7 | 8 | # 安装必要的依赖 9 | npm install --save electron electron-builder react react-dom react-router-dom @mui/material @mui/icons-material @emotion/react @emotion/styled axios moment jwt-decode 10 | npm install --save-dev electron-is-dev concurrently wait-on cross-env 11 | npm install --save-dev @types/react @types/react-dom typescript -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | tmp 18 | %LocalAppData% 19 | data 20 | pkg 21 | 22 | node_modules 23 | 24 | config.yml -------------------------------------------------------------------------------- /backend/controllers/index.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "to-read/utils/logs" 5 | 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | type documentLink struct { 10 | Doc string `json:"document"` 11 | } 12 | 13 | type link struct { 14 | Link documentLink `json:"link"` 15 | } 16 | 17 | func IndexGET(c echo.Context) error { 18 | logs.Debug("GET /") 19 | 20 | return ResponseOK(c, link{ 21 | Link: documentLink{ 22 | Doc: "https://github.com/ligen131/ToRead/blob/main/backend/docs/api.md", 23 | }, 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"] 20 | } -------------------------------------------------------------------------------- /frontend/src/components/Common/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Alert, AlertTitle } from '@mui/material'; 3 | 4 | interface ErrorMessageProps { 5 | message: string; 6 | severity?: 'error' | 'warning' | 'info' | 'success'; 7 | } 8 | 9 | const ErrorMessage: React.FC = ({ 10 | message, 11 | severity = 'error', 12 | }) => { 13 | return ( 14 | 15 | {severity === 'error' ? '错误' : '提示'} 16 | {message} 17 | 18 | ); 19 | }; 20 | 21 | export default ErrorMessage; -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | ToRead 9 | 13 | 14 | 15 | 16 |
17 | 18 | -------------------------------------------------------------------------------- /frontend/src/components/Common/Loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, CircularProgress, Typography } from '@mui/material'; 3 | 4 | interface LoadingProps { 5 | message?: string; 6 | } 7 | 8 | const Loading: React.FC = ({ message = '加载中...' }) => { 9 | return ( 10 | 18 | 19 | 20 | {message} 21 | 22 | 23 | ); 24 | }; 25 | 26 | export default Loading; -------------------------------------------------------------------------------- /extension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "ToRead", 4 | "version": "1.0", 5 | "description": "Save current page to ToRead collection", 6 | "permissions": [ 7 | "activeTab", 8 | "storage", 9 | "tabs" 10 | ], 11 | "background": { 12 | "service_worker": "background.js" 13 | }, 14 | "action": { 15 | "default_popup": "popup.html", 16 | "default_icon": { 17 | "16": "images/page-ai-line.png", 18 | "48": "images/page-ai-line.png", 19 | "128": "images/page-ai-line.png" 20 | } 21 | }, 22 | "icons": { 23 | "16": "images/page-ai-line.png", 24 | "48": "images/page-ai-line.png", 25 | "128": "images/page-ai-line.png" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /backend/scripts/toc.pl: -------------------------------------------------------------------------------- 1 | use v5.12; 2 | use utf8; 3 | use open ':utf8'; 4 | use open ':std', ':utf8'; 5 | 6 | binmode(STDOUT,":encoding(utf8)"); 7 | 8 | my @subtitle_number; 9 | say "# 目录"; 10 | while (<>) { 11 | # next if /^```/ ... /^```/; 12 | if (/^#(#+)\s*(.*?)\s*$/) { 13 | my ($level, $title) = (length($1), $2); 14 | 15 | my $indent = " " x $level; 16 | 17 | my $id = $title; 18 | $id =~ s/[^_[:^punct:]]//g; 19 | $id =~ s/[[:space:]]/-/g; 20 | $id = lc $id; 21 | 22 | @subtitle_number = splice @subtitle_number, 0, $level; 23 | $subtitle_number[$level - 1] += 1; 24 | my $subtitle_number = join ".", @subtitle_number; 25 | 26 | say "$indent+ $subtitle_number [$title](#$id)"; 27 | } 28 | } 29 | say ""; -------------------------------------------------------------------------------- /backend/shared/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "strconv" 5 | "to-read/router" 6 | "to-read/utils/logs" 7 | 8 | "github.com/labstack/echo/v4" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | type Server struct { 13 | Hostname string `yaml:"hostname"` 14 | Port int `yaml:"port"` 15 | } 16 | 17 | func Run(s Server) error { 18 | e := echo.New() 19 | 20 | // Router and middleware 21 | router.Load(e) 22 | 23 | // Default Configurations 24 | if s.Hostname == "" { 25 | s.Hostname = "127.0.0.1" 26 | } 27 | if s.Port == 0 { 28 | s.Port = 3435 29 | } 30 | 31 | address := s.Hostname + ":" + strconv.Itoa(s.Port) 32 | err := e.Start(address) 33 | if err != nil { 34 | logs.Error("Server run failed at "+address+". ", zap.Error(err)) 35 | return err 36 | } 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /backend/utils/logs/logs.go: -------------------------------------------------------------------------------- 1 | package logs 2 | 3 | import ( 4 | "go.uber.org/zap" 5 | "go.uber.org/zap/zapcore" 6 | ) 7 | 8 | type LogLevel int 9 | 10 | const ( 11 | ERROR LogLevel = 0 12 | WARN LogLevel = 1 13 | INFO LogLevel = 2 14 | DEBUG LogLevel = 3 15 | ) 16 | 17 | const logLevel LogLevel = DEBUG 18 | 19 | var logger *zap.Logger 20 | 21 | func init() { 22 | logger, _ = zap.NewDevelopment() 23 | } 24 | 25 | func Debug(msg string, fields ...zapcore.Field) { 26 | if logLevel >= DEBUG { 27 | logger.Debug(msg, fields...) 28 | } 29 | } 30 | 31 | func Info(msg string, fields ...zapcore.Field) { 32 | if logLevel >= INFO { 33 | logger.Info(msg, fields...) 34 | } 35 | } 36 | 37 | func Warn(msg string, fields ...zapcore.Field) { 38 | if logLevel >= WARN { 39 | logger.Warn(msg, fields...) 40 | } 41 | } 42 | 43 | func Error(msg string, fields ...zapcore.Field) { 44 | if logLevel >= ERROR { 45 | logger.Error(msg, fields...) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /backend/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "to-read/controllers/auth" 5 | "to-read/model" 6 | "to-read/shared/llmprocessor" 7 | "to-read/shared/server" 8 | "to-read/shared/yamlconfig" 9 | ) 10 | 11 | func main() { 12 | configuration, err := yamlconfig.ConfigLoad("config.yml") 13 | if err != nil { 14 | panic(err) 15 | } 16 | 17 | err = model.Connect(configuration.Database) 18 | if err != nil { 19 | panic(err) 20 | } 21 | 22 | err = model.InitModel() 23 | if err != nil { 24 | panic(err) 25 | } 26 | 27 | err = auth.InitAuthorization(configuration.Authorization) 28 | if err != nil { 29 | panic(err) 30 | } 31 | 32 | err = llmprocessor.InitLLMProcessor(configuration.LLMProcessor) 33 | if err != nil { 34 | panic(err) 35 | } 36 | 37 | // err = miniprogram.InitMiniProgramConfig(configuration.Miniprogram) 38 | // if err != nil { 39 | // panic(err) 40 | // } 41 | 42 | err = server.Run(configuration.Server) 43 | if err != nil { 44 | panic(err) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /backend/model/model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "gorm.io/gorm" 8 | ) 9 | 10 | var ( 11 | db *gorm.DB 12 | ) 13 | 14 | type Model struct { 15 | tx *gorm.DB 16 | context context.Context 17 | cancel context.CancelFunc 18 | } 19 | 20 | func GetModel() *Model { 21 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 22 | return &Model{ 23 | tx: db.Begin().WithContext(ctx), 24 | context: ctx, 25 | cancel: cancel, 26 | } 27 | } 28 | 29 | func InitModel() error { 30 | err := AutoMigrateTable(&User{}) 31 | if err != nil { 32 | return err 33 | } 34 | err = AutoMigrateTable(&Collection{}) 35 | if err != nil { 36 | return err 37 | } 38 | err = AutoMigrateTable(&Tag{}) 39 | if err != nil { 40 | return err 41 | } 42 | err = AutoMigrateTable(&CollectionWithTag{}) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | return nil 48 | } 49 | 50 | func (m *Model) Close() { 51 | if r := recover(); r != nil { 52 | m.tx.Rollback() 53 | } 54 | m.cancel() 55 | } 56 | 57 | func (m *Model) Abort() { 58 | m.tx.Rollback() 59 | m.cancel() 60 | } 61 | -------------------------------------------------------------------------------- /frontend/public/electron.js: -------------------------------------------------------------------------------- 1 | const { app, BrowserWindow, shell } = require('electron'); 2 | const path = require('path'); 3 | const isDev = require('electron-is-dev'); 4 | 5 | let mainWindow; 6 | 7 | function createWindow() { 8 | mainWindow = new BrowserWindow({ 9 | width: 1200, 10 | height: 800, 11 | webPreferences: { 12 | nodeIntegration: true, 13 | contextIsolation: false 14 | } 15 | }); 16 | 17 | const startURL = isDev 18 | ? 'http://localhost:3000' 19 | : `file://${path.join(__dirname, '../build/index.html')}`; 20 | 21 | mainWindow.loadURL(startURL); 22 | 23 | // 打开开发者工具 24 | if (isDev) { 25 | mainWindow.webContents.openDevTools(); 26 | } 27 | 28 | // 处理外部链接,在默认浏览器中打开 29 | mainWindow.webContents.setWindowOpenHandler(({ url }) => { 30 | shell.openExternal(url); 31 | return { action: 'deny' }; 32 | }); 33 | 34 | mainWindow.on('closed', () => { 35 | mainWindow = null; 36 | }); 37 | } 38 | 39 | app.on('ready', createWindow); 40 | 41 | app.on('window-all-closed', () => { 42 | if (process.platform !== 'darwin') { 43 | app.quit(); 44 | } 45 | }); 46 | 47 | app.on('activate', () => { 48 | if (mainWindow === null) { 49 | createWindow(); 50 | } 51 | }); -------------------------------------------------------------------------------- /backend/controllers/middleware/auth.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "time" 5 | "to-read/controllers" 6 | "to-read/controllers/auth" 7 | "to-read/model" 8 | 9 | "github.com/labstack/echo/v4" 10 | "gorm.io/gorm" 11 | ) 12 | 13 | func TokenVerificationMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 14 | return func(c echo.Context) error { 15 | claims, err := auth.GetClaimsFromHeader(c) 16 | if err != nil { 17 | return controllers.ResponseUnauthorized(c, "Invalid bearer token in header.", err) 18 | } 19 | if claims.Valid() != nil { 20 | return controllers.ResponseUnauthorized(c, "Invalid jwt token.", claims.Valid()) 21 | } 22 | 23 | if claims.ExpiresAt < time.Now().Unix() { 24 | return controllers.ResponseUnauthorized(c, "Token expired.", nil) 25 | } 26 | 27 | user, err := model.FindUserByID(claims.UserID) 28 | if err != nil { 29 | if err == gorm.ErrRecordNotFound { 30 | return controllers.ResponseUnauthorized(c, "User in token not found.", err) 31 | } 32 | return controllers.ResponseInternalServerError(c, "Find user by ID failed.", err) 33 | } 34 | if user.Role != claims.Role { 35 | return controllers.ResponseUnauthorized(c, "UserID does not match user's Role.", err) 36 | } 37 | 38 | return next(c) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /backend/model/database.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "strconv" 5 | "to-read/utils/logs" 6 | 7 | "go.uber.org/zap" 8 | "gorm.io/driver/postgres" 9 | "gorm.io/gorm" 10 | ) 11 | 12 | type Database struct { 13 | Hostname string `yaml:"hostname"` 14 | Port int `yaml:"port"` 15 | User string `yaml:"user"` 16 | Password string `yaml:"password"` 17 | SslMode bool `yaml:"sslMode"` 18 | TimeZone string `yaml:"timeZone"` 19 | } 20 | 21 | func Connect(d Database) error { 22 | const DbName = "toread" 23 | isSSL := "disable" 24 | if d.SslMode { 25 | isSSL = "enable" 26 | } 27 | dsn := "host=" + d.Hostname + 28 | " user=" + d.User + 29 | " password=" + d.Password + 30 | " dbname=" + DbName + 31 | " port=" + strconv.Itoa(d.Port) + 32 | " sslmode=" + isSSL + 33 | " TimeZone=" + d.TimeZone 34 | var err error 35 | db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) 36 | if err != nil { 37 | logs.Error("Failed to connect database "+DbName+".", zap.Error(err)) 38 | } 39 | return err 40 | } 41 | 42 | // Almost used for creating tables 43 | func AutoMigrateTable(dst ...interface{}) error { 44 | for _, d := range dst { 45 | err := db.Migrator().AutoMigrate(d) 46 | if err != nil { 47 | logs.Error("Auto migrate table failed.", zap.Any("model", d), zap.Error(err)) 48 | return err 49 | } 50 | logs.Debug("Auto migrate table successfully. ", zap.Any("model", d)) 51 | } 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /backend/shared/yamlconfig/yamlconfig.go: -------------------------------------------------------------------------------- 1 | package yamlconfig 2 | 3 | import ( 4 | "to-read/controllers/auth" 5 | "to-read/model" 6 | "to-read/shared/llmprocessor" 7 | "to-read/shared/server" 8 | "to-read/utils/logs" 9 | 10 | "github.com/gookit/config/v2" 11 | "github.com/gookit/config/v2/yaml" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | type Configuration struct { 16 | Server server.Server `yaml:"server"` 17 | Database model.Database `yaml:"database"` 18 | Authorization auth.Authorization `yaml:"Authorization"` 19 | LLMProcessor llmprocessor.LLMConfig `yaml:"llmProcessor"` 20 | } 21 | 22 | func ConfigLoad(path string) (Configuration, error) { 23 | // 设置选项支持 ENV 解析 24 | config.WithOptions(config.ParseEnv) 25 | 26 | // 添加驱动程序以支持 yaml 内容解析 27 | config.AddDriver(yaml.Driver) 28 | config.WithOptions(func(opt *config.Options) { 29 | opt.DecoderConfig.TagName = "yaml" 30 | }) 31 | 32 | configuration := Configuration{} 33 | err := config.LoadFiles(path) 34 | if err != nil { 35 | logs.Error("Read config file from "+path+"failed. ", zap.Error(err)) 36 | return configuration, err 37 | } 38 | 39 | err = config.Decode(&configuration) 40 | if err != nil { 41 | logs.Error("Decode config file from "+path+"failed. ", zap.Error(err)) 42 | return configuration, err 43 | } 44 | logs.Info("Read config file from "+path, zap.Any("configuration", configuration)) 45 | 46 | return configuration, nil 47 | } 48 | -------------------------------------------------------------------------------- /extension/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ToRead 6 | 7 | 8 | 9 |
10 |

ToRead

11 | 12 | 13 |
14 |

登录

15 |
16 | 17 | 18 |
19 |
20 | 21 | 22 |
23 | 24 |

25 |
26 | 27 | 28 | 44 |
45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /backend/shared/miniprogram/miniprogram.go: -------------------------------------------------------------------------------- 1 | package miniprogram 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "time" 10 | ) 11 | 12 | var ( 13 | appId string 14 | appSecret string 15 | ) 16 | 17 | type MiniProgramConfig struct { 18 | AppId string `yaml:"app-id"` 19 | AppSecret string `yaml:"app-secret"` 20 | } 21 | 22 | func InitMiniProgramConfig(m MiniProgramConfig) error { 23 | if m.AppId == "" { 24 | return errors.New("appId should not be empty") 25 | } 26 | appId = m.AppId 27 | if m.AppSecret == "" { 28 | return errors.New("appSecret should not be empty") 29 | } 30 | appSecret = m.AppSecret 31 | return nil 32 | } 33 | 34 | type WxLoginResponse struct { 35 | SessionKey string `json:"session_key"` 36 | UnionId string `json:"unionid"` 37 | ErrorMessage string `json:"errmsg"` 38 | OpenID string `json:"openid"` 39 | ErrCode int32 `json:"errcode"` 40 | } 41 | 42 | func WxLogin(code string) (WxLoginResponse, error) { 43 | url := fmt.Sprintf( 44 | "https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code", 45 | appId, appSecret, code) 46 | client := &http.Client{ 47 | Timeout: 5 * time.Second, 48 | } 49 | resp, err := client.Get(url) 50 | if err != nil { 51 | return WxLoginResponse{}, err 52 | } 53 | defer resp.Body.Close() 54 | 55 | body, err := ioutil.ReadAll(resp.Body) 56 | if err != nil { 57 | return WxLoginResponse{}, err 58 | } 59 | 60 | var result WxLoginResponse 61 | json.Unmarshal(body, &result) 62 | return result, nil 63 | } 64 | -------------------------------------------------------------------------------- /backend/go.mod: -------------------------------------------------------------------------------- 1 | module to-read 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/golang-jwt/jwt v3.2.2+incompatible 7 | github.com/gookit/config/v2 v2.2.1 8 | github.com/labstack/echo/v4 v4.10.2 9 | go.uber.org/zap v1.24.0 10 | gorm.io/driver/postgres v1.5.0 11 | gorm.io/gorm v1.25.0 12 | ) 13 | 14 | require ( 15 | github.com/fatih/color v1.14.1 // indirect 16 | github.com/goccy/go-yaml v1.10.0 // indirect 17 | github.com/gookit/color v1.5.2 // indirect 18 | github.com/gookit/goutil v0.6.6 // indirect 19 | github.com/imdario/mergo v0.3.13 // indirect 20 | github.com/jackc/pgpassfile v1.0.0 // indirect 21 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 22 | github.com/jackc/pgx/v5 v5.3.0 // indirect 23 | github.com/jinzhu/inflection v1.0.0 // indirect 24 | github.com/jinzhu/now v1.1.5 // indirect 25 | github.com/labstack/gommon v0.4.0 // indirect 26 | github.com/mattn/go-colorable v0.1.13 // indirect 27 | github.com/mattn/go-isatty v0.0.17 // indirect 28 | github.com/mitchellh/mapstructure v1.5.0 // indirect 29 | github.com/stretchr/testify v1.8.2 // indirect 30 | github.com/valyala/bytebufferpool v1.0.0 // indirect 31 | github.com/valyala/fasttemplate v1.2.2 // indirect 32 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 33 | go.uber.org/atomic v1.7.0 // indirect 34 | go.uber.org/multierr v1.6.0 // indirect 35 | golang.org/x/crypto v0.6.0 // indirect 36 | golang.org/x/net v0.7.0 // indirect 37 | golang.org/x/sync v0.1.0 // indirect 38 | golang.org/x/sys v0.5.0 // indirect 39 | golang.org/x/term v0.5.0 // indirect 40 | golang.org/x/text v0.7.0 // indirect 41 | golang.org/x/time v0.3.0 // indirect 42 | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect 43 | ) 44 | -------------------------------------------------------------------------------- /backend/config-default.yml: -------------------------------------------------------------------------------- 1 | server: 2 | hostname: 127.0.0.1 3 | port: 3435 4 | 5 | database: 6 | hostname: host.docker.internal 7 | port: 5432 8 | user: postgres 9 | password: password 10 | sslMode: false 11 | timeZone: Asia/Shanghai 12 | 13 | Authorization: 14 | # Generate a random secret-key by the following shell: 15 | # $ echo $(dd if=/dev/urandom | base64 -w0 | dd bs=1 count=20 2>/dev/null) 16 | secret-key: XWpHQ0Q1fUM3H8M3ysmk 17 | refresh-secret-key: tuiBz7zIUQYKii+ncBdt 18 | 19 | llmProcessor: 20 | textProcessor: 21 | enabled: true 22 | apiEndpoint: "https://api.openai.com/v1/chat/completions" 23 | apiKey: "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 24 | model: "gpt-4o-mini" 25 | prompt: "你是一位网页内容总结专家,你需要对用户给出的网页内容总结为标题(title)、内容介绍(description)和标签(tags),介绍应当由若干句话组成而非分点列出。你必须将结果通过调用 extract_content_summary 的 function 的形式给出。" 26 | maxTokens: 1000 27 | temperature: 0.3 28 | 29 | imageProcessor: 30 | enabled: true 31 | apiEndpoint: "https://api.openai.com/v1/chat/completions" 32 | apiKey: "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 33 | model: "gpt-4o-mini" 34 | prompt: "你是一位网页内容总结专家,你需要对用户给出的网页内容总结为标题(title)、内容介绍(description)和标签(tags),介绍应当由若干句话组成而非分点列出。你必须将结果通过调用 extract_content_summary 的 function 的形式给出。" 35 | maxTokens: 800 36 | temperature: 0.4 37 | 38 | videoProcessor: 39 | enabled: true 40 | apiEndpoint: "https://api.openai.com/v1/chat/completions" 41 | apiKey: "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 42 | model: "gpt-4o-mini" 43 | prompt: "你是一位网页内容总结专家,你需要对用户给出的网页内容总结为标题(title)、内容介绍(description)和标签(tags),介绍应当由若干句话组成而非分点列出。你必须将结果通过调用 extract_content_summary 的 function 的形式给出。" 44 | maxTokens: 1200 45 | temperature: 0.3 46 | -------------------------------------------------------------------------------- /extension/popup.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Arial, sans-serif; 3 | margin: 0; 4 | padding: 0; 5 | width: 320px; 6 | } 7 | 8 | .container { 9 | padding: 16px; 10 | } 11 | 12 | h1 { 13 | color: #333; 14 | font-size: 20px; 15 | margin-top: 0; 16 | text-align: center; 17 | } 18 | 19 | h2 { 20 | font-size: 16px; 21 | margin-bottom: 12px; 22 | } 23 | 24 | h3 { 25 | font-size: 14px; 26 | margin-bottom: 8px; 27 | } 28 | 29 | .form-group { 30 | margin-bottom: 12px; 31 | } 32 | 33 | label { 34 | display: block; 35 | margin-bottom: 4px; 36 | } 37 | 38 | input[type="text"], 39 | input[type="password"] { 40 | width: 100%; 41 | padding: 8px; 42 | border: 1px solid #ccc; 43 | border-radius: 4px; 44 | box-sizing: border-box; 45 | } 46 | 47 | button { 48 | background-color: #4285f4; 49 | color: white; 50 | border: none; 51 | padding: 8px 12px; 52 | border-radius: 4px; 53 | cursor: pointer; 54 | font-size: 14px; 55 | } 56 | 57 | button:hover { 58 | background-color: #3367d6; 59 | } 60 | 61 | .message { 62 | margin: 12px 0; 63 | padding: 8px; 64 | border-radius: 4px; 65 | } 66 | 67 | .success { 68 | background-color: #d4edda; 69 | color: #155724; 70 | } 71 | 72 | .error { 73 | background-color: #f8d7da; 74 | color: #721c24; 75 | } 76 | 77 | .info { 78 | background-color: #d1ecf1; 79 | color: #0c5460; 80 | } 81 | 82 | .user-info { 83 | display: flex; 84 | justify-content: space-between; 85 | align-items: center; 86 | margin-bottom: 16px; 87 | } 88 | 89 | #logoutBtn { 90 | background-color: #f44336; 91 | } 92 | 93 | #logoutBtn:hover { 94 | background-color: #d32f2f; 95 | } 96 | 97 | .actions { 98 | margin-top: 16px; 99 | } 100 | 101 | .custom-url { 102 | padding-top: 8px; 103 | border-top: 1px solid #eee; 104 | } 105 | 106 | #customUrl { 107 | margin-bottom: 8px; 108 | } 109 | 110 | #saveCustomBtn { 111 | width: 100%; 112 | } -------------------------------------------------------------------------------- /backend/router/router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "to-read/controllers" 5 | "to-read/controllers/middleware" 6 | 7 | "github.com/labstack/echo/v4" 8 | echoMiddleware "github.com/labstack/echo/v4/middleware" 9 | ) 10 | 11 | func Load(e *echo.Echo) { 12 | routes(e) 13 | } 14 | 15 | func routes(e *echo.Echo) { 16 | e.Use(echoMiddleware.Recover()) 17 | e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{ 18 | AllowOrigins: []string{"*"}, 19 | AllowHeaders: []string{ 20 | echo.HeaderOrigin, 21 | echo.HeaderContentType, 22 | echo.HeaderAccept, 23 | echo.HeaderAuthorization, 24 | }, 25 | AllowMethods: []string{echo.GET, echo.HEAD, echo.PUT, echo.PATCH, echo.POST, echo.DELETE, echo.OPTIONS}, 26 | })) 27 | 28 | apiVersionUrl := "/api/v1" 29 | 30 | e.GET(apiVersionUrl+"", controllers.IndexGET) 31 | e.GET(apiVersionUrl+"/", controllers.IndexGET) 32 | 33 | e.GET(apiVersionUrl+"/health", controllers.HealthGET) 34 | 35 | userGroup := e.Group(apiVersionUrl + "/user") 36 | { 37 | userGroup.GET("", controllers.UserGET) 38 | userGroup.GET("/", controllers.UserGET) 39 | userGroup.POST("/login", controllers.UserLoginPOST) 40 | userGroup.POST("/register", controllers.UserRegisterPOST) 41 | userGroup.GET("/isauth", controllers.UserIsAuthGET, middleware.TokenVerificationMiddleware) 42 | } 43 | 44 | collectionGroup := e.Group(apiVersionUrl + "/collection") 45 | { 46 | collectionGroup.GET("", controllers.CollectionListGET, middleware.TokenVerificationMiddleware) 47 | collectionGroup.GET("/", controllers.CollectionListGET, middleware.TokenVerificationMiddleware) 48 | collectionGroup.GET("/list", controllers.CollectionListGET, middleware.TokenVerificationMiddleware) 49 | collectionGroup.POST("/add", controllers.CollectionAddPOST, middleware.TokenVerificationMiddleware) 50 | collectionGroup.GET("/summary", controllers.CollectionSummaryGET, middleware.TokenVerificationMiddleware) 51 | collectionGroup.GET("/tag", controllers.CollectionTagGET, middleware.TokenVerificationMiddleware) 52 | } 53 | 54 | gGroup := e.Group("/g") 55 | { 56 | gGroup.POST("", controllers.CollectionGPOST) 57 | gGroup.POST("/", controllers.CollectionGPOST) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /frontend/src/components/Collection/Summary.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { 3 | Paper, 4 | Typography, 5 | Box, 6 | CircularProgress 7 | } from '@mui/material'; 8 | import { collectionAPI } from '../../services/api'; 9 | import ErrorMessage from '../Common/ErrorMessage'; 10 | 11 | interface SummaryProps { 12 | searchQuery?: string; 13 | selectedTags?: string[]; 14 | } 15 | 16 | const Summary: React.FC = ({ searchQuery, selectedTags = [] }) => { 17 | const [summary, setSummary] = useState(''); 18 | const [loading, setLoading] = useState(true); 19 | const [error, setError] = useState(null); 20 | 21 | useEffect(() => { 22 | const fetchSummary = async () => { 23 | try { 24 | setLoading(true); 25 | setError(null); 26 | 27 | const params: { search?: string; tags?: string[] } = {}; 28 | if (searchQuery) { 29 | params.search = searchQuery; 30 | } 31 | if (selectedTags.length > 0) { 32 | params.tags = selectedTags; 33 | } 34 | 35 | const response = await collectionAPI.getSummary(params); 36 | 37 | if (response.code === 200) { 38 | setSummary(response.data.summary || '暂无总结内容'); 39 | } else { 40 | setError(response.msg || '获取总结失败'); 41 | } 42 | } catch (err: any) { 43 | setError(err.response?.data?.msg || '获取总结失败,请检查网络连接'); 44 | } finally { 45 | setLoading(false); 46 | } 47 | }; 48 | 49 | fetchSummary(); 50 | }, [searchQuery, selectedTags]); 51 | 52 | return ( 53 | 54 | 55 | 内容总结 56 | 57 | 58 | {loading ? ( 59 | 60 | 61 | 62 | ) : error ? ( 63 | 64 | ) : ( 65 | 66 | {summary} 67 | 68 | )} 69 | 70 | ); 71 | }; 72 | 73 | export default Summary; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | logo 2 | 3 | # ToRead 4 | 5 | [![Website](https://img.shields.io/website?down_message=offline&up_color=green&up_message=online&url=https%3A%2F%2Ftr.lg.gl)](https://tr.lg.gl) 6 | 7 | A simple Read-It-Later and link collection tool, AI-powered for text and images, multi-platform, open-source. A browser extension available for one-click bookmarking. 8 | 9 | Demo deployed at 10 | 11 | API document: 12 | 13 | API Endpoint: 14 | 15 | **Integrate ToRead into your application with just one line of code**: 16 | 17 | ```shell 18 | $ curl https://g.lg.gl --data '{"url": "https://lg.gl"}' 19 | ``` 20 | 21 | The url field can be any publicly accessible link. Please note that some content requiring CAPTCHA verification or with restricted permissions may not be accessible. This endpoint may be slow to response. 22 | 23 | This project was my undergraduate thesis at Huazhong University of Science and Technology (HUST). The current implementation is relatively simple and still has several bugs. The backend currently has no rate limiting or security checks, so please use it within a limited scope. 24 | 25 | If you like this project, please give it a Star! ⭐ 26 | 27 | ## Usage 28 | 29 | ### Browser Extension 30 | 31 | Download the repository or just the extension folder separately. 32 | 33 | Open your browser's extension settings, enable developer mode, and click "Load unpacked extension". Select the extension folder you downloaded. 34 | 35 | Unhide the newly loaded ToRead extension from the top-right corner of your browser. Open any webpage, click the ToRead icon in the top-right corner to automatically add the link to your bookmarks. Note that you need to log in for first-time use. 36 | 37 | ## Deploy 38 | 39 | ### Backend 40 | 41 | ```shell 42 | $ cp config-default.yml config.yml 43 | ``` 44 | 45 | Then mdify the configuration file `config.yml` to your needs. 46 | 47 | Using golang 1.18+ 48 | 49 | ```shell 50 | $ go mod tidy 51 | $ go run main.go 52 | ``` 53 | 54 | The backend will be running at `http://0.0.0.0:3435` by default. 55 | 56 | ### Frontend 57 | 58 | ```shell 59 | $ npm install -g yarn 60 | $ yarn 61 | $ npm run start 62 | ``` 63 | 64 | The frontend will be running at `http://0.0.0.0:3000` by default. 65 | 66 | ## LICENSE 67 | 68 | GNU General Public License v3.0 69 | -------------------------------------------------------------------------------- /frontend/src/components/Common/NoticeDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { 3 | Dialog, 4 | DialogTitle, 5 | DialogContent, 6 | DialogActions, 7 | Button, 8 | Typography, 9 | Box, 10 | Link 11 | } from '@mui/material'; 12 | 13 | interface NoticeDialogProps { 14 | open: boolean; 15 | onClose: () => void; 16 | } 17 | 18 | const NoticeDialog: React.FC = ({ open, onClose }) => { 19 | return ( 20 | 26 | 使用须知 27 | 28 | 29 | 如果你觉得这个项目还不错,求给我一个Star吧! 30 | 31 | 32 | 33 | 项目链接: 34 | 39 | https://github.com/ligen131/ToRead 40 | 41 | 42 | 43 | 44 | 45 | 使用方法: 46 | 47 | 48 | 先注册并登录,用户名不能有特殊字符。登陆进去之后会跳转到收藏列表页面,点击右上角的加号(+)按钮可以添加你想要的收藏链接,可以添加任意链接,比如 https://lg.gl 。点击确认之后可能需要稍等一会,如果使用的人多的话可能需要等待一分钟,如果返回的结果是失败请换一个链接试试,可能链接内容需要通过人机验证才能获取;如果返回成功,直接刷新页面就可以看到已经被解析出来的文章,点击上方的标签按钮可以对文章进行筛选,点击卡片可以跳转原链接。 49 | 50 | 51 | ToRead 目前已实现的部分比较简单,前端是用 AI 写的,还存在不少 bug,后端也没有任何的速率限制和安全检查,所以求轻喷。 52 | 53 | 54 | 如果你也想像ToRead一样丢一个链接给 API,然后 API 给你返回标题+简要描述+标签,你可以像这样请求 55 | 62 | {`curl https://g.lg.gl --data '{"url": "https://lg.gl"}'`} 63 | 64 | 把 url 字段改成任何你想要的链接就可以了。 65 | 66 | 67 | ToRead还有一个浏览器插件可以使用,你可以在上方的开源仓库中找到,在 extension 文件夹中。 68 | 69 | 70 | 71 | 74 | 75 | 76 | ); 77 | }; 78 | 79 | export default NoticeDialog; 80 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # vitepress build output 108 | **/.vitepress/dist 109 | 110 | # vitepress cache directory 111 | **/.vitepress/cache 112 | 113 | # Docusaurus cache and generated files 114 | .docusaurus 115 | 116 | # Serverless directories 117 | .serverless/ 118 | 119 | # FuseBox cache 120 | .fusebox/ 121 | 122 | # DynamoDB Local files 123 | .dynamodb/ 124 | 125 | # TernJS port file 126 | .tern-port 127 | 128 | # Stores VSCode versions used for testing VSCode extensions 129 | .vscode-test 130 | 131 | # yarn v2 132 | .yarn/cache 133 | .yarn/unplugged 134 | .yarn/build-state.yml 135 | .yarn/install-state.gz 136 | .pnp.* 137 | -------------------------------------------------------------------------------- /backend/model/tag.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | "to-read/utils/logs" 6 | 7 | "go.uber.org/zap" 8 | "gorm.io/gorm" 9 | ) 10 | 11 | type Tag struct { 12 | ID uint32 `json:"tag_id" form:"tag_id" query:"tag_id" gorm:"primaryKey;unique;autoIncrement;not null"` 13 | CreatedAt time.Time `json:"created_at" form:"created_at" query:"created_at"` 14 | UpdatedAt time.Time `json:"updated_at" form:"updated_at" query:"updated_at"` 15 | DeletedAt gorm.DeletedAt `json:"deleted_at" form:"deleted_at" query:"deleted_at"` 16 | Name string `json:"tag_name" form:"tag_name" query:"tag_name" gorm:"unique;not null"` 17 | } 18 | 19 | func FindTagByName(tagName string) (Tag, error) { 20 | m := GetModel() 21 | defer m.Close() 22 | 23 | var tag Tag 24 | result := m.tx.Model(&Tag{}).Where("name = ?", tagName).First(&tag) 25 | if result.Error != nil { 26 | logs.Info("Find tag by name failed.", zap.Error(result.Error)) 27 | m.Abort() 28 | return tag, result.Error 29 | } 30 | 31 | m.tx.Commit() 32 | return tag, nil 33 | } 34 | 35 | func FindMaxTagID() (Tag, error) { 36 | m := GetModel() 37 | defer m.Close() 38 | 39 | var tag Tag 40 | result := m.tx.Order("id desc").First(&tag) 41 | if result.Error != nil { 42 | logs.Info("Find max tag id failed.", zap.Error(result.Error)) 43 | m.Abort() 44 | return tag, result.Error 45 | } 46 | 47 | m.tx.Commit() 48 | return tag, nil 49 | } 50 | 51 | func FindOrCreateTag(tagName string) (Tag, error) { 52 | if tag, err := FindTagByName(tagName); err == nil { 53 | return tag, nil 54 | } else if err != gorm.ErrRecordNotFound { 55 | return Tag{}, err 56 | } 57 | 58 | m := GetModel() 59 | defer m.Close() 60 | 61 | tag := Tag{ 62 | Name: tagName, 63 | } 64 | result := m.tx.Create(&tag) 65 | if result.Error != nil { 66 | logs.Warn("Create tag failed.", zap.Error(result.Error), zap.Any("tag", tag)) 67 | m.Abort() 68 | return tag, result.Error 69 | } 70 | 71 | m.tx.Commit() 72 | return tag, nil 73 | } 74 | 75 | // GetTagList 获取指定用户的所有标签 76 | func GetTagList(userID uint32) ([]Tag, error) { 77 | m := GetModel() 78 | defer m.Close() 79 | 80 | var tags []Tag 81 | 82 | // 使用子查询先获取用户的所有收藏ID 83 | collectionSubQuery := m.tx.Model(&Collection{}). 84 | Select("id"). 85 | Where("user_id = ?", userID) 86 | 87 | // 再通过收藏ID获取所有关联的标签ID 88 | tagIDsSubQuery := m.tx.Model(&CollectionWithTag{}). 89 | Select("tag_id"). 90 | Where("collection_id IN (?)", collectionSubQuery) 91 | 92 | // 最后获取这些标签的详细信息 93 | result := m.tx.Where("id IN (?)", tagIDsSubQuery). 94 | Order("name"). // 按标签名称排序 95 | Find(&tags) 96 | 97 | if result.Error != nil { 98 | logs.Error("Get tag list failed", 99 | zap.Error(result.Error), 100 | zap.Uint32("user_id", userID)) 101 | m.Abort() 102 | return nil, result.Error 103 | } 104 | 105 | m.tx.Commit() 106 | return tags, nil 107 | } 108 | -------------------------------------------------------------------------------- /backend/controllers/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | "time" 7 | "to-read/model" 8 | "to-read/utils/logs" 9 | 10 | "github.com/golang-jwt/jwt" 11 | "github.com/labstack/echo/v4" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | const ( 16 | tokenHeaderName = "Authorization" 17 | accessTokenExpirationDuration = 30 * 24 * time.Hour 18 | refreshTokenExpirationDuration = 30 * 24 * time.Hour 19 | ) 20 | 21 | var jwtAccessSecretKey string 22 | var jwtRefreshSecretKey string 23 | 24 | type Authorization struct { 25 | AccessSecretKey string `yaml:"secret-key"` 26 | RefreshSecretKey string `yaml:"refresh-secret-key"` 27 | } 28 | 29 | type Claims struct { 30 | UserID uint32 `json:"user_id"` 31 | Role uint32 `json:"role"` 32 | jwt.StandardClaims 33 | } 34 | 35 | func InitAuthorization(a Authorization) error { 36 | if a.AccessSecretKey == "" { 37 | return errors.New("access-secret-key is empty") 38 | } 39 | jwtAccessSecretKey = a.AccessSecretKey 40 | if a.RefreshSecretKey == "" { 41 | return errors.New("refresh-secret-key is empty") 42 | } 43 | jwtRefreshSecretKey = a.RefreshSecretKey 44 | return nil 45 | } 46 | 47 | func GetJwtAccessSecretKey() string { 48 | return jwtAccessSecretKey 49 | } 50 | 51 | func GetJwtRefreshSecretKey() string { 52 | return jwtRefreshSecretKey 53 | } 54 | 55 | func GenerateAccessToken(user *model.User) (token string, expireAt time.Time, err error) { 56 | expireAt = time.Now().Add(accessTokenExpirationDuration) 57 | token, err = generateToken(user.ID, user.Role, expireAt, GetJwtAccessSecretKey()) 58 | return token, expireAt, err 59 | } 60 | 61 | func GenerateRefreshToken(user *model.User) (token string, expireAt time.Time, err error) { 62 | expireAt = time.Now().Add(refreshTokenExpirationDuration) 63 | token, err = generateToken(user.ID, user.Role, expireAt, GetJwtRefreshSecretKey()) 64 | return token, expireAt, err 65 | } 66 | 67 | func generateToken(userID uint32, role uint32, expireAt time.Time, secretKey string) (tokenString string, err error) { 68 | claims := &Claims{ 69 | UserID: userID, 70 | Role: role, 71 | StandardClaims: jwt.StandardClaims{ 72 | ExpiresAt: expireAt.Unix(), 73 | }, 74 | } 75 | 76 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 77 | 78 | tokenString, err = token.SignedString([]byte(secretKey)) 79 | if err != nil { 80 | logs.Warn("Generate token failed.", zap.Error(err)) 81 | return "", err 82 | } 83 | return tokenString, err 84 | } 85 | 86 | func GetClaimsFromHeader(c echo.Context) (claims Claims, err error) { 87 | bearerToken := strings.Split(c.Request().Header.Get(tokenHeaderName), " ") 88 | if len(bearerToken) < 2 { 89 | return Claims{}, errors.New("invalid header") 90 | } 91 | if bearerToken[0] != "Bearer" { 92 | return Claims{}, errors.New("invalid header") 93 | } 94 | 95 | tokenString := bearerToken[1] 96 | claims = Claims{} 97 | _, err = jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (interface{}, error) { 98 | return []byte(GetJwtAccessSecretKey()), nil 99 | }) 100 | if err != nil { 101 | return Claims{}, err 102 | } 103 | 104 | return claims, nil 105 | } 106 | -------------------------------------------------------------------------------- /backend/model/user.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | "to-read/utils" 6 | "to-read/utils/logs" 7 | 8 | "go.uber.org/zap" 9 | "gorm.io/gorm" 10 | ) 11 | 12 | type User struct { 13 | ID uint32 `json:"user_id" form:"user_id" query:"user_id" gorm:"primaryKey;unique;autoIncrement;not null"` 14 | CreatedAt time.Time `json:"created_at" form:"created_at" query:"created_at"` 15 | UpdatedAt time.Time `json:"updated_at" form:"updated_at" query:"updated_at"` 16 | DeletedAt gorm.DeletedAt `json:"deleted_at" form:"deleted_at" query:"deleted_at"` 17 | UserName string `json:"user_name" form:"user_name" query:"user_name" gorm:"unique;not null"` 18 | Role uint32 `json:"role" form:"role" query:"role" gorm:"not null"` 19 | PasswordMD5 string `json:"password_md5" form:"password_md5" query:"password_md5" gorm:"not null"` 20 | Deleted bool `json:"deleted" form:"deleted" query:"deleted" gorm:"not null"` 21 | } 22 | 23 | func FindMaxUserID() (User, error) { 24 | m := GetModel() 25 | defer m.Close() 26 | 27 | var user User 28 | result := m.tx.Order("id desc").First(&user) 29 | if result.Error != nil { 30 | logs.Info("Find max user id failed.", zap.Error(result.Error)) 31 | m.Abort() 32 | return user, result.Error 33 | } 34 | 35 | m.tx.Commit() 36 | return user, nil 37 | } 38 | 39 | func UserRegister(userName string, password string) (User, error) { 40 | m := GetModel() 41 | defer m.Close() 42 | 43 | user := User{ 44 | UserName: userName, 45 | Role: 1, 46 | PasswordMD5: utils.GetMD5(password), 47 | Deleted: false, 48 | } 49 | result := m.tx.Create(&user) 50 | if result.Error != nil { 51 | logs.Warn("Create user failed.", zap.Error(result.Error), zap.Any("user", user)) 52 | m.Abort() 53 | return user, result.Error 54 | } 55 | 56 | m.tx.Commit() 57 | return user, nil 58 | } 59 | 60 | func FindUserByID(userID uint32) (User, error) { 61 | m := GetModel() 62 | defer m.Close() 63 | 64 | var user User 65 | result := m.tx.First(&user, userID) 66 | if result.Error != nil { 67 | logs.Info("Find user by id failed.", zap.Error(result.Error)) 68 | m.Abort() 69 | return user, result.Error 70 | } 71 | 72 | m.tx.Commit() 73 | return user, nil 74 | } 75 | 76 | func FindUserByName(userName string) (User, error) { 77 | m := GetModel() 78 | defer m.Close() 79 | 80 | var user User 81 | result := m.tx.Model(&User{}).Where("user_name = ?", userName).First(&user) 82 | if result.Error != nil { 83 | logs.Info("Find user by name failed.", zap.Error(result.Error)) 84 | m.Abort() 85 | return user, result.Error 86 | } 87 | 88 | m.tx.Commit() 89 | return user, nil 90 | } 91 | 92 | func UpdateUserName(userID uint32, userName string) (User, error) { 93 | m := GetModel() 94 | defer m.Close() 95 | 96 | var user User 97 | result := m.tx.First(&user, userID).Update("user_name", userName) 98 | if result.Error != nil { 99 | logs.Info("Update user name failed.", zap.Error(result.Error)) 100 | m.Abort() 101 | return user, result.Error 102 | } 103 | 104 | m.tx.Commit() 105 | return user, nil 106 | } 107 | -------------------------------------------------------------------------------- /frontend/src/services/api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const API_URL = 'https://to-read.lg.gl/api/v1'; 4 | 5 | // 定义API响应的通用接口 6 | interface ApiResponse { 7 | code: number; 8 | msg: string | null; 9 | data: T; 10 | } 11 | 12 | // 定义各种数据类型的接口 13 | interface User { 14 | user_id: number; 15 | user_name: string; 16 | role: number; 17 | } 18 | 19 | interface LoginResponse { 20 | user_id: number; 21 | user_name: string; 22 | role: number; 23 | token: string; 24 | token_expiration_time: number; 25 | } 26 | 27 | interface Collection { 28 | collection_id: number; 29 | url: string; 30 | type: 'text' | 'image' | 'video'; 31 | title: string; 32 | description: string; 33 | tags: string[]; 34 | created_at: number; 35 | } 36 | 37 | interface CollectionListResponse { 38 | collections: Collection[]; 39 | } 40 | 41 | interface SummaryResponse { 42 | summary: string; 43 | } 44 | 45 | interface TagsResponse { 46 | tags: string[]; 47 | } 48 | 49 | const instance = axios.create({ 50 | baseURL: API_URL, 51 | headers: { 52 | 'Content-Type': 'application/json', 53 | }, 54 | }); 55 | 56 | // 请求拦截器,添加token 57 | instance.interceptors.request.use( 58 | (config) => { 59 | const token = localStorage.getItem('token'); 60 | if (token) { 61 | config.headers.Authorization = `Bearer ${token}`; 62 | } 63 | return config; 64 | }, 65 | (error) => { 66 | return Promise.reject(error); 67 | } 68 | ); 69 | 70 | // 创建一个包装函数,处理响应并提取数据 71 | async function api(promise: Promise): Promise> { 72 | try { 73 | const response = await promise; 74 | return response.data as ApiResponse; 75 | } catch (error: any) { 76 | if (error.response && error.response.status === 401) { 77 | // Token过期,清除登录状态 78 | localStorage.removeItem('token'); 79 | localStorage.removeItem('user'); 80 | window.location.href = '/login'; 81 | } 82 | throw error; 83 | } 84 | } 85 | 86 | // 使用包装函数来处理所有API调用 87 | export const healthCheck = () => api(instance.get('/health')); 88 | 89 | // 用户相关API 90 | export const userAPI = { 91 | getUser: (params: { user_id?: number; user_name?: string }) => 92 | api(instance.get('/user', { params })), 93 | register: (data: { user_name: string; password: string }) => 94 | api(instance.post('/user/register', data)), 95 | login: (data: { user_name: string; password: string }) => 96 | api(instance.post('/user/login', data)), 97 | }; 98 | 99 | // 收藏相关API 100 | export const collectionAPI = { 101 | getList: (params?: { search?: string; tags?: string[] }) => 102 | api(instance.get('/collection/list', { 103 | params: { 104 | ...params, 105 | tags: params?.tags?.join(',') 106 | } 107 | })), 108 | addCollection: (data: { url: string }) => 109 | api(instance.post('/collection/add', data)), 110 | getSummary: (params?: { search?: string; tags?: string[] }) => 111 | api(instance.get('/collection/summary', { 112 | params: { 113 | ...params, 114 | tags: params?.tags?.join(',') 115 | } 116 | })), 117 | getTags: () => api(instance.get('/collection/tag')), 118 | }; 119 | 120 | export type { ApiResponse, User, Collection, LoginResponse, CollectionListResponse, SummaryResponse, TagsResponse }; 121 | export default instance; 122 | -------------------------------------------------------------------------------- /backend/controllers/controllers.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "net/http" 5 | "to-read/utils/logs" 6 | 7 | "github.com/labstack/echo/v4" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | type ErrorMessage struct { 12 | Message string `json:"msg"` 13 | Err string `json:"err"` 14 | } 15 | 16 | type StatusMessage struct { 17 | Status string `json:"status"` 18 | } 19 | 20 | type ResponseStruct struct { 21 | Code int `json:"code"` 22 | Message string `json:"msg"` 23 | Data interface{} `json:"data"` 24 | } 25 | 26 | func Bind(c echo.Context, obj interface{}) (bool, error) { 27 | err := c.Bind(&obj) 28 | if err != nil { 29 | logs.Warn("Failed to parse request data.", zap.Error(err)) 30 | return false, c.JSON(http.StatusBadRequest, ResponseStruct{ 31 | Code: http.StatusBadRequest, 32 | Message: "Bad Request", 33 | Data: ErrorMessage{ 34 | Message: "Failed to parse request data.", 35 | Err: err.Error(), 36 | }, 37 | }) 38 | } 39 | logs.Debug("Parsed struct:", zap.Any("obj", obj)) 40 | return true, nil 41 | } 42 | 43 | func BindJSON(c echo.Context, obj interface{}) (bool, error) { 44 | c.Request().Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) 45 | err := c.Bind(&obj) 46 | 47 | if err != nil { 48 | logs.Warn("Failed to parse request data.", zap.Error(err)) 49 | return false, c.JSON(http.StatusBadRequest, ResponseStruct{ 50 | Code: http.StatusBadRequest, 51 | Message: "Bad Request", 52 | Data: ErrorMessage{ 53 | Message: "Failed to parse request data.", 54 | Err: err.Error(), 55 | }, 56 | }) 57 | } 58 | logs.Debug("Parsed struct:", zap.Any("obj", obj)) 59 | return true, nil 60 | } 61 | 62 | func ResponseOK(c echo.Context, data interface{}) error { 63 | return c.JSON(http.StatusOK, ResponseStruct{ 64 | Code: http.StatusOK, 65 | Message: "OK", 66 | Data: data, 67 | }) 68 | } 69 | 70 | func ResponseBadRequest(c echo.Context, errMessage string, err error) error { 71 | Err := "" 72 | if err != nil { 73 | Err = err.Error() 74 | } 75 | return c.JSON(http.StatusBadRequest, ResponseStruct{ 76 | Code: http.StatusBadRequest, 77 | Message: "Bad Request", 78 | Data: ErrorMessage{ 79 | Message: errMessage, 80 | Err: Err, 81 | }, 82 | }) 83 | } 84 | 85 | func ResponseInternalServerError(c echo.Context, errMessage string, err error) error { 86 | Err := "" 87 | if err != nil { 88 | Err = err.Error() 89 | } 90 | return c.JSON(http.StatusInternalServerError, ResponseStruct{ 91 | Code: http.StatusInternalServerError, 92 | Message: "Internal Server Error", 93 | Data: ErrorMessage{ 94 | Message: errMessage, 95 | Err: Err, 96 | }, 97 | }) 98 | } 99 | 100 | func ResponseUnauthorized(c echo.Context, errMessage string, err error) error { 101 | Err := "" 102 | if err != nil { 103 | Err = err.Error() 104 | } 105 | return c.JSON(http.StatusUnauthorized, ResponseStruct{ 106 | Code: http.StatusUnauthorized, 107 | Message: "Unauthorized", 108 | Data: ErrorMessage{ 109 | Message: errMessage, 110 | Err: Err, 111 | }, 112 | }) 113 | } 114 | 115 | func ResponseForbidden(c echo.Context, errMessage string, err error) error { 116 | Err := "" 117 | if err != nil { 118 | Err = err.Error() 119 | } 120 | return c.JSON(http.StatusForbidden, ResponseStruct{ 121 | Code: http.StatusForbidden, 122 | Message: "Forbidden", 123 | Data: ErrorMessage{ 124 | Message: errMessage, 125 | Err: Err, 126 | }, 127 | }) 128 | } 129 | -------------------------------------------------------------------------------- /frontend/src/components/Collection/AddCollection.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { 3 | Dialog, 4 | DialogTitle, 5 | DialogContent, 6 | DialogActions, 7 | Button, 8 | TextField, 9 | CircularProgress, 10 | Box, 11 | Typography 12 | } from '@mui/material'; 13 | import { collectionAPI } from '../../services/api'; 14 | import ErrorMessage from '../Common/ErrorMessage'; 15 | 16 | interface AddCollectionProps { 17 | open: boolean; 18 | onClose: () => void; 19 | onAdded: () => void; 20 | } 21 | 22 | const AddCollection: React.FC = ({ open, onClose, onAdded }) => { 23 | const [url, setUrl] = useState(''); 24 | const [loading, setLoading] = useState(false); 25 | const [error, setError] = useState(null); 26 | const [urlError, setUrlError] = useState(''); 27 | 28 | const validateUrl = (value: string) => { 29 | if (!value) { 30 | return '请输入URL'; 31 | } 32 | 33 | try { 34 | new URL(value); 35 | return ''; 36 | } catch (e) { 37 | return 'URL格式不正确'; 38 | } 39 | }; 40 | 41 | const handleSubmit = async () => { 42 | const urlValidationError = validateUrl(url); 43 | if (urlValidationError) { 44 | setUrlError(urlValidationError); 45 | return; 46 | } 47 | 48 | try { 49 | setLoading(true); 50 | setError(null); 51 | 52 | const response = await collectionAPI.addCollection({ url }); 53 | 54 | if (response.code === 200) { 55 | setUrl(''); 56 | setLoading(false); // 先停止加载 57 | onAdded(); // 调用回调函数 58 | onClose(); // 关闭对话框 59 | } else { 60 | setError(response.msg || '添加收藏失败'); 61 | setLoading(false); 62 | } 63 | } catch (err: any) { 64 | setError(err.response?.data?.msg || '添加收藏失败,请检查网络连接'); 65 | } finally { 66 | setLoading(false); 67 | } 68 | }; 69 | 70 | const handleUrlChange = (e: React.ChangeEvent) => { 71 | setUrl(e.target.value); 72 | if (urlError) { 73 | setUrlError(''); 74 | } 75 | }; 76 | 77 | return ( 78 | 84 | 添加收藏链接 85 | 86 | {error && } 87 | 88 | 103 | 104 | {loading && ( 105 | 106 | 107 | 108 | 正在处理链接,这可能需要一些时间... 109 | 110 | 111 | )} 112 | 113 | 114 | 115 | 122 | 123 | 124 | ); 125 | }; 126 | 127 | export default AddCollection; 128 | -------------------------------------------------------------------------------- /backend/model/collection_with_tag.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | "to-read/utils/logs" 6 | 7 | "go.uber.org/zap" 8 | "gorm.io/gorm" 9 | ) 10 | 11 | type CollectionWithTag struct { 12 | ID uint32 `json:"collection_with_tag_id" form:"collection_with_tag_id" query:"collection_with_tag_id" gorm:"primaryKey;unique;autoIncrement;not null"` 13 | CreatedAt time.Time `json:"created_at" form:"created_at" query:"created_at"` 14 | UpdatedAt time.Time `json:"updated_at" form:"updated_at" query:"updated_at"` 15 | DeletedAt gorm.DeletedAt `json:"deleted_at" form:"deleted_at" query:"deleted_at"` 16 | CollectionID uint32 `json:"collection_id" form:"collection_id" query:"collection_id" gorm:"not null;uniqueIndex:idx_collection_tag"` 17 | TagID uint32 `json:"tag_id" form:"tag_id" query:"tag_id" gorm:"not null;uniqueIndex:idx_collection_tag"` 18 | } 19 | 20 | func FindCollectionWithTagID(collectionID uint32, tagID uint32) (CollectionWithTag, error) { 21 | m := GetModel() 22 | defer m.Close() 23 | 24 | cwt := CollectionWithTag{} 25 | result := m.tx.Where("collection_id = ? AND tag_id = ?", collectionID, tagID).First(&cwt) 26 | if result.Error != nil { 27 | logs.Info("Find collection with tag failed.", zap.Error(result.Error), zap.Any("cwt", cwt)) 28 | m.Abort() 29 | return cwt, result.Error 30 | } 31 | 32 | m.tx.Commit() 33 | return cwt, nil 34 | } 35 | 36 | func FindOrCreateCollectionWithTagID(collectionID uint32, tagID uint32) (CollectionWithTag, error) { 37 | if cwt, err := FindCollectionWithTagID(collectionID, tagID); err == nil { 38 | return cwt, nil 39 | } else if err != gorm.ErrRecordNotFound { 40 | return cwt, err 41 | } 42 | 43 | m := GetModel() 44 | defer m.Close() 45 | 46 | // collection_with_tag 47 | cwt := CollectionWithTag{ 48 | CollectionID: collectionID, 49 | TagID: tagID, 50 | } 51 | result := m.tx.Create(&cwt) 52 | if result.Error != nil { 53 | logs.Info("Create collection with tag failed.", zap.Error(result.Error), zap.Any("cwt", cwt)) 54 | m.Abort() 55 | return cwt, result.Error 56 | } 57 | 58 | m.tx.Commit() 59 | return cwt, nil 60 | } 61 | 62 | func AddCollectionWithTags(collectionID uint32, tags []string) ([]CollectionWithTag, error) { 63 | result := make([]CollectionWithTag, 0, len(tags)) 64 | 65 | for _, tagName := range tags { 66 | tag, err := FindOrCreateTag(tagName) 67 | if err != nil { 68 | return result, err 69 | } 70 | 71 | logs.Debug("AddCollectionWithTags: FindOrCreateTag", zap.Uint32("collectionID", collectionID), zap.Uint32("tagID", tag.ID), zap.String("tagName", tagName)) 72 | cwt, err := FindOrCreateCollectionWithTagID(collectionID, tag.ID) 73 | if err == nil { 74 | result = append(result, cwt) 75 | } else { 76 | return result, err 77 | } 78 | } 79 | return result, nil 80 | } 81 | 82 | 83 | // 获取收藏关联的标签 84 | func GetCollectionTags(collectionID uint32) ([]string, error) { 85 | m := GetModel() 86 | defer m.Close() 87 | 88 | var tagNames []string 89 | 90 | // 查询与收藏关联的所有标签 91 | err := m.tx.Model(&Tag{}). 92 | Select("tags.name"). 93 | Joins("JOIN collection_with_tags ON collection_with_tags.tag_id = tags.id"). 94 | Where("collection_with_tags.collection_id = ?", collectionID). 95 | Pluck("name", &tagNames).Error 96 | 97 | if err != nil { 98 | m.Abort() 99 | return nil, err 100 | } 101 | 102 | m.tx.Commit() 103 | return tagNames, nil 104 | } 105 | -------------------------------------------------------------------------------- /extension/background.js: -------------------------------------------------------------------------------- 1 | // 后台脚本,处理API请求和登录状态 2 | const API_BASE_URL = 'https://to-read.lg.gl/api/v1'; 3 | 4 | // 检查登录状态 5 | async function checkLoginStatus() { 6 | const { token, tokenExpirationTime } = await chrome.storage.local.get(['token', 'tokenExpirationTime']); 7 | 8 | if (!token || !tokenExpirationTime) { 9 | return false; 10 | } 11 | 12 | // 检查token是否过期 13 | const currentTime = Math.floor(Date.now() / 1000); 14 | if (currentTime >= tokenExpirationTime) { 15 | return false; 16 | } 17 | 18 | return true; 19 | } 20 | 21 | // 登录函数 22 | async function login(username, password) { 23 | try { 24 | const response = await fetch(`${API_BASE_URL}/user/login`, { 25 | method: 'POST', 26 | headers: { 27 | 'Content-Type': 'application/json', 28 | }, 29 | body: JSON.stringify({ 30 | user_name: username, 31 | password: password 32 | }) 33 | }); 34 | 35 | const data = await response.json(); 36 | 37 | if (data.code === 200 && data.data && data.data.token) { 38 | // 保存token和过期时间 39 | await chrome.storage.local.set({ 40 | token: data.data.token, 41 | tokenExpirationTime: data.data.token_expiration_time, 42 | userId: data.data.user_id, 43 | userName: data.data.user_name 44 | }); 45 | return { success: true, data: data.data }; 46 | } else { 47 | return { success: false, message: data.msg || '登录失败' }; 48 | } 49 | } catch (error) { 50 | console.error('Login error:', error); 51 | return { success: false, message: '网络错误,请稍后再试' }; 52 | } 53 | } 54 | 55 | // 添加收藏 56 | async function addCollection(url) { 57 | try { 58 | const { token } = await chrome.storage.local.get('token'); 59 | 60 | if (!token) { 61 | return { success: false, message: '未登录,请先登录' }; 62 | } 63 | 64 | const response = await fetch(`${API_BASE_URL}/collection/add`, { 65 | method: 'POST', 66 | headers: { 67 | 'Content-Type': 'application/json', 68 | 'Authorization': `Bearer ${token}` 69 | }, 70 | body: JSON.stringify({ url }) 71 | }); 72 | 73 | const data = await response.json(); 74 | 75 | if (data.code === 200) { 76 | return { success: true, data: data.data }; 77 | } else { 78 | return { success: false, message: data.msg || '添加收藏失败' }; 79 | } 80 | } catch (error) { 81 | console.error('Add collection error:', error); 82 | return { success: false, message: '网络错误,请稍后再试' }; 83 | } 84 | } 85 | 86 | // 监听来自popup的消息 87 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 88 | if (message.action === 'checkLogin') { 89 | checkLoginStatus().then(isLoggedIn => { 90 | sendResponse({ isLoggedIn }); 91 | }); 92 | return true; // 异步响应 93 | } 94 | 95 | if (message.action === 'login') { 96 | login(message.username, message.password).then(result => { 97 | sendResponse(result); 98 | }); 99 | return true; 100 | } 101 | 102 | if (message.action === 'addCurrentPage') { 103 | chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => { 104 | if (tabs.length > 0) { 105 | const url = tabs[0].url; 106 | const result = await addCollection(url); 107 | sendResponse(result); 108 | } else { 109 | sendResponse({ success: false, message: '无法获取当前页面URL' }); 110 | } 111 | }); 112 | return true; 113 | } 114 | 115 | if (message.action === 'addCustomUrl') { 116 | addCollection(message.url).then(result => { 117 | sendResponse(result); 118 | }); 119 | return true; 120 | } 121 | }); 122 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; 3 | import { ThemeProvider, createTheme, CssBaseline } from '@mui/material'; 4 | import { AuthProvider, useAuth } from './context/AuthContext'; 5 | import Login from './components/Auth/Login'; 6 | import Register from './components/Auth/Register'; 7 | import CollectionList from './components/Collection/CollectionList'; 8 | import Header from './components/Layout/Header'; 9 | import AddCollection from './components/Collection/AddCollection'; 10 | import NoticeDialog from './components/Common/NoticeDialog'; 11 | 12 | // 创建主题 13 | const theme = createTheme({ 14 | palette: { 15 | primary: { 16 | main: '#1976d2', 17 | }, 18 | secondary: { 19 | main: '#dc004e', 20 | }, 21 | }, 22 | }); 23 | 24 | // 受保护的路由组件 25 | const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { 26 | const { isAuthenticated, loading } = useAuth(); 27 | 28 | if (loading) { 29 | return
Loading...
; 30 | } 31 | 32 | if (!isAuthenticated) { 33 | return ; 34 | } 35 | 36 | return <>{children}; 37 | }; 38 | 39 | // 主应用内容 40 | const AppContent: React.FC = () => { 41 | const [showAddCollection, setShowAddCollection] = React.useState(false); 42 | const [searchQuery, setSearchQuery] = React.useState(''); 43 | const [showNotice, setShowNotice] = React.useState(false); 44 | const { isAuthenticated } = useAuth(); 45 | 46 | // 在组件加载时和认证状态变化时显示通知 47 | useEffect(() => { 48 | if (isAuthenticated) { 49 | const hasSeenNotice = localStorage.getItem('hasSeenNotice'); 50 | const currentTime = new Date().getTime(); 51 | const lastNoticeTime = parseInt(localStorage.getItem('lastNoticeTime') || '0'); 52 | 53 | if (!hasSeenNotice || (currentTime - lastNoticeTime > 24 * 60 * 60 * 1000)) { 54 | setShowNotice(true); 55 | } 56 | } 57 | }, [isAuthenticated]); 58 | 59 | const handleSearch = (query: string) => { 60 | setSearchQuery(query); 61 | }; 62 | 63 | const handleAddCollection = () => { 64 | setShowAddCollection(true); 65 | }; 66 | 67 | const handleCollectionAdded = () => { 68 | setShowAddCollection(false); 69 | // 可以在这里刷新收藏列表 70 | }; 71 | 72 | const handleNoticeClose = () => { 73 | setShowNotice(false); 74 | localStorage.setItem('hasSeenNotice', 'true'); 75 | localStorage.setItem('lastNoticeTime', new Date().getTime().toString()); 76 | }; 77 | 78 | return ( 79 | <> 80 |
84 | 85 | 87 | 88 | 89 | } /> 90 | } /> 91 | } /> 92 | } /> 93 | 94 | 95 | setShowAddCollection(false)} 98 | onAdded={handleCollectionAdded} 99 | /> 100 | 101 | {/* 通知对话框 */} 102 | 106 | 107 | ); 108 | }; 109 | 110 | // 主应用 111 | const App: React.FC = () => { 112 | return ( 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | ); 122 | }; 123 | 124 | export default App; 125 | -------------------------------------------------------------------------------- /frontend/src/context/AuthContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useState, useEffect } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { userAPI } from '../services/api'; 4 | 5 | interface User { 6 | user_id: number; 7 | user_name: string; 8 | role: number; 9 | } 10 | 11 | interface AuthContextType { 12 | user: User | null; 13 | token: string | null; 14 | isAuthenticated: boolean; 15 | login: (username: string, password: string) => Promise; 16 | register: (username: string, password: string) => Promise; 17 | logout: () => void; 18 | loading: boolean; 19 | error: string | null; 20 | } 21 | 22 | const AuthContext = createContext(undefined); 23 | 24 | export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { 25 | const [user, setUser] = useState(null); 26 | const [token, setToken] = useState(null); 27 | const [loading, setLoading] = useState(true); 28 | const [error, setError] = useState(null); 29 | const navigate = useNavigate(); 30 | 31 | useEffect(() => { 32 | // 检查本地存储中的用户信息和token 33 | const storedUser = localStorage.getItem('user'); 34 | const storedToken = localStorage.getItem('token'); 35 | 36 | if (storedUser && storedToken) { 37 | setUser(JSON.parse(storedUser)); 38 | setToken(storedToken); 39 | } 40 | 41 | setLoading(false); 42 | }, []); 43 | 44 | const login = async (username: string, password: string) => { 45 | try { 46 | setLoading(true); 47 | setError(null); 48 | 49 | const response = await userAPI.login({ user_name: username, password }); 50 | 51 | if (response.code === 200) { 52 | const userData = { 53 | user_id: response.data.user_id, 54 | user_name: response.data.user_name, 55 | role: response.data.role || 1 56 | }; 57 | 58 | setUser(userData); 59 | setToken(response.data.token); 60 | 61 | localStorage.setItem('user', JSON.stringify(userData)); 62 | localStorage.setItem('token', response.data.token); 63 | 64 | navigate('/'); 65 | } else { 66 | setError(response.msg || '登录失败'); 67 | } 68 | } catch (err: any) { 69 | setError(err.response?.data?.msg || '登录失败,请检查网络连接'); 70 | } finally { 71 | setLoading(false); 72 | } 73 | }; 74 | 75 | const register = async (username: string, password: string) => { 76 | try { 77 | setLoading(true); 78 | setError(null); 79 | 80 | const response = await userAPI.register({ user_name: username, password }); 81 | 82 | if (response.code === 200) { 83 | navigate('/login'); 84 | } else { 85 | setError(response.msg || '注册失败'); 86 | } 87 | } catch (err: any) { 88 | setError(err.response?.data?.msg || '注册失败,请检查网络连接'); 89 | } finally { 90 | setLoading(false); 91 | } 92 | }; 93 | 94 | const logout = () => { 95 | setUser(null); 96 | setToken(null); 97 | localStorage.removeItem('user'); 98 | localStorage.removeItem('token'); 99 | navigate('/login'); 100 | }; 101 | 102 | return ( 103 | 115 | {children} 116 | 117 | ); 118 | }; 119 | 120 | export const useAuth = () => { 121 | const context = useContext(AuthContext); 122 | if (context === undefined) { 123 | throw new Error('useAuth must be used within an AuthProvider'); 124 | } 125 | return context; 126 | }; -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "to-read", 3 | "version": "1.0.0", 4 | "description": "ToRead - An AI-powered cross-platform app to manage your reading list", 5 | "main": "public/electron.js", 6 | "scripts": { 7 | "start": "cross-env HOST=0.0.0.0 PORT=3001 react-scripts start", 8 | "build": "react-scripts build", 9 | "test": "react-scripts test", 10 | "eject": "react-scripts eject", 11 | "electron-dev": "concurrently \"cross-env BROWSER=none npm start\" \"wait-on http://localhost:3000 && electron .\"", 12 | "electron-pack": "electron-builder build --win --mac --linux", 13 | "postinstall": "electron-builder install-app-deps" 14 | }, 15 | "build": { 16 | "appId": "com.toread.app", 17 | "productName": "ToRead", 18 | "files": [ 19 | "build/**/*", 20 | "node_modules/**/*" 21 | ], 22 | "directories": { 23 | "buildResources": "assets" 24 | } 25 | }, 26 | "browserslist": { 27 | "production": [ 28 | ">0.2%", 29 | "not dead", 30 | "not op_mini all" 31 | ], 32 | "development": [ 33 | "last 1 chrome version", 34 | "last 1 firefox version", 35 | "last 1 safari version" 36 | ] 37 | }, 38 | "dependencies": { 39 | "@emotion/react": "^11.14.0", 40 | "@emotion/styled": "^11.14.0", 41 | "@mui/icons-material": "^5.17.1", 42 | "@mui/material": "^5.17.1", 43 | "axios": "^1.9.0", 44 | "boolean": "^3.2.0", 45 | "buffer-crc32": "^0.2.13", 46 | "cacheable-lookup": "^5.0.4", 47 | "cacheable-request": "^7.0.4", 48 | "clone-response": "^1.0.3", 49 | "debug": "^4.4.0", 50 | "decompress-response": "^6.0.0", 51 | "defer-to-connect": "^2.0.1", 52 | "define-data-property": "^1.1.4", 53 | "define-properties": "^1.2.1", 54 | "detect-node": "^2.1.0", 55 | "electron": "^36.1.0", 56 | "electron-builder": "^26.0.12", 57 | "end-of-stream": "^1.4.4", 58 | "env-paths": "^2.2.1", 59 | "es-define-property": "^1.0.1", 60 | "es-errors": "^1.3.0", 61 | "es6-error": "^4.1.1", 62 | "escape-string-regexp": "^4.0.0", 63 | "extract-zip": "^2.0.1", 64 | "fd-slicer": "^1.1.0", 65 | "fs-extra": "^8.1.0", 66 | "get-stream": "^5.2.0", 67 | "global-agent": "^3.0.0", 68 | "globalthis": "^1.0.4", 69 | "gopd": "^1.2.0", 70 | "got": "^11.8.6", 71 | "graceful-fs": "^4.2.11", 72 | "has-property-descriptors": "^1.0.2", 73 | "http-cache-semantics": "^4.1.1", 74 | "http2-wrapper": "^1.0.3", 75 | "json-buffer": "^3.0.1", 76 | "json-stringify-safe": "^5.0.1", 77 | "jsonfile": "^4.0.0", 78 | "jwt-decode": "^4.0.0", 79 | "keyv": "^4.5.4", 80 | "lowercase-keys": "^2.0.0", 81 | "matcher": "^3.0.0", 82 | "mimic-response": "^1.0.1", 83 | "moment": "^2.30.1", 84 | "ms": "^2.1.3", 85 | "normalize-url": "^6.1.0", 86 | "object-keys": "^1.1.1", 87 | "once": "^1.4.0", 88 | "p-cancelable": "^2.1.1", 89 | "pend": "^1.2.0", 90 | "progress": "^2.0.3", 91 | "pump": "^3.0.2", 92 | "quick-lru": "^5.1.1", 93 | "react": "^19.1.0", 94 | "react-dom": "^19.1.0", 95 | "react-router-dom": "^7.5.3", 96 | "resolve-alpn": "^1.2.1", 97 | "responselike": "^2.0.1", 98 | "roarr": "^2.15.4", 99 | "semver": "^6.3.1", 100 | "semver-compare": "^1.0.0", 101 | "serialize-error": "^7.0.1", 102 | "sprintf-js": "^1.1.3", 103 | "sumchecker": "^3.0.1", 104 | "type-fest": "^0.13.1", 105 | "undici-types": "^6.21.0", 106 | "universalify": "^0.1.2", 107 | "wrappy": "^1.0.2", 108 | "yauzl": "^2.10.0" 109 | }, 110 | "keywords": [], 111 | "author": "", 112 | "license": "ISC", 113 | "devDependencies": { 114 | "@types/react": "^19.1.2", 115 | "@types/react-dom": "^19.1.3", 116 | "concurrently": "^9.1.2", 117 | "cross-env": "^7.0.3", 118 | "electron-is-dev": "^3.0.1", 119 | "react-scripts": "^5.0.1", 120 | "typescript": "^4.9.5", 121 | "wait-on": "^8.0.3" 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /backend/controllers/user.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "regexp" 5 | "strconv" 6 | "to-read/controllers/auth" 7 | "to-read/model" 8 | "to-read/utils" 9 | "to-read/utils/logs" 10 | 11 | "github.com/labstack/echo/v4" 12 | "gorm.io/gorm" 13 | ) 14 | 15 | type UserLoginRequest struct { 16 | UserName string `json:"user_name"` 17 | Password string `json:"password"` 18 | } 19 | 20 | type UserLoginResponse struct { 21 | ID uint32 `json:"user_id"` 22 | UserName string `json:"user_name"` 23 | AccessToken string `json:"token"` 24 | AccessTokenExpireAt int64 `json:"token_expiration_time"` 25 | } 26 | 27 | func UserLoginPOST(c echo.Context) error { 28 | logs.Debug("POST /user/login") 29 | 30 | userRequest := UserLoginRequest{} 31 | _ok, err := Bind(c, &userRequest) 32 | if !_ok { 33 | return err 34 | } 35 | 36 | user, err := model.FindUserByName(userRequest.UserName) 37 | if err != nil { 38 | if err == gorm.ErrRecordNotFound { 39 | return ResponseBadRequest(c, "User or password incorrect.", nil) 40 | } 41 | return ResponseInternalServerError(c, "Find user failed.", err) 42 | } 43 | if user.Deleted { 44 | return ResponseBadRequest(c, "User or password incorrect.", nil) 45 | } 46 | if user.PasswordMD5 != utils.GetMD5(userRequest.Password) { 47 | return ResponseBadRequest(c, "User or password incorrect.", nil) 48 | } 49 | 50 | accessTokenString, accessTokenExpireAt, err := auth.GenerateAccessToken(&user) 51 | if err != nil { 52 | return ResponseInternalServerError(c, "Generate access token failed.", err) 53 | } 54 | 55 | return ResponseOK(c, UserLoginResponse{ 56 | ID: user.ID, 57 | UserName: user.UserName, 58 | AccessToken: accessTokenString, 59 | AccessTokenExpireAt: accessTokenExpireAt.Unix(), 60 | }) 61 | } 62 | 63 | type UserRegisterRequest struct { 64 | UserName string `json:"user_name"` 65 | Password string `json:"password"` 66 | } 67 | 68 | type UserRegisterResponse struct { 69 | ID uint32 `json:"user_id" ` 70 | UserName string `json:"user_name" ` 71 | Role uint32 `json:"role" ` 72 | } 73 | 74 | func isAlphanumeric(s string) bool { 75 | match, _ := regexp.MatchString("^[a-zA-Z0-9]+$", s) 76 | return match 77 | } 78 | 79 | func UserRegisterPOST(c echo.Context) error { 80 | logs.Debug("POST /user/register") 81 | 82 | userRequest := UserRegisterRequest{} 83 | _ok, err := Bind(c, &userRequest) 84 | if !_ok { 85 | return err 86 | } 87 | 88 | if !isAlphanumeric(userRequest.UserName) { 89 | return ResponseBadRequest(c, "User name must be alphanumeric.", nil) 90 | } 91 | 92 | user, err := model.UserRegister(userRequest.UserName, userRequest.Password) 93 | if err != nil { 94 | return ResponseInternalServerError(c, "Register user failed.", err) 95 | } 96 | 97 | return ResponseOK(c, UserRegisterResponse{ 98 | ID: user.ID, 99 | UserName: user.UserName, 100 | Role: user.Role, 101 | }) 102 | } 103 | 104 | func UserIsAuthGET(c echo.Context) error { 105 | logs.Debug("GET /user/isauth") 106 | 107 | return ResponseOK(c, StatusMessage{ 108 | Status: "OK", 109 | }) 110 | } 111 | 112 | type UserGETResponse struct { 113 | ID uint32 `json:"user_id" ` 114 | UserName string `json:"user_name" ` 115 | Role uint32 `json:"role" ` 116 | } 117 | 118 | func UserGET(c echo.Context) error { 119 | logs.Debug("GET /user") 120 | 121 | var err error 122 | user := model.User{} 123 | num, _ := strconv.ParseUint(c.QueryParam("user_id"), 10, 32) 124 | userID := uint32(num) 125 | userName := c.QueryParam("user_name") 126 | 127 | if userID != 0 { 128 | user, err = model.FindUserByID(userID) 129 | } else if userName != "" { 130 | user, err = model.FindUserByName(userName) 131 | } else { 132 | return ResponseBadRequest(c, "User ID or user_name is required.", nil) 133 | } 134 | if err != nil { 135 | if err == gorm.ErrRecordNotFound { 136 | return ResponseBadRequest(c, "User not found.", nil) 137 | } 138 | return ResponseInternalServerError(c, "Find user failed", err) 139 | } 140 | 141 | return ResponseOK(c, UserGETResponse{ 142 | ID: user.ID, 143 | UserName: user.UserName, 144 | Role: user.Role, 145 | }) 146 | } 147 | -------------------------------------------------------------------------------- /extension/popup.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', async () => { 2 | const loginForm = document.getElementById('loginForm'); 3 | const loggedInView = document.getElementById('loggedInView'); 4 | const loginBtn = document.getElementById('loginBtn'); 5 | const logoutBtn = document.getElementById('logoutBtn'); 6 | const saveCustomBtn = document.getElementById('saveCustomBtn'); 7 | const loginMessage = document.getElementById('loginMessage'); 8 | const statusMessage = document.getElementById('statusMessage'); 9 | const userNameDisplay = document.getElementById('userNameDisplay'); 10 | 11 | // 检查登录状态 12 | const checkLoginStatus = async () => { 13 | const response = await chrome.runtime.sendMessage({ action: 'checkLogin' }); 14 | 15 | if (response.isLoggedIn) { 16 | const { userName } = await chrome.storage.local.get('userName'); 17 | userNameDisplay.textContent = `欢迎, ${userName || '用户'}`; 18 | loginForm.style.display = 'none'; 19 | loggedInView.style.display = 'block'; 20 | 21 | // 自动发送添加当前页面的请求 22 | await saveCurrentPage(); 23 | } else { 24 | loginForm.style.display = 'block'; 25 | loggedInView.style.display = 'none'; 26 | } 27 | }; 28 | 29 | // 保存当前页面 30 | const saveCurrentPage = async () => { 31 | statusMessage.textContent = '正在保存当前页面...'; 32 | statusMessage.className = 'message info'; 33 | 34 | const result = await chrome.runtime.sendMessage({ action: 'addCurrentPage' }); 35 | 36 | if (result.success) { 37 | statusMessage.textContent = '页面已成功保存到ToRead'; 38 | statusMessage.className = 'message success'; 39 | } else { 40 | statusMessage.textContent = result.message || '保存失败'; 41 | statusMessage.className = 'message error'; 42 | } 43 | }; 44 | 45 | // 初始检查登录状态 46 | await checkLoginStatus(); 47 | 48 | // 登录按钮点击事件 49 | loginBtn.addEventListener('click', async () => { 50 | const username = document.getElementById('username').value; 51 | const password = document.getElementById('password').value; 52 | 53 | if (!username || !password) { 54 | loginMessage.textContent = '请输入用户名和密码'; 55 | loginMessage.className = 'message error'; 56 | return; 57 | } 58 | 59 | loginMessage.textContent = '登录中...'; 60 | loginMessage.className = 'message info'; 61 | 62 | const result = await chrome.runtime.sendMessage({ 63 | action: 'login', 64 | username, 65 | password 66 | }); 67 | 68 | if (result.success) { 69 | loginMessage.textContent = '登录成功'; 70 | loginMessage.className = 'message success'; 71 | await checkLoginStatus(); 72 | } else { 73 | loginMessage.textContent = result.message || '登录失败'; 74 | loginMessage.className = 'message error'; 75 | } 76 | }); 77 | 78 | // 退出登录按钮点击事件 79 | logoutBtn.addEventListener('click', async () => { 80 | await chrome.storage.local.remove(['token', 'tokenExpirationTime', 'userId', 'userName']); 81 | await checkLoginStatus(); 82 | }); 83 | 84 | // 保存自定义链接按钮点击事件 85 | saveCustomBtn.addEventListener('click', async () => { 86 | const url = document.getElementById('customUrl').value; 87 | 88 | if (!url) { 89 | statusMessage.textContent = '请输入URL'; 90 | statusMessage.className = 'message error'; 91 | return; 92 | } 93 | 94 | statusMessage.textContent = '正在保存链接...'; 95 | statusMessage.className = 'message info'; 96 | 97 | const result = await chrome.runtime.sendMessage({ 98 | action: 'addCustomUrl', 99 | url 100 | }); 101 | 102 | if (result.success) { 103 | statusMessage.textContent = '链接已成功保存到ToRead'; 104 | statusMessage.className = 'message success'; 105 | document.getElementById('customUrl').value = ''; 106 | } else { 107 | statusMessage.textContent = result.message || '保存失败'; 108 | statusMessage.className = 'message error'; 109 | } 110 | }); 111 | 112 | // 添加点击外部关闭弹窗功能 113 | document.addEventListener('mouseout', (event) => { 114 | // 检查鼠标是否离开了弹窗区域 115 | if (!event.relatedTarget || !document.body.contains(event.relatedTarget)) { 116 | // 添加一个点击事件监听器,当点击其他地方时关闭弹窗 117 | const closePopup = (e) => { 118 | window.close(); 119 | document.removeEventListener('click', closePopup); 120 | }; 121 | 122 | // 延迟添加点击事件,避免立即触发 123 | setTimeout(() => { 124 | document.addEventListener('click', closePopup); 125 | }, 300); 126 | } 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /backend/model/collection.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | "to-read/utils/logs" 6 | 7 | "go.uber.org/zap" 8 | "gorm.io/gorm" 9 | ) 10 | 11 | type Collection struct { 12 | ID uint32 `json:"collection_id" form:"collection_id" query:"collection_id" gorm:"primaryKey;unique;autoIncrement;not null"` 13 | CreatedAt time.Time `json:"created_at" form:"created_at" query:"created_at"` 14 | UpdatedAt time.Time `json:"updated_at" form:"updated_at" query:"updated_at"` 15 | DeletedAt gorm.DeletedAt `json:"deleted_at" form:"deleted_at" query:"deleted_at"` 16 | UserID uint32 `json:"user_id" form:"user_id" query:"user_id" gorm:"not null;uniqueIndex:idx_user_url"` 17 | Url string `json:"url" form:"url" query:"url" gorm:"not null;uniqueIndex:idx_user_url"` 18 | Type string `json:"type" form:"type" query:"type"` // one of text/image/video 19 | Title string `json:"title" form:"title" query:"title" gorm:"type text"` 20 | Description string `json:"description" form:"description" query:"description" gorm:"type text"` 21 | } 22 | 23 | func FindCollectionByUrl(userID uint32, url string) (Collection, error) { 24 | m := GetModel() 25 | defer m.Close() 26 | 27 | var collection Collection 28 | result := m.tx.Where("user_id = ? AND url = ?", userID, url).First(&collection) 29 | if result.Error != nil { 30 | logs.Info("Find collection by URL failed.", zap.Error(result.Error), zap.String("url", url)) 31 | m.Abort() 32 | return collection, result.Error 33 | } 34 | 35 | m.tx.Commit() 36 | return collection, nil 37 | } 38 | 39 | func AddCollection(userID uint32, url string, type_ string, title string, description string, tags []string) (Collection, []CollectionWithTag, error) { 40 | m := GetModel() 41 | defer m.Close() 42 | 43 | collection := Collection{ 44 | UserID: userID, 45 | Url: url, 46 | Type: type_, 47 | Title: title, 48 | Description: description, 49 | } 50 | cwt := make([]CollectionWithTag, 0, len(tags)) 51 | result := m.tx.Create(&collection) 52 | if result.Error != nil { 53 | logs.Info("Create collection failed.", zap.Error(result.Error), zap.Any("collection", collection)) 54 | m.Abort() 55 | return collection, cwt, result.Error 56 | } 57 | 58 | cwt, err := AddCollectionWithTags(collection.ID, tags) 59 | if err != nil { 60 | logs.Info("Add collection with tags failed.", zap.Error(result.Error), zap.Any("collection", collection)) 61 | m.Abort() 62 | return collection, cwt, result.Error 63 | } 64 | 65 | m.tx.Commit() 66 | return collection, cwt, nil 67 | } 68 | 69 | // SearchCollection 根据用户ID、关键词和标签列表搜索收藏 70 | func SearchCollection(userID uint32, keyword string, tags []string) ([]Collection, error) { 71 | m := GetModel() 72 | defer m.Close() 73 | 74 | // 构建基本查询 75 | query := m.tx.Model(&Collection{}).Where("collections.user_id = ?", userID) 76 | 77 | // 关键词过滤 78 | if keyword != "" { 79 | query = query.Where("(collections.title ILIKE ? OR collections.description ILIKE ?)", 80 | "%"+keyword+"%", "%"+keyword+"%") 81 | } 82 | 83 | // 标签过滤 84 | if len(tags) > 0 { 85 | // 先查找所有标签的ID 86 | var tagIDs []uint32 87 | for _, tagName := range tags { 88 | tag, err := FindTagByName(tagName) 89 | if err != nil { 90 | if err == gorm.ErrRecordNotFound { 91 | // 标签不存在,返回空结果 92 | return []Collection{}, nil 93 | } 94 | logs.Warn("Find tag by name failed", zap.Error(err), zap.String("tag_name", tagName)) 95 | m.Abort() 96 | return nil, err 97 | } 98 | tagIDs = append(tagIDs, tag.ID) 99 | } 100 | 101 | // 对每个标签ID,我们需要确保收藏与之关联 102 | for _, tagID := range tagIDs { 103 | // 使用子查询确保收藏包含当前标签 104 | subQuery := m.tx.Model(&CollectionWithTag{}). 105 | Select("collection_id"). 106 | Where("tag_id = ?", tagID) 107 | 108 | query = query.Where("collections.id IN (?)", subQuery) 109 | } 110 | } 111 | 112 | // 执行查询并去重 113 | var collections []Collection 114 | result := query.Distinct().Find(&collections) 115 | if result.Error != nil { 116 | logs.Error("Search collections failed", 117 | zap.Error(result.Error), 118 | zap.Uint32("user_id", userID), 119 | zap.String("keyword", keyword), 120 | zap.Any("tags", tags)) 121 | m.Abort() 122 | return nil, result.Error 123 | } 124 | 125 | m.tx.Commit() 126 | return collections, nil 127 | } 128 | -------------------------------------------------------------------------------- /frontend/src/components/Layout/Header.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { 3 | AppBar, 4 | Toolbar, 5 | Typography, 6 | Button, 7 | IconButton, 8 | TextField, 9 | InputAdornment, 10 | Box, 11 | Menu, 12 | MenuItem, 13 | Avatar 14 | } from '@mui/material'; 15 | import { 16 | Search as SearchIcon, 17 | AccountCircle, 18 | Add as AddIcon 19 | } from '@mui/icons-material'; 20 | import { useNavigate } from 'react-router-dom'; 21 | import { useAuth } from '../../context/AuthContext'; 22 | 23 | interface HeaderProps { 24 | onSearch?: (query: string) => void; 25 | onAddClick?: () => void; 26 | } 27 | 28 | const Header: React.FC = ({ onSearch, onAddClick }) => { 29 | const { user, logout, isAuthenticated } = useAuth(); 30 | const navigate = useNavigate(); 31 | const [searchQuery, setSearchQuery] = useState(''); 32 | const [anchorEl, setAnchorEl] = useState(null); 33 | 34 | const handleMenu = (event: React.MouseEvent) => { 35 | setAnchorEl(event.currentTarget); 36 | }; 37 | 38 | const handleClose = () => { 39 | setAnchorEl(null); 40 | }; 41 | 42 | const handleLogout = () => { 43 | handleClose(); 44 | logout(); 45 | }; 46 | 47 | const handleSearchSubmit = (e: React.FormEvent) => { 48 | e.preventDefault(); 49 | console.log('Search submitted:', searchQuery); 50 | if (onSearch) { 51 | onSearch(searchQuery); 52 | } 53 | }; 54 | 55 | return ( 56 | 57 | 58 | navigate('/')} 63 | > 64 | ToRead 65 | 66 | 67 | {isAuthenticated && ( 68 | <> 69 | 70 | setSearchQuery(e.target.value)} 75 | onKeyDown={(e) => { 76 | if (e.key === 'Enter') { 77 | e.preventDefault(); 78 | if (onSearch) { 79 | onSearch(searchQuery); 80 | } 81 | } 82 | }} 83 | sx={{ 84 | backgroundColor: 'rgba(255, 255, 255, 0.15)', 85 | borderRadius: 1, 86 | '& .MuiOutlinedInput-root': { 87 | color: 'white', 88 | '& fieldset': { border: 'none' }, 89 | }, 90 | width: { xs: '100%', sm: '50%', md: '40%' } 91 | }} 92 | InputProps={{ 93 | startAdornment: ( 94 | 95 | { 98 | if (onSearch) { 99 | onSearch(searchQuery); 100 | } 101 | }} 102 | /> 103 | 104 | ), 105 | }} 106 | /> 107 | 108 | 109 | 114 | 115 | 116 | 117 |
118 | 123 | 124 | {user?.user_name.charAt(0).toUpperCase()} 125 | 126 | 127 | 142 | 143 | {user?.user_name} 144 | 145 | 退出登录 146 | 147 |
148 | 149 | )} 150 | 151 | {!isAuthenticated && ( 152 |
153 | 154 | 155 |
156 | )} 157 |
158 |
159 | ); 160 | }; 161 | 162 | export default Header; 163 | -------------------------------------------------------------------------------- /frontend/src/components/Auth/Login.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { 3 | Container, 4 | Typography, 5 | TextField, 6 | Button, 7 | Box, 8 | Paper, 9 | Link, 10 | InputAdornment, 11 | IconButton 12 | } from '@mui/material'; 13 | import { Visibility, VisibilityOff } from '@mui/icons-material'; 14 | import { Link as RouterLink } from 'react-router-dom'; 15 | import { useAuth } from '../../context/AuthContext'; 16 | import ErrorMessage from '../Common/ErrorMessage'; 17 | import Loading from '../Common/Loading'; 18 | import NoticeDialog from '../Common/NoticeDialog'; 19 | 20 | const Login: React.FC = () => { 21 | const { login, loading, error } = useAuth(); 22 | const [username, setUsername] = useState(''); 23 | const [password, setPassword] = useState(''); 24 | const [showPassword, setShowPassword] = useState(false); 25 | const [showNotice, setShowNotice] = useState(false); 26 | const [formErrors, setFormErrors] = useState({ 27 | username: '', 28 | password: '' 29 | }); 30 | 31 | useEffect(() => { 32 | const hasSeenNotice = localStorage.getItem('hasSeenNotice'); 33 | const currentTime = new Date().getTime(); 34 | const lastNoticeTime = parseInt(localStorage.getItem('lastNoticeTime') || '0'); 35 | 36 | if (!hasSeenNotice || (currentTime - lastNoticeTime > 24 * 60 * 60 * 1000)) { 37 | setShowNotice(true); 38 | } 39 | }, []); 40 | 41 | const validateForm = () => { 42 | let isValid = true; 43 | const errors = { 44 | username: '', 45 | password: '' 46 | }; 47 | 48 | if (!username.trim()) { 49 | errors.username = '用户名不能为空'; 50 | isValid = false; 51 | } 52 | 53 | if (!password) { 54 | errors.password = '密码不能为空'; 55 | isValid = false; 56 | } 57 | 58 | setFormErrors(errors); 59 | return isValid; 60 | }; 61 | 62 | const handleSubmit = async (e: React.FormEvent) => { 63 | e.preventDefault(); 64 | if (validateForm()) { 65 | await login(username, password); 66 | } 67 | }; 68 | 69 | const handleNoticeClose = () => { 70 | setShowNotice(false); 71 | localStorage.setItem('hasSeenNotice', 'true'); 72 | localStorage.setItem('lastNoticeTime', new Date().getTime().toString()); 73 | }; 74 | 75 | return ( 76 | 77 | 85 | 93 | 94 | 登录 ToRead 95 | 96 | 97 | {error && } 98 | 99 | {loading ? ( 100 | 101 | ) : ( 102 | 103 | setUsername(e.target.value)} 114 | error={!!formErrors.username} 115 | helperText={formErrors.username} 116 | /> 117 | 118 | setPassword(e.target.value)} 129 | error={!!formErrors.password} 130 | helperText={formErrors.password} 131 | InputProps={{ 132 | endAdornment: ( 133 | 134 | setShowPassword(!showPassword)} 136 | edge="end" 137 | > 138 | {showPassword ? : } 139 | 140 | 141 | ) 142 | }} 143 | /> 144 | 145 | 153 | 154 | 155 | 156 | {"没有账号?立即注册"} 157 | 158 | 159 | 160 | )} 161 | 162 | 163 | 164 | {/* 通知对话框 */} 165 | 169 | 170 | ); 171 | }; 172 | 173 | export default Login; 174 | -------------------------------------------------------------------------------- /backend/docs/api.md: -------------------------------------------------------------------------------- 1 | # ToRead API 文档 2 | 3 | ligen131 [i@lg.ee](mailto:i@lg.ee) 4 | 5 | ## 总览 6 | 7 | Link: 8 | 9 | + 1 [总览](#总览) 10 | + 2 [Health](#health) 11 | + 2.1 [[GET] `/health`](#get-health) 12 | + 3 [用户 User](#用户-user) 13 | + 3.1 [[GET] `/user`](#get-user) 14 | + 3.2 [[POST] `/user/register`](#post-userregister) 15 | + 3.3 [[POST] `/user/login`](#post-userlogin) 16 | + 4 [链接收藏 Collection](#链接收藏-collection) 17 | + 4.1 [*[GET] `/collection/list`](#get-collectionlist) 18 | + 4.2 [*[POST] `/collection/add`](#post-collectionadd) 19 | + 4.3 [*[GET] `/collection/summary`](#get-collectionsummary) 20 | + 4.4 [*[GET] `/collection/tag`](#get-collectiontag) 21 | 22 | 在标题带 `*` 标识的请求中,请在请求头中提供登录获取到的 JWT token。 23 | 24 | **所有 GET 都使用 QueryString 格式而非 JSON Body。** 25 | 26 | ```yaml 27 | Authorization: Bearer 28 | ``` 29 | 30 | ## Health 31 | 32 | ### [GET] `/health` 33 | 34 | 获取服务状态。 35 | 36 | #### Request 37 | 38 | 无。 39 | 40 | #### Response 41 | 42 | ```json 43 | { 44 | "code": 200, 45 | "msg": null, 46 | "data": "ok", 47 | } 48 | ``` 49 | 50 | ## 用户 User 51 | 52 | - `user_id`: 用户注册时由后端生成的用户 ID,递增整数。 53 | - `user_name`: 用户自定义昵称,字符串。 54 | - `role`: 用户角色,整数,目前只有一种角色。 55 | 56 | ### [GET] `/user` 57 | 58 | #### Request 59 | 60 | ```json 61 | { 62 | "user_id": 1, 63 | "user_name": "ligen131" 64 | } 65 | ``` 66 | 67 | `user_id` 和 `user_name` 二选一,若都提供则优先使用 `user_id`。 68 | 69 | #### Response 70 | 71 | ```json 72 | { 73 | "code": 200, 74 | "msg": null, 75 | "data": { 76 | "user_id": 1, 77 | "user_name": "ligen131", 78 | "role": 1 79 | }, 80 | } 81 | ``` 82 | 83 | ### [POST] `/user/register` 84 | 85 | 用户注册。 86 | 87 | #### Request 88 | 89 | ```json 90 | { 91 | "user_name": "ligen131", 92 | "password": "xxxxxx", 93 | } 94 | ``` 95 | 96 | - `user_name`: 用户自定义昵称。 97 | - `password`: 用户密码。 98 | 99 | #### Response 100 | 101 | ```json 102 | { 103 | "code": 200, 104 | "msg": null, 105 | "data": { 106 | "user_id": 1, 107 | "user_name": "ligen131", 108 | "role": 1 109 | } 110 | } 111 | ``` 112 | 113 | 若注册成功,返回用户信息。若失败,在 `data.msg` 中返回错误信息。 114 | 115 | ### [POST] `/user/login` 116 | 117 | 用户登录系统。 118 | 119 | #### Request 120 | 121 | ```json 122 | { 123 | "user_name": "ligen131", 124 | "password": "xxxxxx", 125 | } 126 | ``` 127 | 128 | #### Response 129 | 130 | ```json 131 | { 132 | "code": 200, 133 | "msg": null, 134 | "data": { 135 | "user_id": 1, 136 | "user_name": "ligen131", 137 | "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxM...", 138 | "token_expiration_time": 1683561600, 139 | }, 140 | } 141 | ``` 142 | 143 | 若登录成功,返回用户信息。若失败,在 `data.msg` 中返回错误信息。 144 | 145 | - `token`: 登录时获取 JWT token,请在与用户权限相关的请求发送时在请求头中包含该 token。 146 | 147 | ```yaml 148 | Authorization: Bearer 149 | ``` 150 | - `token_expiration_time`: token 过期时间,格式:Unix 时间戳。由于暂时不设置 `refresh_token` 接口,故过期时间可能会很长。 151 | 152 | ## 链接收藏 Collection 153 | 154 | ### *[GET] `/collection/list` 155 | 156 | 获取用户收藏列表。 157 | 158 | #### Request 159 | 160 | ```json 161 | { 162 | "search": "title or description including sth.", 163 | "tags": [ 164 | "tag1", 165 | "tag2", 166 | ] 167 | } 168 | ``` 169 | 170 | - `search`: 可选,搜索关键词,字符串,用于搜索收藏标题和描述。 171 | - `tags`: 可选,标签,字符串数组,包含多个标签时是 and 关系。 172 | 173 | #### Response 174 | 175 | ```json 176 | { 177 | "code": 200, 178 | "msg": null, 179 | "data": { 180 | "collections": [ 181 | { 182 | "collection_id": 1, 183 | "url": "https://example.com", 184 | "type": "text", 185 | "title": "Example", 186 | "description": "This is an example.", 187 | "tags": ["example", "test"], 188 | "created_at": 1683561600, 189 | }, 190 | { 191 | "collection_id": 2, 192 | "url": "https://example2.com/xxx.jpg", 193 | "type": "image", 194 | "title": "Example 2", 195 | "description": "This is an example 2.", 196 | "tags": ["example", "test"], 197 | "created_at": 1683561600, 198 | }, 199 | ] 200 | }, 201 | } 202 | ``` 203 | 204 | - `collection_id`: 收藏 ID,递增整数。 205 | - `type`: 收藏类型,字符串,可能为 `text` 、 `image` 、 `video` 三种类型。 206 | - `url`: 收藏链接,字符串。 207 | - `title`: 收藏标题,字符串。 208 | - `description`: 收藏描述,字符串。 209 | - `tags`: 标签,字符串数组。 210 | - `created_at`: 收藏创建时间,格式:Unix 时间戳。 211 | 212 | ### *[POST] `/collection/add` 213 | 214 | 添加收藏链接。 215 | 216 | #### Request 217 | 218 | ```json 219 | { 220 | "url": "https://example.com", 221 | } 222 | ``` 223 | 224 | #### Response 225 | 226 | ```json 227 | { 228 | "code": 200, 229 | "msg": null, 230 | "data": { 231 | "collection_id": 1, 232 | "url": "https://example.com", 233 | "type": "text", 234 | "title": "Example", 235 | "description": "This is an example.", 236 | "tags": ["example", "test"], 237 | "created_at": 1683561600, 238 | }, 239 | } 240 | ``` 241 | 242 | 若收藏成功,返回 AI 总结。若失败,在 `data.msg` 中返回错误信息。 243 | 244 | ### *[GET] `/collection/summary` 245 | 246 | 获取已收藏的链接的 AI 总结。 247 | 248 | #### Request 249 | 250 | ```json 251 | { 252 | "search": "title or description including sth.", 253 | "tags": [ 254 | "tag1", 255 | "tag2", 256 | ] 257 | } 258 | ``` 259 | 260 | - `search`: 可选,搜索关键词,字符串,用于搜索收藏标题和描述。 261 | - `tags`: 可选,标签,字符串数组,包含多个标签时是 and 关系。 262 | 263 | 同用户收藏列表接口。 264 | 265 | #### Response 266 | 267 | ```json 268 | { 269 | "code": 200, 270 | "msg": null, 271 | "data": { 272 | "summary": "AI summary here." 273 | }, 274 | } 275 | ``` 276 | 277 | ### *[GET] `/collection/tag` 278 | 279 | 获取已收藏的链接的所有 tag。 280 | 281 | #### Request 282 | 283 | None. 284 | 285 | #### Response 286 | 287 | ```json 288 | { 289 | "code": 200, 290 | "msg": null, 291 | "data": { 292 | "tags": [ 293 | "tag1", 294 | "tag2", 295 | ] 296 | }, 297 | } 298 | ``` 299 | -------------------------------------------------------------------------------- /frontend/src/components/Auth/Register.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { 3 | Container, 4 | Typography, 5 | TextField, 6 | Button, 7 | Box, 8 | Paper, 9 | Link, 10 | InputAdornment, 11 | IconButton 12 | } from '@mui/material'; 13 | import { Visibility, VisibilityOff } from '@mui/icons-material'; 14 | import { Link as RouterLink, useNavigate } from 'react-router-dom'; 15 | import { useAuth } from '../../context/AuthContext'; 16 | import ErrorMessage from '../Common/ErrorMessage'; 17 | import Loading from '../Common/Loading'; 18 | import NoticeDialog from '../Common/NoticeDialog'; 19 | 20 | const Register: React.FC = () => { 21 | const { register, loading, error } = useAuth(); 22 | const navigate = useNavigate(); 23 | const [username, setUsername] = useState(''); 24 | const [password, setPassword] = useState(''); 25 | const [confirmPassword, setConfirmPassword] = useState(''); 26 | const [showPassword, setShowPassword] = useState(false); 27 | const [showNotice, setShowNotice] = useState(false); 28 | const [formErrors, setFormErrors] = useState({ 29 | username: '', 30 | password: '', 31 | confirmPassword: '' 32 | }); 33 | 34 | useEffect(() => { 35 | const hasSeenNotice = localStorage.getItem('hasSeenNotice'); 36 | const currentTime = new Date().getTime(); 37 | const lastNoticeTime = parseInt(localStorage.getItem('lastNoticeTime') || '0'); 38 | 39 | if (!hasSeenNotice || (currentTime - lastNoticeTime > 24 * 60 * 60 * 1000)) { 40 | setShowNotice(true); 41 | } 42 | }, []); 43 | 44 | const validateForm = () => { 45 | let isValid = true; 46 | const errors = { 47 | username: '', 48 | password: '', 49 | confirmPassword: '' 50 | }; 51 | 52 | if (!username.trim()) { 53 | errors.username = '用户名不能为空'; 54 | isValid = false; 55 | } else if (username.length < 3) { 56 | errors.username = '用户名至少需要3个字符'; 57 | isValid = false; 58 | } 59 | 60 | if (!password) { 61 | errors.password = '密码不能为空'; 62 | isValid = false; 63 | } else if (password.length < 6) { 64 | errors.password = '密码至少需要6个字符'; 65 | isValid = false; 66 | } 67 | 68 | if (password !== confirmPassword) { 69 | errors.confirmPassword = '两次输入的密码不一致'; 70 | isValid = false; 71 | } 72 | 73 | setFormErrors(errors); 74 | return isValid; 75 | }; 76 | 77 | const handleSubmit = async (e: React.FormEvent) => { 78 | e.preventDefault(); 79 | if (validateForm()) { 80 | await register(username, password); 81 | } 82 | }; 83 | 84 | const handleNoticeClose = () => { 85 | setShowNotice(false); 86 | localStorage.setItem('hasSeenNotice', 'true'); 87 | localStorage.setItem('lastNoticeTime', new Date().getTime().toString()); 88 | }; 89 | 90 | return ( 91 | 92 | 100 | 108 | 109 | 注册 ToRead 110 | 111 | 112 | {error && } 113 | 114 | {loading ? ( 115 | 116 | ) : ( 117 | 118 | setUsername(e.target.value)} 129 | error={!!formErrors.username} 130 | helperText={formErrors.username} 131 | /> 132 | 133 | setPassword(e.target.value)} 144 | error={!!formErrors.password} 145 | helperText={formErrors.password} 146 | InputProps={{ 147 | endAdornment: ( 148 | 149 | setShowPassword(!showPassword)} 151 | edge="end" 152 | > 153 | {showPassword ? : } 154 | 155 | 156 | ) 157 | }} 158 | /> 159 | 160 | setConfirmPassword(e.target.value)} 170 | error={!!formErrors.confirmPassword} 171 | helperText={formErrors.confirmPassword} 172 | InputProps={{ 173 | endAdornment: ( 174 | 175 | setShowPassword(!showPassword)} 177 | edge="end" 178 | > 179 | {showPassword ? : } 180 | 181 | 182 | ) 183 | }} 184 | /> 185 | 186 | 194 | 195 | 196 | 197 | {"已有账号?立即登录"} 198 | 199 | 200 | 201 | )} 202 | 203 | 204 | 205 | {/* 通知对话框 */} 206 | 210 | 211 | ); 212 | }; 213 | 214 | export default Register; 215 | -------------------------------------------------------------------------------- /frontend/src/components/Collection/CollectionList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { 3 | Box, 4 | Typography, 5 | Card, 6 | CardContent, 7 | CardActionArea, 8 | Chip, 9 | Grid, 10 | Button, 11 | Container, 12 | Divider 13 | } from '@mui/material'; 14 | import { 15 | Link as LinkIcon, 16 | Image as ImageIcon, 17 | VideoLibrary as VideoIcon 18 | } from '@mui/icons-material'; 19 | import moment from 'moment'; 20 | import { collectionAPI } from '../../services/api'; 21 | import Loading from '../Common/Loading'; 22 | import ErrorMessage from '../Common/ErrorMessage'; 23 | import Summary from './Summary'; 24 | import AddCollection from './AddCollection'; 25 | 26 | interface Collection { 27 | collection_id: number; 28 | url: string; 29 | type: 'text' | 'image' | 'video'; 30 | title: string; 31 | description: string; 32 | tags: string[]; 33 | created_at: number; 34 | } 35 | 36 | interface CollectionListProps { 37 | searchQuery?: string; 38 | } 39 | 40 | const CollectionList: React.FC = ({ searchQuery: externalSearchQuery = '' }) => { 41 | const [collections, setCollections] = useState([]); 42 | const [loading, setLoading] = useState(true); 43 | const [error, setError] = useState(null); 44 | const [tags, setTags] = useState([]); 45 | const [selectedTags, setSelectedTags] = useState([]); 46 | const [internalSearchQuery, setInternalSearchQuery] = useState(''); 47 | const [showSummary, setShowSummary] = useState(false); 48 | const [showAddCollection, setShowAddCollection] = useState(false); 49 | 50 | // 使用外部搜索查询更新内部状态 51 | useEffect(() => { 52 | setInternalSearchQuery(externalSearchQuery); 53 | }, [externalSearchQuery]); 54 | 55 | // 获取所有标签 56 | const fetchTags = async () => { 57 | try { 58 | const response = await collectionAPI.getTags(); 59 | if (response.code === 200) { 60 | setTags(response.data.tags); 61 | } 62 | } catch (err: any) { 63 | console.error('获取标签失败:', err); 64 | } 65 | }; 66 | 67 | // 获取收藏列表 68 | const fetchCollections = async () => { 69 | try { 70 | setLoading(true); 71 | setError(null); 72 | 73 | const params: { search?: string; tags?: string[] } = {}; 74 | if (internalSearchQuery) { 75 | params.search = internalSearchQuery; 76 | } 77 | if (selectedTags.length > 0) { 78 | params.tags = selectedTags; 79 | } 80 | 81 | const response = await collectionAPI.getList(params); 82 | 83 | if (response.code === 200) { 84 | // 按创建时间从新到旧排序 85 | const sortedCollections = [...(response.data.collections || [])].sort( 86 | (a, b) => b.created_at - a.created_at 87 | ); 88 | setCollections(sortedCollections); 89 | } else { 90 | setError(response.msg || '获取收藏列表失败'); 91 | } 92 | } catch (err: any) { 93 | setError(err.response?.data?.msg || '获取收藏列表失败,请检查网络连接'); 94 | } finally { 95 | setLoading(false); 96 | } 97 | }; 98 | 99 | useEffect(() => { 100 | fetchTags(); 101 | fetchCollections(); 102 | }, []); 103 | 104 | useEffect(() => { 105 | fetchCollections(); 106 | }, [internalSearchQuery, selectedTags]); 107 | 108 | const handleTagClick = (tag: string) => { 109 | setSelectedTags(prev => { 110 | if (prev.includes(tag)) { 111 | return prev.filter(t => t !== tag); 112 | } else { 113 | return [...prev, tag]; 114 | } 115 | }); 116 | }; 117 | 118 | const handleAddCollection = () => { 119 | setShowAddCollection(true); 120 | }; 121 | 122 | const handleCollectionAdded = () => { 123 | setShowAddCollection(false); 124 | fetchCollections(); 125 | fetchTags(); 126 | }; 127 | 128 | const getIconByType = (type: string) => { 129 | switch (type) { 130 | case 'image': 131 | return ; 132 | case 'video': 133 | return ; 134 | default: 135 | return ; 136 | } 137 | }; 138 | 139 | return ( 140 | 141 | 142 | {/* 标签筛选区域 */} 143 | 144 | 145 | 标签筛选 146 | 147 | 148 | {tags.map((tag) => ( 149 | handleTagClick(tag)} 155 | sx={{ mb: 1 }} 156 | /> 157 | ))} 158 | 159 | 160 | 161 | 162 | 163 | {selectedTags.length > 0 || internalSearchQuery ? '筛选结果' : '我的收藏'} 164 | {internalSearchQuery && ( 165 | 166 | 搜索: "{internalSearchQuery}" 167 | 168 | )} 169 | 170 | 177 | 178 | 179 | {/* 总结区域 */} 180 | {showSummary && ( 181 | 185 | )} 186 | 187 | {/* 添加收藏对话框 */} 188 | setShowAddCollection(false)} 191 | onAdded={handleCollectionAdded} 192 | /> 193 | 194 | {/* 收藏列表 */} 195 | {loading ? ( 196 | 197 | ) : error ? ( 198 | 199 | ) : collections.length === 0 ? ( 200 | 201 | 202 | 暂无收藏内容 203 | 204 | 211 | 212 | ) : ( 213 | 214 | {collections.map((collection) => ( 215 | 216 | 217 | 224 | 225 | 226 | {getIconByType(collection.type)} 227 | 228 | {collection.title} 229 | 230 | 231 | 232 | {collection.description} 233 | 234 | 245 | {collection.url} 246 | 247 | 248 | {collection.tags.map((tag) => ( 249 | { 254 | e.preventDefault(); 255 | e.stopPropagation(); 256 | handleTagClick(tag); 257 | }} 258 | /> 259 | ))} 260 | 261 | 262 | 263 | {moment.unix(collection.created_at).format('YYYY-MM-DD HH:mm')} 264 | 265 | 266 | 267 | 268 | 269 | ))} 270 | 271 | )} 272 | 273 | 274 | ); 275 | }; 276 | 277 | export default CollectionList; 278 | -------------------------------------------------------------------------------- /backend/controllers/collection.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | "time" 8 | "to-read/controllers/auth" 9 | "to-read/model" 10 | "to-read/shared/llmprocessor" 11 | "to-read/utils/logs" 12 | 13 | "github.com/labstack/echo/v4" 14 | "go.uber.org/zap" 15 | "gorm.io/gorm" 16 | ) 17 | 18 | // CollectionListRequest 收藏列表请求 19 | type CollectionListRequest struct { 20 | Search string `query:"search"` 21 | Tags []string `query:"tags"` 22 | } 23 | 24 | // CollectionListItem 收藏列表项 25 | type CollectionListItem struct { 26 | CollectionID uint32 `json:"collection_id"` 27 | Url string `json:"url"` 28 | Type string `json:"type"` 29 | Title string `json:"title"` 30 | Description string `json:"description"` 31 | Tags []string `json:"tags"` 32 | CreatedAt int64 `json:"created_at"` 33 | } 34 | 35 | // CollectionListResponse 收藏列表响应 36 | type CollectionListResponse struct { 37 | Collections []CollectionListItem `json:"collections"` 38 | } 39 | 40 | // CollectionListGET 获取收藏列表 41 | func CollectionListGET(c echo.Context) error { 42 | logs.Debug("GET /collection/list") 43 | 44 | // 解析请求参数 45 | req := new(CollectionListRequest) 46 | 47 | req.Search = c.QueryParam("search") 48 | tagsParam := c.QueryParam("tags") 49 | 50 | if tagsParam != "" { 51 | req.Tags = strings.Split(tagsParam, ",") 52 | } 53 | 54 | // 获取用户ID 55 | claims, err := auth.GetClaimsFromHeader(c) 56 | if err != nil { 57 | return ResponseBadRequest(c, err.Error(), nil) 58 | } 59 | userID := claims.UserID 60 | 61 | // 搜索收藏 62 | collections, err := model.SearchCollection(userID, req.Search, req.Tags) 63 | if err != nil { 64 | logs.Error("Failed to search collections", 65 | zap.Error(err), 66 | zap.Uint32("user_id", userID), 67 | zap.String("search", req.Search), 68 | zap.Any("tags", req.Tags)) 69 | return ResponseInternalServerError(c, "Failed to search collections", err) 70 | } 71 | 72 | // 构建响应 73 | response := CollectionListResponse{ 74 | Collections: make([]CollectionListItem, 0, len(collections)), 75 | } 76 | 77 | for _, collection := range collections { 78 | // 获取每个收藏关联的标签 79 | tags, err := model.GetCollectionTags(collection.ID) 80 | if err != nil { 81 | logs.Warn("Failed to get tags for collection", 82 | zap.Error(err), 83 | zap.Uint32("collection_id", collection.ID)) 84 | // 继续处理其他收藏,不因为一个收藏的标签获取失败而中断整个请求 85 | continue 86 | } 87 | 88 | item := CollectionListItem{ 89 | CollectionID: collection.ID, 90 | Url: collection.Url, 91 | Type: collection.Type, 92 | Title: collection.Title, 93 | Description: collection.Description, 94 | Tags: tags, 95 | CreatedAt: collection.CreatedAt.Unix(), 96 | } 97 | response.Collections = append(response.Collections, item) 98 | } 99 | 100 | return ResponseOK(c, response) 101 | } 102 | 103 | type CollectionAddRequest struct { 104 | Url string `json:"url"` 105 | } 106 | 107 | type CollectionAddResponse struct { 108 | CollectionID uint32 `json:"collection_id"` 109 | Url string `json:"url"` 110 | Type string `json:"type"` 111 | Title string `json:"title"` 112 | Description string `json:"description"` 113 | Tags []string `json:"tags"` 114 | CreateAt int64 `json:"create_at"` 115 | } 116 | 117 | func CollectionAddPOST(c echo.Context) error { 118 | logs.Debug("POST /collection/add") 119 | 120 | collectionRequest := CollectionAddRequest{} 121 | _ok, err := Bind(c, &collectionRequest) 122 | if !_ok { 123 | return err 124 | } 125 | url := collectionRequest.Url 126 | if url == "" { 127 | return ResponseBadRequest(c, "Url is required", nil) 128 | } 129 | 130 | claims, err := auth.GetClaimsFromHeader(c) 131 | if err != nil { 132 | return ResponseBadRequest(c, err.Error(), nil) 133 | } 134 | userID := claims.UserID 135 | 136 | // 检查URL是否已经被该用户收藏 137 | _, err = model.FindCollectionByUrl(userID, url) 138 | if err == nil { 139 | // URL已被收藏 140 | return ResponseBadRequest(c, "URL already collected", nil) 141 | } else if err != gorm.ErrRecordNotFound { 142 | // 发生其他错误 143 | logs.Error("Failed to check if URL is already collected", zap.Error(err), zap.String("url", url)) 144 | return ResponseInternalServerError(c, "Failed to check if URL is already collected", err) 145 | } 146 | 147 | processor := llmprocessor.NewLLMProcessor() 148 | summary, err := processor.ProcessURLAuto(url) 149 | if err != nil { 150 | logs.Warn("Failed to process URL with LLM", zap.Error(err), zap.String("url", url)) 151 | return ResponseInternalServerError(c, "Failed to process URL with LLM", err) 152 | } 153 | 154 | // 使用处理结果创建收藏 155 | collection, cwt, err := model.AddCollection( 156 | userID, 157 | url, 158 | summary.Type, 159 | summary.Title, 160 | summary.Description, 161 | summary.Tags, 162 | ) 163 | 164 | if err != nil { 165 | logs.Warn("Failed to add collection", zap.Error(err), zap.String("url", url), zap.Any("collection", collection), zap.Any("cwt", cwt)) 166 | return ResponseInternalServerError(c, "Add collection failed", err) 167 | } 168 | 169 | resp := CollectionAddResponse{ 170 | CollectionID: collection.ID, 171 | Url: collection.Url, 172 | Type: collection.Type, 173 | Title: collection.Title, 174 | Description: collection.Description, 175 | Tags: summary.Tags, 176 | CreateAt: collection.CreatedAt.Unix(), 177 | } 178 | 179 | return ResponseOK(c, resp) 180 | } 181 | 182 | // 不写数据库 183 | func CollectionGPOST(c echo.Context) error { 184 | logs.Debug("POST /g") 185 | 186 | collectionRequest := CollectionAddRequest{} 187 | _ok, err := BindJSON(c, &collectionRequest) 188 | if !_ok { 189 | return err 190 | } 191 | url := collectionRequest.Url 192 | if url == "" { 193 | return ResponseBadRequest(c, "Url is required", nil) 194 | } 195 | 196 | processor := llmprocessor.NewLLMProcessor() 197 | summary, err := processor.ProcessURLAuto(url) 198 | if err != nil { 199 | logs.Warn("Failed to process URL with LLM", zap.Error(err), zap.String("url", url)) 200 | return ResponseInternalServerError(c, "Failed to process URL with LLM", err) 201 | } 202 | 203 | resp := CollectionAddResponse{ 204 | CollectionID: 0, 205 | Url: url, 206 | Type: summary.Type, 207 | Title: summary.Title, 208 | Description: summary.Description, 209 | Tags: summary.Tags, 210 | CreateAt: time.Now().Unix(), 211 | } 212 | 213 | return ResponseOK(c, resp) 214 | } 215 | 216 | // CollectionSummaryRequest 收藏摘要请求 217 | type CollectionSummaryRequest struct { 218 | Search string `query:"search"` 219 | Tags []string `query:"tags"` 220 | } 221 | 222 | // CollectionSummaryResponse 收藏摘要响应 223 | type CollectionSummaryResponse struct { 224 | Summary string `json:"summary"` 225 | } 226 | 227 | // CollectionSummaryGET 获取收藏摘要 228 | func CollectionSummaryGET(c echo.Context) error { 229 | logs.Debug("GET /collection/summary") 230 | 231 | // 解析请求参数 232 | req := new(CollectionListRequest) 233 | 234 | req.Search = c.QueryParam("search") 235 | tagsParam := c.QueryParam("tags") 236 | 237 | if tagsParam != "" { 238 | req.Tags = strings.Split(tagsParam, ",") 239 | } 240 | 241 | // 获取用户ID 242 | claims, err := auth.GetClaimsFromHeader(c) 243 | if err != nil { 244 | return ResponseBadRequest(c, err.Error(), nil) 245 | } 246 | userID := claims.UserID 247 | 248 | // 搜索收藏 249 | collections, err := model.SearchCollection(userID, req.Search, req.Tags) 250 | if err != nil { 251 | logs.Error("Failed to search collections for summary", 252 | zap.Error(err), 253 | zap.Uint32("user_id", userID), 254 | zap.String("search", req.Search), 255 | zap.Any("tags", req.Tags)) 256 | return ResponseInternalServerError(c, "Failed to search collections", err) 257 | } 258 | 259 | if len(collections) == 0 { 260 | return ResponseOK(c, CollectionSummaryResponse{ 261 | Summary: "没有找到符合条件的收藏内容。", 262 | }) 263 | } 264 | 265 | // 构建摘要文本 266 | summary, err := generateSummaryFromCollections(collections) 267 | if err != nil { 268 | logs.Error("Failed to generate summary from collections", 269 | zap.Error(err), 270 | zap.Int("collection_count", len(collections))) 271 | return ResponseInternalServerError(c, "Failed to generate summary", err) 272 | } 273 | 274 | return ResponseOK(c, CollectionSummaryResponse{ 275 | Summary: summary, 276 | }) 277 | } 278 | 279 | // 从收藏列表生成摘要 280 | func generateSummaryFromCollections(collections []model.Collection) (string, error) { 281 | if len(collections) == 0 { 282 | return "没有找到符合条件的收藏内容。", nil 283 | } 284 | 285 | if len(collections) == 1 { 286 | return collections[0].Description, nil 287 | } 288 | 289 | // 创建LLM处理器 290 | processor := llmprocessor.NewLLMProcessor() 291 | 292 | // 构建输入文本 293 | var inputText strings.Builder 294 | 295 | // 收集标签信息以提供更多上下文 296 | allTags := make(map[string]int) 297 | for _, collection := range collections { 298 | tags, err := model.GetCollectionTags(collection.ID) 299 | if err == nil { 300 | for _, tag := range tags { 301 | allTags[tag]++ 302 | } 303 | } 304 | } 305 | 306 | // 找出最常见的标签 307 | type tagFreq struct { 308 | tag string 309 | count int 310 | } 311 | tagFreqs := make([]tagFreq, 0, len(allTags)) 312 | for tag, count := range allTags { 313 | tagFreqs = append(tagFreqs, tagFreq{tag, count}) 314 | } 315 | sort.Slice(tagFreqs, func(i, j int) bool { 316 | return tagFreqs[i].count > tagFreqs[j].count 317 | }) 318 | 319 | // 取前5个最常见的标签 320 | topTags := []string{} 321 | for i, tf := range tagFreqs { 322 | if i >= 5 { 323 | break 324 | } 325 | topTags = append(topTags, tf.tag) 326 | } 327 | 328 | // 构建详细的输入文本 329 | inputText.WriteString("## 收藏内容分析任务\n\n") 330 | inputText.WriteString(fmt.Sprintf("请分析以下%d篇收藏内容,生成一份综合报告。请直接给出报告内容,无需说出你的思路等等。\n\n", len(collections))) 331 | 332 | if len(topTags) > 0 { 333 | inputText.WriteString("主要标签: " + strings.Join(topTags, ", ") + "\n\n") 334 | } 335 | 336 | inputText.WriteString("## 收藏内容列表\n\n") 337 | 338 | for i, collection := range collections { 339 | inputText.WriteString(fmt.Sprintf("### 文章 %d\n", i+1)) 340 | inputText.WriteString(fmt.Sprintf("标题: %s\n", collection.Title)) 341 | inputText.WriteString(fmt.Sprintf("类型: %s\n", collection.Type)) 342 | 343 | // 获取标签 344 | tags, _ := model.GetCollectionTags(collection.ID) 345 | if len(tags) > 0 { 346 | inputText.WriteString(fmt.Sprintf("标签: %s\n", strings.Join(tags, ", "))) 347 | } 348 | 349 | inputText.WriteString(fmt.Sprintf("摘要: %s\n\n", collection.Description)) 350 | 351 | // 限制输入长度,避免超过LLM的上下文长度限制 352 | if i >= 9 && len(collections) > 10 { 353 | inputText.WriteString(fmt.Sprintf("... 以及其他 %d 篇收藏内容\n", len(collections)-10)) 354 | break 355 | } 356 | } 357 | 358 | // 构造一个特殊的虚拟URL,表示这是收藏摘要请求 359 | virtualURL := fmt.Sprintf("collection-summary://%d-articles", len(collections)) 360 | 361 | // 使用专门的摘要配置 362 | summaryConfig := llmprocessor.ProcessorConfig{ 363 | Enabled: true, 364 | APIEndpoint: processor.GetConfig().TextProcessor.APIEndpoint, 365 | APIKey: processor.GetConfig().TextProcessor.APIKey, 366 | Model: processor.GetConfig().TextProcessor.Model, 367 | // 使用专门的摘要提示词 368 | Prompt: getSummaryPrompt(), 369 | MaxTokens: 1500, 370 | Temperature: 1.0, 371 | } 372 | 373 | // 调用LLM处理器获取摘要 374 | summary, err := processor.GetContentSummaryFromText(inputText.String(), virtualURL, summaryConfig) 375 | if err != nil { 376 | return "", fmt.Errorf("failed to get summary from LLM: %w", err) 377 | } 378 | 379 | return summary.Description, nil 380 | } 381 | 382 | // getSummaryPrompt 获取专门的摘要生成提示词 383 | func getSummaryPrompt() string { 384 | return `你是一位专业的内容分析师和知识管理专家。你的任务是分析用户收藏的一组文章,并生成一份全面而有见解的综合报告。 385 | 386 | 请遵循以下指南: 387 | 1. 分析所有文章的主题和内容,找出共同的主题和关联 388 | 2. 识别关键的见解、观点和有价值的信息 389 | 3. 组织信息为一个连贯的摘要,而不是简单地列出每篇文章的内容 390 | 4. 如果文章之间有关联或互补的信息,请指出这些关联 391 | 5. 如果文章包含相互矛盾的观点,请客观地指出这些差异 392 | 6. 总结可能的行动要点或进一步探索的方向 393 | 7. 使用简体中文撰写摘要,语言应当清晰、专业且易于理解 394 | 8. 摘要应当是完整的段落,而不是要点列表 395 | 396 | 你的分析应该帮助用户理解这些收藏内容的整体价值和意义,而不仅仅是各个部分的简单总结。` 397 | } 398 | 399 | // CollectionTagResponse 收藏标签响应 400 | type CollectionTagResponse struct { 401 | Tags []string `json:"tags"` 402 | } 403 | 404 | // CollectionTagGET 获取收藏标签 405 | func CollectionTagGET(c echo.Context) error { 406 | logs.Debug("GET /collection/tag") 407 | 408 | // 获取用户ID 409 | claims, err := auth.GetClaimsFromHeader(c) 410 | if err != nil { 411 | return ResponseBadRequest(c, err.Error(), nil) 412 | } 413 | userID := claims.UserID 414 | 415 | // 获取用户的所有标签 416 | tags, err := model.GetTagList(userID) 417 | if err != nil { 418 | logs.Error("Failed to get tag list", zap.Error(err), zap.Uint32("user_id", userID)) 419 | return ResponseInternalServerError(c, "Failed to get tag list", err) 420 | } 421 | 422 | // 构建响应 423 | tagNames := make([]string, 0, len(tags)) 424 | for _, tag := range tags { 425 | tagNames = append(tagNames, tag.Name) 426 | } 427 | 428 | return ResponseOK(c, CollectionTagResponse{ 429 | Tags: tagNames, 430 | }) 431 | } 432 | -------------------------------------------------------------------------------- /backend/go.sum: -------------------------------------------------------------------------------- 1 | github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= 2 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= 7 | github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w= 8 | github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= 9 | github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 10 | github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= 11 | github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= 12 | github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= 13 | github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= 14 | github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE= 15 | github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= 16 | github.com/goccy/go-yaml v1.10.0 h1:rBi+5HGuznOxx0JZ+60LDY85gc0dyIJCIMvsMJTKSKQ= 17 | github.com/goccy/go-yaml v1.10.0/go.mod h1:h/18Lr6oSQ3mvmqFoWmQ47KChOgpfHpTyIHl3yVmpiY= 18 | github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 19 | github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 20 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 21 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 22 | github.com/gookit/color v1.5.2 h1:uLnfXcaFjlrDnQDT+NCBcfhrXqYTx/rcCa6xn01Y8yI= 23 | github.com/gookit/color v1.5.2/go.mod h1:w8h4bGiHeeBpvQVePTutdbERIUf3oJE5lZ8HM0UgXyg= 24 | github.com/gookit/config/v2 v2.2.1 h1:9WOXW5JCDwLcShdQZ1Ztzr67qrI63jjRmT+Cm3lzk7Q= 25 | github.com/gookit/config/v2 v2.2.1/go.mod h1:22ZTM0ve1ESyAx/ocUfjOrQ5ztFwy1Rs3YH1ifu9XXc= 26 | github.com/gookit/goutil v0.6.6 h1:XdvnPocHpKDXA+eykfc/F846Y1V2Vyo3+cV8rfliG90= 27 | github.com/gookit/goutil v0.6.6/go.mod h1:D++7kbQd/6vECyYTxB5tq6AKDIG9ZYwZNhubWJvN9dw= 28 | github.com/gookit/ini/v2 v2.2.1 h1:6fCrz8icnUHhYqGZwu7RtHLh+v+ErrgrAt9+aIcoJCc= 29 | github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= 30 | github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= 31 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 32 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 33 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= 34 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 35 | github.com/jackc/pgx/v5 v5.3.0 h1:/NQi8KHMpKWHInxXesC8yD4DhkXPrVhmnwYkjp9AmBA= 36 | github.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8= 37 | github.com/jackc/puddle/v2 v2.2.0/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 38 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 39 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 40 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 41 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 42 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 43 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 44 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 45 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 46 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 47 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 48 | github.com/labstack/echo/v4 v4.10.2 h1:n1jAhnq/elIFTHr1EYpiYtyKgx4RW9ccVgkqByZaN2M= 49 | github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k= 50 | github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8= 51 | github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= 52 | github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= 53 | github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= 54 | github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 55 | github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= 56 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 57 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 58 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 59 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 60 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 61 | github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= 62 | github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 63 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 64 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 65 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 66 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 67 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 68 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 69 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 70 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 71 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 72 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 73 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 74 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 75 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 76 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 77 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 78 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 79 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 80 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 81 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 82 | github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 83 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 84 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 85 | github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= 86 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 87 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 88 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 89 | go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= 90 | go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 91 | go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= 92 | go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= 93 | go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 94 | go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= 95 | go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= 96 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 97 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 98 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 99 | golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= 100 | golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= 101 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 102 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 103 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 104 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 105 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 106 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 107 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 108 | golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= 109 | golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 110 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 111 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 112 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 113 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 114 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 115 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 116 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 117 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 118 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 119 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 120 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 121 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 122 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 123 | golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 124 | golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 125 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 126 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 127 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 128 | golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= 129 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 130 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 131 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 132 | golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= 133 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 134 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 135 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 136 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 137 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 138 | golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= 139 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 140 | golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= 141 | golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 142 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 143 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 144 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 145 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 146 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 147 | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= 148 | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= 149 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 150 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 151 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 152 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 153 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 154 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 155 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 156 | gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 157 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 158 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 159 | gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U= 160 | gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A= 161 | gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= 162 | gorm.io/gorm v1.25.0 h1:+KtYtb2roDz14EQe4bla8CbQlmb9dN3VejSai3lprfU= 163 | gorm.io/gorm v1.25.0/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= 164 | -------------------------------------------------------------------------------- /backend/shared/llmprocessor/llmprocessor.go: -------------------------------------------------------------------------------- 1 | package llmprocessor 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "strings" 12 | "time" 13 | "to-read/utils/logs" 14 | 15 | "go.uber.org/zap" 16 | ) 17 | 18 | type LLMConfig struct { 19 | TextProcessor ProcessorConfig `yaml:"textProcessor"` 20 | ImageProcessor ProcessorConfig `yaml:"imageProcessor"` 21 | VideoProcessor ProcessorConfig `yaml:"videoProcessor"` 22 | } 23 | 24 | type ProcessorConfig struct { 25 | Enabled bool `yaml:"enabled"` 26 | APIEndpoint string `yaml:"apiEndpoint"` 27 | APIKey string `yaml:"apiKey"` 28 | Model string `yaml:"model"` 29 | Prompt string `yaml:"prompt"` 30 | MaxTokens int `yaml:"maxTokens"` 31 | Temperature float64 `yaml:"temperature"` 32 | } 33 | 34 | // ContentType 内容类型 35 | type ContentType string 36 | 37 | const ( 38 | TextType ContentType = "text" 39 | ImageType ContentType = "image" 40 | VideoType ContentType = "video" 41 | ) 42 | 43 | // ContentSummary 内容摘要 44 | type ContentSummary struct { 45 | Type string `json:"type"` 46 | Title string `json:"title"` 47 | Description string `json:"description"` 48 | Tags []string `json:"tags"` 49 | } 50 | 51 | // Processor LLM处理器接口 52 | type Processor interface { 53 | ProcessURL(url string) (ContentSummary, error) 54 | } 55 | 56 | // LLMProcessor 大语言模型处理器 57 | type LLMProcessor struct { 58 | config LLMConfig 59 | client *http.Client 60 | } 61 | 62 | var config LLMConfig 63 | 64 | func InitLLMProcessor(cfg LLMConfig) error { 65 | config = cfg 66 | 67 | return nil 68 | } 69 | 70 | // NewLLMProcessor 创建新的LLM处理器 71 | func NewLLMProcessor() *LLMProcessor { 72 | return &LLMProcessor{ 73 | config: config, 74 | client: &http.Client{ 75 | Timeout: 30 * time.Second, 76 | }, 77 | } 78 | } 79 | 80 | // ProcessURL 处理URL并返回内容摘要 81 | func (p *LLMProcessor) ProcessURL(url string, contentType ContentType) (ContentSummary, error) { 82 | // 根据内容类型选择处理器配置 83 | var processorConfig ProcessorConfig 84 | switch contentType { 85 | case TextType: 86 | processorConfig = p.config.TextProcessor 87 | case ImageType: 88 | processorConfig = p.config.ImageProcessor 89 | case VideoType: 90 | processorConfig = p.config.VideoProcessor 91 | default: 92 | return ContentSummary{}, errors.New("unsupported content type") 93 | } 94 | 95 | // 检查处理器是否启用 96 | if !processorConfig.Enabled { 97 | return ContentSummary{}, fmt.Errorf("%s processor is not enabled", contentType) 98 | } 99 | 100 | // 获取网页内容 101 | content, err := p.fetchURLContent(url) 102 | if err != nil { 103 | return ContentSummary{}, fmt.Errorf("failed to fetch URL content: %w", err) 104 | } 105 | 106 | logs.Debug("Fetching URL content", zap.String("url", url), zap.String("content", content)) 107 | 108 | // 调用LLM API处理内容 109 | summary, err := p.callLLMAPI(content, url, processorConfig) 110 | if err != nil { 111 | return ContentSummary{}, fmt.Errorf("failed to process content with LLM: %w", err) 112 | } 113 | 114 | // 设置内容类型 115 | summary.Type = string(contentType) 116 | 117 | logs.Debug("Processing URL content", zap.String("url", url), zap.Any("summary", summary)) 118 | 119 | return summary, nil 120 | } 121 | 122 | // fetchURLContent 获取URL内容 123 | func (p *LLMProcessor) fetchURLContent(url string) (string, error) { 124 | // 对于文本格式的网页,先通过 r.jina.ai 转换成 Markdown 格式 125 | if strings.HasPrefix(url, "http") && !p.isImageURL(url) { 126 | jinaURL := fmt.Sprintf("https://r.jina.ai/%s", url) 127 | resp, err := p.client.Get(jinaURL) 128 | if err != nil { 129 | return "", err 130 | } 131 | defer resp.Body.Close() 132 | 133 | if resp.StatusCode == http.StatusOK { 134 | body, err := io.ReadAll(resp.Body) 135 | if err != nil { 136 | return "", err 137 | } 138 | return string(body), nil 139 | } 140 | // 如果 jina 转换失败,回退到直接获取 141 | logs.Warn("Failed to convert via jina.ai, falling back to direct fetch", zap.String("url", url)) 142 | } 143 | 144 | // 对于图片URL,转换成base64 145 | if p.isImageURL(url) { 146 | req, err := http.NewRequest("GET", url, nil) 147 | if err != nil { 148 | return "", err 149 | } 150 | 151 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36") 152 | req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8") 153 | req.Header.Set("Accept-Language", "en-US,en;q=0.9") 154 | req.Header.Set("Accept-Encoding", "gzip, deflate, br") 155 | req.Header.Set("Connection", "keep-alive") 156 | req.Header.Set("Cache-Control", "no-cache") 157 | req.Header.Set("Pragma", "no-cache") 158 | req.Header.Set("Sec-Fetch-Site", "cross-site") 159 | req.Header.Set("Sec-Fetch-Mode", "no-cors") 160 | req.Header.Set("Sec-Fetch-Dest", "image") 161 | // req.Header.Set("Referer", "https://www.google.com/") 162 | 163 | // 发送请求 164 | resp, err := p.client.Do(req) 165 | if err != nil { 166 | return "", err 167 | } 168 | defer resp.Body.Close() 169 | 170 | if resp.StatusCode != http.StatusOK { 171 | data, _ := io.ReadAll(resp.Body) 172 | logs.Info("Fetching image content failed", zap.String("url", url), zap.String("body", string(data))) 173 | return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) 174 | } 175 | 176 | // 读取图片内容 177 | imageData, err := io.ReadAll(resp.Body) 178 | if err != nil { 179 | return "", err 180 | } 181 | 182 | // 这里返回特殊格式,表示这是一个图片内容 183 | return fmt.Sprintf("__IMAGE_BASE64__:%s", p.encodeToBase64(imageData, resp.Header.Get("Content-Type"))), nil 184 | } 185 | 186 | // 默认处理方式 187 | resp, err := p.client.Get(url) 188 | if err != nil { 189 | return "", err 190 | } 191 | defer resp.Body.Close() 192 | 193 | if resp.StatusCode != http.StatusOK { 194 | return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) 195 | } 196 | 197 | body, err := io.ReadAll(resp.Body) 198 | if err != nil { 199 | return "", err 200 | } 201 | 202 | return string(body), nil 203 | } 204 | 205 | // isImageURL 判断URL是否为图片 206 | func (p *LLMProcessor) isImageURL(url string) bool { 207 | lowerURL := strings.ToLower(url) 208 | return strings.HasSuffix(lowerURL, ".jpg") || 209 | strings.HasSuffix(lowerURL, ".jpeg") || 210 | strings.HasSuffix(lowerURL, ".png") || 211 | strings.HasSuffix(lowerURL, ".gif") || 212 | strings.HasSuffix(lowerURL, ".webp") 213 | } 214 | 215 | // encodeToBase64 将图片数据编码为base64 216 | func (p *LLMProcessor) encodeToBase64(data []byte, contentType string) string { 217 | if contentType == "" { 218 | contentType = "image/png" // 默认类型 219 | } 220 | return fmt.Sprintf("data:%s;base64,%s", contentType, base64.StdEncoding.EncodeToString(data)) 221 | } 222 | 223 | // OpenAIRequest OpenAI API请求 224 | type OpenAIRequest struct { 225 | Model string `json:"model"` 226 | Messages []OpenAIMessage `json:"messages"` 227 | Functions []OpenAIFunction `json:"functions,omitempty"` 228 | FunctionCall string `json:"function_call,omitempty"` 229 | MaxTokens int `json:"max_tokens,omitempty"` 230 | Temperature float64 `json:"temperature,omitempty"` 231 | } 232 | 233 | // OpenAIMessage OpenAI消息 234 | type OpenAIMessage struct { 235 | Role string `json:"role"` 236 | Content string `json:"content"` 237 | } 238 | 239 | // OpenAIFunction OpenAI函数 240 | type OpenAIFunction struct { 241 | Name string `json:"name"` 242 | Description string `json:"description"` 243 | Parameters map[string]interface{} `json:"parameters"` 244 | } 245 | 246 | // OpenAIResponse OpenAI API响应 247 | type OpenAIResponse struct { 248 | ID string `json:"id"` 249 | Object string `json:"object"` 250 | Created int64 `json:"created"` 251 | Model string `json:"model"` 252 | Choices []struct { 253 | Message struct { 254 | Role string `json:"role"` 255 | Content string `json:"content"` 256 | FunctionCall struct { 257 | Name string `json:"name"` 258 | Arguments string `json:"arguments"` 259 | } `json:"function_call,omitempty"` 260 | } `json:"message"` 261 | FinishReason string `json:"finish_reason"` 262 | } `json:"choices"` 263 | } 264 | 265 | // callLLMAPI 调用LLM API 266 | func (p *LLMProcessor) callLLMAPI(content, url string, config ProcessorConfig) (ContentSummary, error) { 267 | // 检查内容是否为图片 268 | if strings.HasPrefix(content, "__IMAGE_BASE64__:") { 269 | // 处理图片内容 270 | imageBase64 := strings.TrimPrefix(content, "__IMAGE_BASE64__:") 271 | 272 | // 第一步:让LLM详细解释图片内容 273 | imageDescription, err := p.getImageDescription(imageBase64, url, config) 274 | if err != nil { 275 | return ContentSummary{}, fmt.Errorf("failed to get image description: %w", err) 276 | } 277 | 278 | logs.Debug("Image description", zap.String("description", imageDescription)) 279 | 280 | // 第二步:将图片描述作为文本再次请求LLM,获取结构化摘要 281 | return p.getContentSummaryFromText(imageDescription, url, config, false) 282 | } else { 283 | // 直接处理文本内容 284 | return p.getContentSummaryFromText(content, url, config, false) 285 | } 286 | } 287 | 288 | // getImageDescription 获取图片的详细描述 289 | func (p *LLMProcessor) getImageDescription(imageBase64, url string, config ProcessorConfig) (string, error) { 290 | // 构建系统提示 291 | systemPrompt := "你是一个专业的图片分析专家。请详细描述这张图片的内容,包括主要对象、场景、文字、颜色、风格等方面。尽可能全面和具体地描述,不要遗漏任何可见的细节。" 292 | 293 | // 构建包含图片的请求 294 | imageMessage := map[string]interface{}{ 295 | "role": "user", 296 | "content": []map[string]interface{}{ 297 | { 298 | "type": "image_url", 299 | "image_url": map[string]interface{}{ 300 | "url": imageBase64, 301 | }, 302 | }, 303 | { 304 | "type": "text", 305 | "text": "请详细描述这张图片的内容。这张图片来自URL: " + url, 306 | }, 307 | }, 308 | } 309 | 310 | // 构建请求体 311 | requestMap := map[string]interface{}{ 312 | "model": config.Model, 313 | "messages": []interface{}{ 314 | map[string]interface{}{ 315 | "role": "system", 316 | "content": systemPrompt, 317 | }, 318 | imageMessage, 319 | }, 320 | "max_tokens": 1000, 321 | "temperature": 0.5, 322 | } 323 | 324 | requestBody, err := json.Marshal(requestMap) 325 | if err != nil { 326 | return "", err 327 | } 328 | 329 | // 创建HTTP请求 330 | req, err := http.NewRequest("POST", config.APIEndpoint, bytes.NewBuffer(requestBody)) 331 | if err != nil { 332 | return "", err 333 | } 334 | 335 | // 设置请求头 336 | req.Header.Set("Content-Type", "application/json") 337 | req.Header.Set("Authorization", "Bearer "+config.APIKey) 338 | 339 | logs.Debug("Sending image description request to OpenAI API", zap.String("request", string(requestBody))) 340 | 341 | // 发送请求 342 | resp, err := p.client.Do(req) 343 | if err != nil { 344 | return "", err 345 | } 346 | defer resp.Body.Close() 347 | 348 | // 检查响应状态码 349 | if resp.StatusCode != http.StatusOK { 350 | bodyBytes, _ := io.ReadAll(resp.Body) 351 | return "", fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) 352 | } 353 | 354 | // 读取响应体内容 355 | bodyBytes, err := io.ReadAll(resp.Body) 356 | if err != nil { 357 | return "", fmt.Errorf("failed to read response body: %w", err) 358 | } 359 | 360 | // 记录响应内容到日志 361 | logs.Debug("API response body for image description", zap.String("body", string(bodyBytes))) 362 | 363 | // 解析响应 364 | var apiResponse struct { 365 | Choices []struct { 366 | Message struct { 367 | Content string `json:"content"` 368 | } `json:"message"` 369 | } `json:"choices"` 370 | } 371 | 372 | if err := json.Unmarshal(bodyBytes, &apiResponse); err != nil { 373 | return "", err 374 | } 375 | 376 | // 检查是否有响应 377 | if len(apiResponse.Choices) == 0 { 378 | return "", errors.New("no response from LLM API for image description") 379 | } 380 | 381 | return apiResponse.Choices[0].Message.Content, nil 382 | } 383 | 384 | // getContentSummaryFromText 从文本内容获取结构化摘要 385 | func (p *LLMProcessor) getContentSummaryFromText(content, url string, config ProcessorConfig, isSummaryRequest bool) (ContentSummary, error) { 386 | var messages []OpenAIMessage 387 | 388 | // 系统提示 389 | messages = append(messages, OpenAIMessage{ 390 | Role: "system", 391 | Content: config.Prompt, 392 | }) 393 | 394 | // 用户消息 395 | messages = append(messages, OpenAIMessage{ 396 | Role: "user", 397 | Content: fmt.Sprintf("URL: %s\n\nContent: %s", url, content), 398 | }) 399 | 400 | enable_function_call := "auto" 401 | if isSummaryRequest { 402 | enable_function_call = "none" 403 | } 404 | 405 | // 构建OpenAI API请求 406 | request := OpenAIRequest{ 407 | Model: config.Model, 408 | Messages: messages, 409 | Functions: getFunctionDefinition(), 410 | FunctionCall: enable_function_call, 411 | MaxTokens: config.MaxTokens, 412 | Temperature: config.Temperature, 413 | } 414 | 415 | // 序列化请求 416 | requestBody, err := json.Marshal(request) 417 | if err != nil { 418 | return ContentSummary{}, err 419 | } 420 | 421 | // 创建HTTP请求 422 | req, err := http.NewRequest("POST", config.APIEndpoint, bytes.NewBuffer(requestBody)) 423 | if err != nil { 424 | return ContentSummary{}, err 425 | } 426 | 427 | // 设置请求头 428 | req.Header.Set("Content-Type", "application/json") 429 | req.Header.Set("Authorization", "Bearer "+config.APIKey) 430 | 431 | logs.Debug("Sending content summary request to OpenAI API", zap.String("request", string(requestBody))) 432 | 433 | // 发送请求 434 | resp, err := p.client.Do(req) 435 | if err != nil { 436 | return ContentSummary{}, err 437 | } 438 | defer resp.Body.Close() 439 | 440 | if isSummaryRequest { 441 | return processTextResponse(resp) 442 | } 443 | return processAPIResponse(resp) 444 | } 445 | 446 | // getFunctionDefinition 获取函数定义 447 | func getFunctionDefinition() []OpenAIFunction { 448 | return []OpenAIFunction{ 449 | { 450 | Name: "extract_content_summary", 451 | Description: "提取网页内容的摘要信息,并以简体中文返回结果", 452 | Parameters: map[string]interface{}{ 453 | "type": "object", 454 | "properties": map[string]interface{}{ 455 | "title": map[string]interface{}{ 456 | "type": "string", 457 | "description": "内容的标题(使用简体中文)", 458 | }, 459 | "description": map[string]interface{}{ 460 | "type": "string", 461 | "description": "内容的简洁摘要(使用简体中文)", 462 | }, 463 | "tags": map[string]interface{}{ 464 | "type": "array", 465 | "description": "与内容相关的标签(使用简体中文)", 466 | "items": map[string]interface{}{ 467 | "type": "string", 468 | }, 469 | }, 470 | }, 471 | "required": []string{"title", "description", "tags"}, 472 | }, 473 | }, 474 | } 475 | } 476 | 477 | // processAPIResponse 处理API响应 478 | func processAPIResponse(resp *http.Response) (ContentSummary, error) { 479 | // 检查响应状态码 480 | if resp.StatusCode != http.StatusOK { 481 | bodyBytes, _ := io.ReadAll(resp.Body) 482 | return ContentSummary{}, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) 483 | } 484 | 485 | // 读取响应体内容 486 | bodyBytes, err := io.ReadAll(resp.Body) 487 | if err != nil { 488 | return ContentSummary{}, fmt.Errorf("failed to read response body: %w", err) 489 | } 490 | 491 | // 记录响应内容到日志 492 | logs.Debug("API response body", zap.String("body", string(bodyBytes))) 493 | 494 | // 重新创建一个新的io.ReadCloser供后续代码使用 495 | resp.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) 496 | 497 | // 解析响应 498 | var apiResponse OpenAIResponse 499 | if err := json.NewDecoder(resp.Body).Decode(&apiResponse); err != nil { 500 | return ContentSummary{}, err 501 | } 502 | 503 | // 检查是否有响应 504 | if len(apiResponse.Choices) == 0 { 505 | return ContentSummary{}, errors.New("no response from LLM API") 506 | } 507 | 508 | // 解析函数调用结果 509 | functionCall := apiResponse.Choices[0].Message.FunctionCall 510 | if functionCall.Name != "extract_content_summary" { 511 | return ContentSummary{}, fmt.Errorf("unexpected function call: %s", functionCall.Name) 512 | } 513 | 514 | // 解析函数参数 515 | var summary ContentSummary 516 | if err := json.Unmarshal([]byte(functionCall.Arguments), &summary); err != nil { 517 | return ContentSummary{}, err 518 | } 519 | 520 | return summary, nil 521 | } 522 | 523 | // DetectContentType 检测URL的内容类型 524 | func (p *LLMProcessor) DetectContentType(url string) (ContentType, error) { 525 | // 简单的基于URL后缀的检测 526 | lowerURL := strings.ToLower(url) 527 | 528 | // 检测图片 529 | if strings.HasSuffix(lowerURL, ".jpg") || 530 | strings.HasSuffix(lowerURL, ".jpeg") || 531 | strings.HasSuffix(lowerURL, ".png") || 532 | strings.HasSuffix(lowerURL, ".gif") || 533 | strings.HasSuffix(lowerURL, ".webp") { 534 | return ImageType, nil 535 | } 536 | 537 | // 检测视频 538 | if strings.HasSuffix(lowerURL, ".mp4") || 539 | strings.HasSuffix(lowerURL, ".avi") || 540 | strings.HasSuffix(lowerURL, ".mov") || 541 | strings.HasSuffix(lowerURL, ".webm") || 542 | strings.Contains(lowerURL, "youtube.com/watch") || 543 | strings.Contains(lowerURL, "youtu.be/") { 544 | return VideoType, nil 545 | } 546 | 547 | // 默认为文本 548 | return TextType, nil 549 | } 550 | 551 | // ProcessURLAuto 自动检测内容类型并处理URL 552 | func (p *LLMProcessor) ProcessURLAuto(url string) (ContentSummary, error) { 553 | contentType, err := p.DetectContentType(url) 554 | if err != nil { 555 | return ContentSummary{}, err 556 | } 557 | 558 | logs.Info("Detected content type", zap.String("url", url), zap.String("type", string(contentType))) 559 | return p.ProcessURL(url, contentType) 560 | } 561 | 562 | // GetContentSummaryFromText 从文本内容获取结构化摘要(公开版本) 563 | func (p *LLMProcessor) GetContentSummaryFromText(content, url string, config ProcessorConfig) (ContentSummary, error) { 564 | return p.getContentSummaryFromText(content, url, config, true) 565 | } 566 | 567 | // GetConfig 获取LLM处理器配置 568 | func (p *LLMProcessor) GetConfig() LLMConfig { 569 | return p.config 570 | } 571 | 572 | // processTextResponse 处理文本响应(用于摘要请求) 573 | func processTextResponse(resp *http.Response) (ContentSummary, error) { 574 | // 检查响应状态码 575 | if resp.StatusCode != http.StatusOK { 576 | bodyBytes, _ := io.ReadAll(resp.Body) 577 | return ContentSummary{}, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) 578 | } 579 | 580 | // 读取响应体内容 581 | bodyBytes, err := io.ReadAll(resp.Body) 582 | if err != nil { 583 | return ContentSummary{}, fmt.Errorf("failed to read response body: %w", err) 584 | } 585 | 586 | // 记录响应内容到日志 587 | logs.Debug("API response body for text response", zap.String("body", string(bodyBytes))) 588 | 589 | // 解析响应 590 | var apiResponse struct { 591 | Choices []struct { 592 | Message struct { 593 | Content string `json:"content"` 594 | } `json:"message"` 595 | } `json:"choices"` 596 | } 597 | 598 | if err := json.Unmarshal(bodyBytes, &apiResponse); err != nil { 599 | return ContentSummary{}, err 600 | } 601 | 602 | // 检查是否有响应 603 | if len(apiResponse.Choices) == 0 { 604 | return ContentSummary{}, errors.New("no response from LLM API") 605 | } 606 | 607 | // 创建摘要对象 608 | summary := ContentSummary{ 609 | Type: "summary", 610 | Title: "收藏内容综合摘要", 611 | Description: apiResponse.Choices[0].Message.Content, 612 | Tags: []string{"摘要"}, 613 | } 614 | 615 | return summary, nil 616 | } 617 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | --------------------------------------------------------------------------------