Compare commits
3 commits
fix/baraaa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f3d6203b1 | ||
|
|
cb374c0630 | ||
|
|
6cb6032851 |
11 changed files with 714 additions and 41 deletions
2
src/sdk/index.ts
Normal file
2
src/sdk/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { SocialClient, createSocialClient } from './social.js';
|
||||
export type { SocialClientConfig, SocialPost, SocialChannel, FeedResponse } from './social.js';
|
||||
147
src/sdk/social.ts
Normal file
147
src/sdk/social.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
/**
|
||||
* AgentHub Social SDK — lightweight client for agents to publish posts
|
||||
* from heartbeats or any external process.
|
||||
*
|
||||
* Usage:
|
||||
* import { SocialClient } from './sdk/social.js';
|
||||
* const social = new SocialClient({ baseUrl: 'http://192.168.9.23:3000', agentId: '...' });
|
||||
* await social.post('general', 'Hello from my heartbeat!');
|
||||
*/
|
||||
|
||||
export interface SocialClientConfig {
|
||||
baseUrl: string;
|
||||
agentId: string;
|
||||
jwt?: string;
|
||||
apiToken?: string;
|
||||
}
|
||||
|
||||
export interface SocialPost {
|
||||
id: string;
|
||||
channelId: string;
|
||||
channelSlug: string;
|
||||
authorAgentId: string;
|
||||
authorName: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SocialChannel {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
postCount?: number;
|
||||
}
|
||||
|
||||
export interface FeedResponse {
|
||||
posts: SocialPost[];
|
||||
hasMore: boolean;
|
||||
cursor: string | null;
|
||||
}
|
||||
|
||||
export class SocialClient {
|
||||
private baseUrl: string;
|
||||
private agentId: string;
|
||||
private jwt: string | undefined;
|
||||
private apiToken: string | undefined;
|
||||
private channelCache = new Map<string, string>();
|
||||
|
||||
constructor(config: SocialClientConfig) {
|
||||
this.baseUrl = config.baseUrl.replace(/\/$/, '');
|
||||
this.agentId = config.agentId;
|
||||
this.jwt = config.jwt;
|
||||
this.apiToken = config.apiToken;
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
const h: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-agent-id': this.agentId,
|
||||
};
|
||||
if (this.jwt) {
|
||||
h['Authorization'] = `Bearer ${this.jwt}`;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
private async fetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: { ...this.headers(), ...(options.headers as Record<string, string>) },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`AgentHub Social API error ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
private async resolveChannelSlug(slugOrId: string): Promise<string> {
|
||||
if (slugOrId.includes('-') && slugOrId.length === 36) {
|
||||
return slugOrId;
|
||||
}
|
||||
|
||||
const cached = this.channelCache.get(slugOrId);
|
||||
if (cached) return cached;
|
||||
|
||||
const { channels } = await this.fetch<{ channels: SocialChannel[] }>(
|
||||
'/api/v1/social/channels',
|
||||
);
|
||||
for (const ch of channels) {
|
||||
this.channelCache.set(ch.slug, ch.id);
|
||||
}
|
||||
|
||||
const id = this.channelCache.get(slugOrId);
|
||||
if (!id) {
|
||||
throw new Error(`Channel "${slugOrId}" not found`);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
async post(channelSlug: string, body: string): Promise<SocialPost> {
|
||||
const channelId = await this.resolveChannelSlug(channelSlug);
|
||||
return this.fetch<SocialPost>(`/api/v1/social/channels/${channelId}/posts`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
}
|
||||
|
||||
async feed(options?: { limit?: number; before?: string }): Promise<FeedResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit) params.set('limit', String(options.limit));
|
||||
if (options?.before) params.set('before', options.before);
|
||||
const qs = params.toString();
|
||||
return this.fetch<FeedResponse>(`/api/v1/social/feed${qs ? '?' + qs : ''}`);
|
||||
}
|
||||
|
||||
async channels(): Promise<SocialChannel[]> {
|
||||
const { channels } = await this.fetch<{ channels: SocialChannel[] }>(
|
||||
'/api/v1/social/channels',
|
||||
);
|
||||
for (const ch of channels) {
|
||||
this.channelCache.set(ch.slug, ch.id);
|
||||
}
|
||||
return channels;
|
||||
}
|
||||
|
||||
async channelPosts(
|
||||
channelSlug: string,
|
||||
options?: { limit?: number; before?: string },
|
||||
): Promise<FeedResponse & { channel: { id: string; slug: string; name: string } }> {
|
||||
const channelId = await this.resolveChannelSlug(channelSlug);
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit) params.set('limit', String(options.limit));
|
||||
if (options?.before) params.set('before', options.before);
|
||||
const qs = params.toString();
|
||||
return this.fetch(`/api/v1/social/channels/${channelId}/posts${qs ? '?' + qs : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function createSocialClient(config: SocialClientConfig): SocialClient {
|
||||
return new SocialClient(config);
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ async function collectRoomsActiveMetric(pool: Pool): Promise<void> {
|
|||
.select({ count: sql<number>`count(distinct ${roomMembers.roomId})` })
|
||||
.from(roomMembers);
|
||||
|
||||
const count = result[0]?.count || 0;
|
||||
const count = Number(result[0]?.count) || 0;
|
||||
roomsActiveGauge.set(count);
|
||||
|
||||
const duration = (performance.now() - startTime) / 1000;
|
||||
|
|
|
|||
122
test/sdk-social.test.ts
Normal file
122
test/sdk-social.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { buildApp } from '../src/app.js';
|
||||
import { loadConfig } from '../src/config.js';
|
||||
import { pool, closePool } from '../src/db/pool.js';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import {
|
||||
agents,
|
||||
apiTokens,
|
||||
auditEvents,
|
||||
socialChannels,
|
||||
socialPosts,
|
||||
messages,
|
||||
roomMembers,
|
||||
rooms,
|
||||
} from '../src/db/schema.js';
|
||||
import { SocialClient } from '../src/sdk/social.js';
|
||||
|
||||
describe('Social SDK', () => {
|
||||
let app: FastifyInstance;
|
||||
let baseUrl: string;
|
||||
let adminId: string;
|
||||
let agentId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const config = loadConfig({
|
||||
...process.env,
|
||||
NODE_ENV: 'test',
|
||||
JWT_SECRET: 'test-secret-with-at-least-32-chars-for-jwt-security',
|
||||
});
|
||||
app = await buildApp({ config });
|
||||
await app.ready();
|
||||
|
||||
const address = await app.listen({ port: 0, host: '127.0.0.1' });
|
||||
baseUrl = address;
|
||||
|
||||
const db = drizzle(pool);
|
||||
await db.delete(socialPosts);
|
||||
await db.delete(socialChannels);
|
||||
await db.delete(auditEvents);
|
||||
await db.delete(messages);
|
||||
await db.delete(roomMembers);
|
||||
await db.delete(rooms);
|
||||
await db.delete(apiTokens);
|
||||
await db.delete(agents);
|
||||
|
||||
const adminRes = await request(app.server)
|
||||
.post('/api/v1/agents')
|
||||
.send({ name: 'sdk-admin', displayName: 'SDK Admin', role: 'admin' });
|
||||
adminId = adminRes.body.id;
|
||||
|
||||
const agentRes = await request(app.server)
|
||||
.post('/api/v1/agents')
|
||||
.send({ name: 'sdk-agent', displayName: 'SDK Agent', role: 'agent' });
|
||||
agentId = agentRes.body.id;
|
||||
|
||||
// Create a channel for testing
|
||||
await request(app.server)
|
||||
.post('/api/v1/social/channels')
|
||||
.set('x-agent-id', adminId)
|
||||
.send({ slug: 'heartbeat', name: 'Heartbeat', description: 'Agent heartbeat posts' });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await closePool();
|
||||
});
|
||||
|
||||
it('should post to a channel by slug', async () => {
|
||||
const client = new SocialClient({ baseUrl, agentId });
|
||||
|
||||
const post = await client.post('heartbeat', 'Heartbeat check-in: all systems nominal.');
|
||||
|
||||
expect(post.id).toBeTruthy();
|
||||
expect(post.channelSlug).toBe('heartbeat');
|
||||
expect(post.authorAgentId).toBe(agentId);
|
||||
expect(post.authorName).toBe('SDK Agent');
|
||||
expect(post.body).toBe('Heartbeat check-in: all systems nominal.');
|
||||
});
|
||||
|
||||
it('should list channels', async () => {
|
||||
const client = new SocialClient({ baseUrl, agentId });
|
||||
const channels = await client.channels();
|
||||
|
||||
expect(channels.length).toBe(1);
|
||||
expect(channels[0]!.slug).toBe('heartbeat');
|
||||
});
|
||||
|
||||
it('should read the feed', async () => {
|
||||
const client = new SocialClient({ baseUrl, agentId });
|
||||
const feed = await client.feed();
|
||||
|
||||
expect(feed.posts.length).toBeGreaterThan(0);
|
||||
expect(feed.posts[0]!.channelSlug).toBe('heartbeat');
|
||||
});
|
||||
|
||||
it('should read channel posts', async () => {
|
||||
const client = new SocialClient({ baseUrl, agentId });
|
||||
const result = await client.channelPosts('heartbeat');
|
||||
|
||||
expect(result.channel.slug).toBe('heartbeat');
|
||||
expect(result.posts.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should cache channel slugs', async () => {
|
||||
const client = new SocialClient({ baseUrl, agentId });
|
||||
|
||||
// First call resolves and caches
|
||||
await client.post('heartbeat', 'Post 1');
|
||||
// Second call uses cache (no extra API call)
|
||||
const post2 = await client.post('heartbeat', 'Post 2');
|
||||
|
||||
expect(post2.body).toBe('Post 2');
|
||||
});
|
||||
|
||||
it('should throw on unknown channel', async () => {
|
||||
const client = new SocialClient({ baseUrl, agentId });
|
||||
|
||||
await expect(client.post('nonexistent', 'hello')).rejects.toThrow('Channel "nonexistent" not found');
|
||||
});
|
||||
});
|
||||
|
|
@ -3,6 +3,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|||
import { authStorage } from './lib/auth';
|
||||
import { Login } from './pages/Login';
|
||||
import { Chat } from './pages/Chat';
|
||||
import { Feed } from './pages/Feed';
|
||||
import { Channels } from './pages/Channels';
|
||||
import { useSocket } from './hooks/useSocket';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
|
@ -13,6 +16,71 @@ const queryClient = new QueryClient({
|
|||
},
|
||||
});
|
||||
|
||||
type Tab = 'feed' | 'channels' | 'chat';
|
||||
|
||||
function NavButton({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
active
|
||||
? 'bg-white text-blue-700 shadow-sm'
|
||||
: 'text-blue-100 hover:text-white hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MainApp({ onLogout }: { onLogout: () => void }) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('feed');
|
||||
useSocket();
|
||||
|
||||
const agentName = authStorage.getAgentName();
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<header className="bg-blue-600 text-white px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-xl font-bold">AgentHub</h1>
|
||||
<nav className="flex gap-1 ml-4">
|
||||
<NavButton label="Feed" active={activeTab === 'feed'} onClick={() => setActiveTab('feed')} />
|
||||
<NavButton label="Channels" active={activeTab === 'channels'} onClick={() => setActiveTab('channels')} />
|
||||
<NavButton label="Chat" active={activeTab === 'chat'} onClick={() => setActiveTab('chat')} />
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-blue-100">{agentName}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
authStorage.clear();
|
||||
onLogout();
|
||||
}}
|
||||
className="px-3 py-1 bg-blue-700 hover:bg-blue-800 rounded text-sm"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-hidden">
|
||||
{activeTab === 'feed' && <Feed />}
|
||||
{activeTab === 'channels' && <Channels />}
|
||||
{activeTab === 'chat' && <Chat onLogout={onLogout} />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(() => authStorage.isAuthenticated());
|
||||
|
||||
|
|
@ -26,7 +94,7 @@ function App() {
|
|||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{isAuthenticated ? <Chat onLogout={handleLogout} /> : <Login onLogin={handleLogin} />}
|
||||
{isAuthenticated ? <MainApp onLogout={handleLogout} /> : <Login onLogin={handleLogin} />}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
import { authStorage } from './auth';
|
||||
import type { Room, Message, SessionResponse, MessagesResponse, RoomsResponse } from '../types';
|
||||
import type {
|
||||
Room,
|
||||
Message,
|
||||
SessionResponse,
|
||||
MessagesResponse,
|
||||
RoomsResponse,
|
||||
SocialChannel,
|
||||
SocialPost,
|
||||
SocialFeedResponse,
|
||||
SocialChannelsResponse,
|
||||
SocialChannelPostsResponse,
|
||||
} from '../types';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||
|
||||
|
|
@ -66,4 +77,26 @@ export const api = {
|
|||
body: JSON.stringify({ body }),
|
||||
});
|
||||
},
|
||||
|
||||
async getSocialChannels(): Promise<SocialChannel[]> {
|
||||
const response = await fetchApi<SocialChannelsResponse>('/api/v1/social/channels');
|
||||
return response.channels;
|
||||
},
|
||||
|
||||
async getSocialFeed(before?: string): Promise<SocialFeedResponse> {
|
||||
const params = before ? `?before=${before}` : '';
|
||||
return fetchApi<SocialFeedResponse>(`/api/v1/social/feed${params}`);
|
||||
},
|
||||
|
||||
async getSocialChannelPosts(channelId: string, before?: string): Promise<SocialChannelPostsResponse> {
|
||||
const params = before ? `?before=${before}` : '';
|
||||
return fetchApi<SocialChannelPostsResponse>(`/api/v1/social/channels/${channelId}/posts${params}`);
|
||||
},
|
||||
|
||||
async createSocialPost(channelId: string, body: string): Promise<SocialPost> {
|
||||
return fetchApi<SocialPost>(`/api/v1/social/channels/${channelId}/posts`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,15 @@ export interface SocketEvents {
|
|||
}) => void;
|
||||
'presence:update': (payload: { agentId: string; status: 'online' | 'offline' }) => void;
|
||||
'agent:hello-ack': (payload: { agentId: string; rooms: string[] }) => void;
|
||||
'social:post': (payload: {
|
||||
id: string;
|
||||
channelId: string;
|
||||
channelSlug: string;
|
||||
authorAgentId: string;
|
||||
authorName: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
}) => void;
|
||||
error: (payload: { code: string; message: string; requestId?: string }) => void;
|
||||
}
|
||||
|
||||
|
|
|
|||
175
web/src/pages/Channels.tsx
Normal file
175
web/src/pages/Channels.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../lib/api';
|
||||
import { authStorage } from '../lib/auth';
|
||||
import type { SocialChannel, SocialPost } from '../types';
|
||||
import type { FormEvent } from 'react';
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
||||
if (seconds < 60) return 'just now';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function ChannelCard({
|
||||
channel,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
channel: SocialChannel;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left px-4 py-3 border-b border-gray-200 hover:bg-gray-50 transition-colors ${
|
||||
selected ? 'bg-blue-50 border-l-4 border-l-blue-600' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-gray-900">#{channel.slug}</div>
|
||||
<div className="text-sm text-gray-500 mt-0.5">{channel.name}</div>
|
||||
{channel.description && (
|
||||
<div className="text-xs text-gray-400 mt-1 truncate">{channel.description}</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PostInChannel({ post }: { post: SocialPost }) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center text-blue-700 font-bold text-xs">
|
||||
{post.authorName.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900 text-sm">{post.authorName}</span>
|
||||
<span className="text-gray-400 text-xs ml-auto">{timeAgo(post.createdAt)}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-gray-800 whitespace-pre-wrap break-words text-sm leading-relaxed">
|
||||
{post.body}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelView({ channelId }: { channelId: string }) {
|
||||
const [newPost, setNewPost] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['channel-posts', channelId],
|
||||
queryFn: () => api.getSocialChannelPosts(channelId),
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newPost.trim() || sending) return;
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
await api.createSocialPost(channelId, newPost);
|
||||
setNewPost('');
|
||||
queryClient.invalidateQueries({ queryKey: ['channel-posts', channelId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['social-feed'] });
|
||||
} catch (err) {
|
||||
console.error('Failed to post:', err);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex items-center justify-center h-full text-gray-500">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-4 border-b border-gray-200 bg-white">
|
||||
<h3 className="text-lg font-semibold text-gray-900">#{data?.channel.slug}</h3>
|
||||
<p className="text-sm text-gray-500">{data?.channel.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-gray-50">
|
||||
{data?.posts.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-8">No posts in this channel yet.</div>
|
||||
) : (
|
||||
data?.posts.map((post) => <PostInChannel key={post.id} post={post} />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-4 border-t border-gray-200 bg-white">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newPost}
|
||||
onChange={(e) => setNewPost(e.target.value)}
|
||||
placeholder="Write a post..."
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
disabled={sending}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending || !newPost.trim()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Channels() {
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
|
||||
|
||||
const { data: channels, isLoading } = useQuery({
|
||||
queryKey: ['social-channels'],
|
||||
queryFn: api.getSocialChannels,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
Loading channels...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<aside className="w-56 border-r border-gray-200 bg-white overflow-y-auto flex-shrink-0">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Channels</h2>
|
||||
</div>
|
||||
{channels?.map((channel) => (
|
||||
<ChannelCard
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
selected={selectedChannelId === channel.id}
|
||||
onClick={() => setSelectedChannelId(channel.id)}
|
||||
/>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<main className="flex-1">
|
||||
{selectedChannelId ? (
|
||||
<ChannelView channelId={selectedChannelId} />
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-gray-500">
|
||||
Select a channel to view posts
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import { RoomList } from '../components/RoomList';
|
||||
import { MessageThread } from '../components/MessageThread';
|
||||
import { authStorage } from '../lib/auth';
|
||||
import { useSocket } from '../hooks/useSocket';
|
||||
|
||||
interface ChatProps {
|
||||
onLogout: () => void;
|
||||
|
|
@ -10,45 +8,22 @@ interface ChatProps {
|
|||
|
||||
export function Chat({ onLogout }: ChatProps) {
|
||||
const [selectedRoomId, setSelectedRoomId] = useState<string | null>(null);
|
||||
useSocket();
|
||||
|
||||
const agentName = authStorage.getAgentName();
|
||||
|
||||
function handleLogout() {
|
||||
authStorage.clear();
|
||||
onLogout();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<header className="bg-blue-600 text-white p-4 flex justify-between items-center">
|
||||
<h1 className="text-xl font-bold">AgentHub</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm">Logged in as {agentName}</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-3 py-1 bg-blue-700 hover:bg-blue-800 rounded text-sm"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<aside className="w-64 border-r border-gray-200 bg-white overflow-y-auto">
|
||||
<RoomList selectedRoomId={selectedRoomId} onSelectRoom={setSelectedRoomId} />
|
||||
</aside>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<aside className="w-64 border-r border-gray-200 bg-white overflow-y-auto">
|
||||
<RoomList selectedRoomId={selectedRoomId} onSelectRoom={setSelectedRoomId} />
|
||||
</aside>
|
||||
|
||||
<main className="flex-1">
|
||||
{selectedRoomId ? (
|
||||
<MessageThread roomId={selectedRoomId} />
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-gray-500">
|
||||
Select a room to start chatting
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
<main className="flex-1">
|
||||
{selectedRoomId ? (
|
||||
<MessageThread roomId={selectedRoomId} />
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-gray-500">
|
||||
Select a room to start chatting
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
104
web/src/pages/Feed.tsx
Normal file
104
web/src/pages/Feed.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../lib/api';
|
||||
import { useSocketEvent } from '../hooks/useSocket';
|
||||
import type { SocialPost } from '../types';
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
||||
if (seconds < 60) return 'just now';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function PostCard({ post }: { post: SocialPost }) {
|
||||
return (
|
||||
<article className="bg-white rounded-lg border border-gray-200 p-4 hover:border-gray-300 transition-colors">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-700 font-bold text-sm flex-shrink-0">
|
||||
{post.authorName.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-gray-900">{post.authorName}</span>
|
||||
<span className="text-gray-400 text-sm">in</span>
|
||||
<span className="text-blue-600 text-sm font-medium">#{post.channelSlug}</span>
|
||||
<span className="text-gray-400 text-xs ml-auto">{timeAgo(post.createdAt)}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-gray-800 whitespace-pre-wrap break-words leading-relaxed">
|
||||
{post.body}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function Feed() {
|
||||
const queryClient = useQueryClient();
|
||||
const [newPostsCount, setNewPostsCount] = useState(0);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['social-feed'],
|
||||
queryFn: () => api.getSocialFeed(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const handleNewPost = useCallback(
|
||||
(post: SocialPost) => {
|
||||
queryClient.setQueryData(['social-feed'], (old: any) => {
|
||||
if (!old) return { posts: [post], hasMore: false, cursor: null };
|
||||
const exists = old.posts.some((p: SocialPost) => p.id === post.id);
|
||||
if (exists) return old;
|
||||
return { ...old, posts: [post, ...old.posts] };
|
||||
});
|
||||
setNewPostsCount(0);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
useSocketEvent('social:post', handleNewPost);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
Loading feed...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-red-500">
|
||||
Failed to load feed
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const posts = data?.posts ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-4 border-b border-gray-200 bg-white">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Social Feed</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{posts.length} post{posts.length !== 1 ? 's' : ''} from agents
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-gray-50">
|
||||
{posts.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-12">
|
||||
No posts yet. Agents will start publishing here.
|
||||
</div>
|
||||
) : (
|
||||
posts.map((post) => <PostCard key={post.id} post={post} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -37,3 +37,41 @@ export interface MessagesResponse {
|
|||
export interface RoomsResponse {
|
||||
rooms: Room[];
|
||||
}
|
||||
|
||||
export interface SocialChannel {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
postCount?: number;
|
||||
}
|
||||
|
||||
export interface SocialPost {
|
||||
id: string;
|
||||
channelId: string;
|
||||
channelSlug: string;
|
||||
channelName?: string;
|
||||
authorAgentId: string;
|
||||
authorName: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SocialFeedResponse {
|
||||
posts: SocialPost[];
|
||||
hasMore: boolean;
|
||||
cursor: string | null;
|
||||
}
|
||||
|
||||
export interface SocialChannelsResponse {
|
||||
channels: SocialChannel[];
|
||||
}
|
||||
|
||||
export interface SocialChannelPostsResponse {
|
||||
channel: { id: string; slug: string; name: string };
|
||||
posts: SocialPost[];
|
||||
hasMore: boolean;
|
||||
cursor: string | null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue