├── .eslintrc.json ├── .gitignore ├── README.md ├── app ├── api │ └── completion │ │ └── route.ts ├── content.tsx ├── favicon.ico ├── globals.css ├── helpers │ └── index.tsx ├── layout.tsx └── page.tsx ├── next.config.js ├── package.json ├── postcss.config.js ├── public ├── next.svg └── vercel.svg ├── tailwind.config.js └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A widget that summarizes the unread messages in a chat when you open it. 2 | 3 | Take a look at https://github.com/beeper/sdk-docs for installation instructions. 4 | -------------------------------------------------------------------------------- /app/api/completion/route.ts: -------------------------------------------------------------------------------- 1 | import { AnthropicStream, StreamingTextResponse } from 'ai' 2 | 3 | // IMPORTANT! Set the runtime to edge 4 | export const runtime = 'edge' 5 | 6 | export async function POST(req: Request) { 7 | // Extract the `prompt` from the body of the request 8 | const { prompt } = await req.json() 9 | 10 | 11 | const apiKey = process.env.ANTHROPIC_API_KEY; 12 | if (!apiKey) { 13 | throw new Error('ANTHROPIC_API_KEY is not defined'); 14 | } 15 | 16 | const headers: HeadersInit = { 17 | 'Content-Type': 'application/json', 18 | 'x-api-key': apiKey 19 | } 20 | 21 | const response = await fetch('https://api.anthropic.com/v1/complete', { 22 | method: 'POST', 23 | headers: headers, 24 | body: JSON.stringify({ 25 | prompt: `Human: ${prompt}\n\nAssistant:`, 26 | model: 'claude-v1', 27 | max_tokens_to_sample: 3000, 28 | temperature: 0.9, 29 | stream: true 30 | }) 31 | }) 32 | 33 | // Convert the response into a friendly text-stream 34 | const stream = AnthropicStream(response) 35 | 36 | // Respond with the stream 37 | return new StreamingTextResponse(stream) 38 | } -------------------------------------------------------------------------------- /app/content.tsx: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from "react"; 2 | import {useCompletion} from "ai/react"; 3 | import {useWidgetApi} from "@beeper/matrix-widget-toolkit-react"; 4 | import {RoomAccountData, RoomEvent} from "@beeper/matrix-widget-toolkit-api"; 5 | import {getDisplayNameData, Message, processMessages, RoomMessageEvent} from "@/app/helpers"; 6 | 7 | function generatePrompt(messages: Message[], displayNameData: Record) { 8 | const start_prompt = "Here is a transcript of a chat:" 9 | 10 | const end_prompt = "Give a bullet-point summary that is detailed, thorough, and that accurately captures the conversation. Include names only to tell me who's backing up a claim or assertion. After reading your summary, my understanding of what happened should be as good as if I had read the messages myself. Give me details and specifics. Use active voice throughout. Only include links that would be genuinely useful for me to have. Write only the summary, without including text like \"Here\'s the summary\" or \"I hope this helped\"" 11 | let messages_prompt = ""; 12 | 13 | messages.forEach((message) => { 14 | let username = displayNameData[message.user] || message.user; 15 | messages_prompt += `\n${username}: ${message.content}`; 16 | }) 17 | 18 | return `${start_prompt}\n\n${messages_prompt}\n\n${end_prompt}`; 19 | } 20 | 21 | export default function Content() { 22 | const [noMessages, setNoMessages] = useState(false); 23 | const [loading, setLoading] = useState(true); 24 | const [summarizing, setSummarizing] = useState(false); 25 | const [count, setCount] = useState(250); 26 | const [messageCount, setMessageCount] = useState(0); 27 | const [error, setError] = useState(""); 28 | 29 | const { complete, completion } = useCompletion({ 30 | api: '/api/completion', 31 | onResponse: () => { 32 | setLoading(false); 33 | setSummarizing(true); 34 | }, 35 | onError: (error) => { 36 | setError(error.message); 37 | } 38 | }) 39 | const widgetApi = useWidgetApi(); 40 | 41 | async function fetchData(useUnread: boolean, limit: number = 500) { 42 | 43 | let roomEvents: RoomEvent[]; 44 | 45 | if (useUnread) { 46 | const fullyReadData: RoomAccountData[] = await widgetApi.receiveRoomAccountData('m.fully_read'); 47 | const fullyRead: string | undefined = fullyReadData[0].content.event_id; 48 | roomEvents = await widgetApi.receiveRoomEvents('m.room.message', {limit: limit, since: fullyRead}); 49 | } else { 50 | roomEvents = await widgetApi.receiveRoomEvents('m.room.message', {limit: limit}); 51 | } 52 | 53 | const messages = processMessages(roomEvents); 54 | const displayNameData = await getDisplayNameData(widgetApi); 55 | return {messages, displayNameData} 56 | } 57 | 58 | useEffect(() => { 59 | summarize(true); 60 | }, []); 61 | 62 | function summarize(useUnread: boolean, limit?: number) { 63 | setLoading(true); 64 | setNoMessages(false); 65 | 66 | fetchData(useUnread, limit).then(({messages, displayNameData}) => { 67 | if (messages.length === 0) { 68 | setNoMessages(true); 69 | setLoading(false); 70 | } else { 71 | setMessageCount(messages.length); 72 | complete(generatePrompt(messages, displayNameData)); 73 | } 74 | }) 75 | } 76 | 77 | if (error) { 78 | return

{ error }

79 | } 80 | 81 | return ( 82 |
83 | { noMessages && ( 84 | <> 85 |

No new messages.

86 |

Summarize recent messages?

87 | setCount(parseInt(e.target.value))} value={count}/> 88 | 89 | 90 | )} 91 | 92 | { loading && ( 93 | <> 94 |

Computing...

95 | 96 | )} 97 | 98 | { summarizing && ( 99 | <> 100 |

{messageCount} messages:

101 |
{completion}
102 | 103 | )} 104 |
105 | ) 106 | } -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beeper/widget-summarizer/a7de0efc8b58c8cc2611904ade452d3880b06452/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /app/helpers/index.tsx: -------------------------------------------------------------------------------- 1 | import {RoomEvent, WidgetApi} from "@beeper/matrix-widget-toolkit-api"; 2 | 3 | export interface Message { 4 | user: string; 5 | content: string; 6 | } 7 | 8 | export interface RoomMessageEvent { 9 | msgtype: string; 10 | body: string; 11 | } 12 | 13 | export async function getDisplayNameData(widgetApi: WidgetApi) { 14 | const users_response: RoomEvent[] = await widgetApi.receiveStateEvents('m.room.member'); 15 | let user_dict: Record = {} 16 | users_response.map((user) => { 17 | let sender = user.sender; 18 | user_dict[sender] = user.content.displayname 19 | }) 20 | return user_dict 21 | } 22 | 23 | function quoteString(input: string | undefined) { 24 | if (input === undefined) { 25 | return ""; 26 | } 27 | 28 | const lines = input.split('\n'); 29 | const quotedLines = lines.map(line => '> ' + line); 30 | return quotedLines.join('\n'); 31 | } 32 | 33 | export function processMessages(roomEvents: RoomEvent[]) { 34 | let messages_store: Record = {} 35 | 36 | roomEvents.forEach((message) => { 37 | messages_store[message.event_id] = {user: message.sender, content: message.content.body as string} 38 | }) 39 | 40 | const messages: Message[] = roomEvents.reduce((acc: Message[], x): Message[] => { 41 | 42 | // Don't add edited messages 43 | if (x.content.body && typeof x.content.body === 'string' && !x.content.body.startsWith('* ')) { 44 | let content = x.content.body as string 45 | // check if message relates to any others 46 | let relates_to = x.content?.["m.relates_to"]?.["m.in_reply_to"]?.event_id; 47 | if (relates_to && messages_store[relates_to]) { 48 | content = `${quoteString(messages_store[relates_to]["content"])}\n${content}` 49 | } 50 | acc.push({user: x.sender, content: content}) 51 | } 52 | return acc; 53 | }, []); 54 | 55 | return messages 56 | } -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import './globals.css' 4 | import { Inter } from 'next/font/google' 5 | import { WidgetApi, WidgetApiImpl } from '@beeper/matrix-widget-toolkit-api'; 6 | import { MuiThemeProvider, MuiWidgetApiProvider } from "@beeper/matrix-widget-toolkit-mui"; 7 | 8 | const inter = Inter({subsets: ['latin']}) 9 | 10 | const widgetApiPromise = 11 | typeof window !== "undefined" 12 | ? WidgetApiImpl.create({ 13 | capabilities: [], 14 | }) 15 | : new Promise(() => {}) 16 | 17 | export default function RootLayout({ 18 | children, 19 | }: { 20 | children: React.ReactNode 21 | }) { 22 | 23 | if (!widgetApiPromise) return ( 24 | 25 | 26 | 27 | 28 | ); 29 | 30 | return ( 31 | 32 | 33 | 34 | 35 | {children} 36 | 37 | 38 | 39 | 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { EventDirection, WidgetEventCapability } from '@beeper/matrix-widget-api'; 4 | import { MuiCapabilitiesGuard } from "@beeper/matrix-widget-toolkit-mui"; 5 | import Content from "@/app/content"; 6 | 7 | export default function Home() { 8 | 9 | 10 | return ( 11 | <> 12 | 28 | 29 | 30 | 31 | ); 32 | }; -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {} 3 | 4 | module.exports = nextConfig 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "summarizer-widget", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@anthropic-ai/sdk": "^0.4.4", 13 | "@beeper/matrix-widget-api": "^1.5.1", 14 | "@beeper/matrix-widget-toolkit-api": "^3.3.5", 15 | "@beeper/matrix-widget-toolkit-mui": "^1.2.7", 16 | "@beeper/matrix-widget-toolkit-react": "^1.0.11", 17 | "@emotion/react": "^11.11.1", 18 | "@emotion/styled": "^11.11.0", 19 | "@mui/material": "^5.13.5", 20 | "@types/node": "20.3.1", 21 | "@types/react": "18.2.12", 22 | "@types/react-dom": "18.2.5", 23 | "ai": "2.1.7", 24 | "autoprefixer": "10.4.14", 25 | "dotenv": "^16.1.4", 26 | "encoding": "^0.1.13", 27 | "eslint": "8.42.0", 28 | "eslint-config-next": "13.4.5", 29 | "joi": "^17.9.2", 30 | "next": "13.4.5", 31 | "postcss": "8.4.24", 32 | "react": "18.2.0", 33 | "react-dom": "18.2.0", 34 | "react-use": "^17.4.0", 35 | "tailwindcss": "3.3.2", 36 | "typescript": "5.1.3", 37 | "use-debounce": "^9.0.4" 38 | }, 39 | "devDependencies": { 40 | "@types/jest": "^29.5.2" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 5 | './components/**/*.{js,ts,jsx,tsx,mdx}', 6 | './app/**/*.{js,ts,jsx,tsx,mdx}', 7 | ], 8 | theme: { 9 | extend: { 10 | backgroundImage: { 11 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 12 | 'gradient-conic': 13 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | --------------------------------------------------------------------------------