feat(room): redesign mention system with AST-based format
Backend: - Add MENTION_TAG_RE matching new `<mention type="..." id="...">label</mention>` format - Extend extract_mentions() and resolve_mentions() to parse new format (legacy backward-compatible) Frontend: - New src/lib/mention-ast.ts: AST types (TextNode, MentionNode, AiActionNode), parse() and serialize() functions for AST↔HTML conversion - MentionPopover: load @repository: from project repos, @ai: from room_ai configs (not room members); output new HTML format with ID instead of label - MessageMentions: use AST parse() for rendering (falls back to legacy parser) - ChatInputArea: insertMention now produces `<mention type="user" id="...">label</mention>` - RoomParticipantsPanel: onMention passes member UUID to insertMention - RoomContext: add projectRepos and roomAiConfigs for mention data sources
This commit is contained in:
parent
0ea6440ea3
commit
26682973e7
@ -27,6 +27,15 @@ const DEFAULT_MAX_CONCURRENT_WORKERS: usize = 1024;
|
||||
static USER_MENTION_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex> =
|
||||
LazyLock::new(|| regex_lite::Regex::new(r"<user>\s*([^<]+?)\s*</user>").unwrap());
|
||||
|
||||
/// Matches <mention type="..." id="...">label</mention>
|
||||
static MENTION_TAG_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex> =
|
||||
LazyLock::new(|| {
|
||||
regex_lite::Regex::new(
|
||||
r#"<mention\s+type="([^"]+)"\s+id="([^"]+)"[^>]*>\s*([^<]*?)\s*</mention>"#,
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RoomService {
|
||||
pub db: AppDatabase,
|
||||
@ -533,8 +542,12 @@ impl RoomService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extracts user UUIDs from both the legacy `<user>uuid</user>` format
|
||||
/// and the new `<mention type="user" id="uuid">label</mention>` format.
|
||||
pub fn extract_mentions(content: &str) -> Vec<Uuid> {
|
||||
let mut mentioned = Vec::new();
|
||||
|
||||
// Legacy <user>uuid</user> format
|
||||
for cap in USER_MENTION_RE.captures_iter(content) {
|
||||
if let Some(inner) = cap.get(1) {
|
||||
let token = inner.as_str().trim();
|
||||
@ -546,9 +559,25 @@ impl RoomService {
|
||||
}
|
||||
}
|
||||
|
||||
// New <mention type="user" id="...">label</mention> format
|
||||
for cap in MENTION_TAG_RE.captures_iter(content) {
|
||||
if let (Some(type_m), Some(id_m)) = (cap.get(1), cap.get(2)) {
|
||||
if type_m.as_str() == "user" {
|
||||
if let Ok(uuid) = Uuid::parse_str(id_m.as_str().trim()) {
|
||||
if !mentioned.contains(&uuid) {
|
||||
mentioned.push(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mentioned
|
||||
}
|
||||
|
||||
/// Resolves user mentions from both the legacy `<user>...` format and the
|
||||
/// new `<mention type="user" id="uuid">label</mention>` format.
|
||||
/// Repository and AI mention types are accepted but produce no user UUIDs.
|
||||
pub async fn resolve_mentions(&self, content: &str) -> Vec<Uuid> {
|
||||
use models::users::User;
|
||||
use sea_orm::EntityTrait;
|
||||
@ -556,6 +585,7 @@ impl RoomService {
|
||||
let mut resolved: Vec<Uuid> = Vec::new();
|
||||
let mut seen_usernames: Vec<String> = Vec::new();
|
||||
|
||||
// Legacy <user>uuid</user> or <user>username</user> format
|
||||
for cap in USER_MENTION_RE.captures_iter(content) {
|
||||
if let Some(inner) = cap.get(1) {
|
||||
let token = inner.as_str().trim();
|
||||
@ -587,6 +617,46 @@ impl RoomService {
|
||||
}
|
||||
}
|
||||
|
||||
// New <mention type="user" id="uuid">label</mention> format
|
||||
for cap in MENTION_TAG_RE.captures_iter(content) {
|
||||
if let (Some(type_m), Some(id_m)) = (cap.get(1), cap.get(2)) {
|
||||
if type_m.as_str() == "user" {
|
||||
let id = id_m.as_str().trim();
|
||||
if let Ok(uuid) = Uuid::parse_str(id) {
|
||||
if !resolved.contains(&uuid) {
|
||||
resolved.push(uuid);
|
||||
}
|
||||
} else {
|
||||
// Fall back to label-based username lookup
|
||||
if let Some(label_m) = cap.get(3) {
|
||||
let label = label_m.as_str().trim();
|
||||
if !label.is_empty() {
|
||||
let label_lower = label.to_lowercase();
|
||||
if seen_usernames.contains(&label_lower) {
|
||||
continue;
|
||||
}
|
||||
seen_usernames.push(label_lower.clone());
|
||||
|
||||
if let Some(user) = User::find()
|
||||
.filter(models::users::user::Column::Username.eq(label_lower))
|
||||
.one(&self.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
{
|
||||
if !resolved.contains(&user.uid) {
|
||||
resolved.push(user.uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// `repository` and `ai` mention types are accepted but do not
|
||||
// produce user notification UUIDs — no-op here.
|
||||
}
|
||||
}
|
||||
|
||||
resolved
|
||||
}
|
||||
|
||||
|
||||
@ -1,15 +1,25 @@
|
||||
import type { RoomMemberResponse } from '@/client';
|
||||
import type { ProjectRepositoryItem, RoomMemberResponse } from '@/client';
|
||||
import { buildMentionHtml, type MentionMentionType } from '@/lib/mention-ast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Check, CornerDownLeft, Keyboard, SearchX } from 'lucide-react';
|
||||
import { Bot, Check, CornerDownLeft, Keyboard, SearchX } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { SetStateAction } from 'react';
|
||||
import { ModelIcon } from './icon-match';
|
||||
|
||||
/** Room AI config — the configured model for this room */
|
||||
export interface RoomAiConfig {
|
||||
model: string; // model UID / model ID
|
||||
modelName?: string;
|
||||
}
|
||||
|
||||
interface MentionSuggestion {
|
||||
type: 'category' | 'item';
|
||||
category?: 'repository' | 'user' | 'ai';
|
||||
category?: MentionMentionType;
|
||||
label: string;
|
||||
/** Raw value stored in suggestion (for display) */
|
||||
value: string;
|
||||
/** ID used when building HTML mention (absent for category headers) */
|
||||
mentionId?: string;
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
@ -27,10 +37,12 @@ interface PopoverPosition {
|
||||
}
|
||||
|
||||
interface MentionPopoverProps {
|
||||
/** Available members for user/ai mention suggestions */
|
||||
/** Available members for @user: mention suggestions */
|
||||
members: RoomMemberResponse[];
|
||||
/** Repository names available for @repository: mention */
|
||||
repos?: string[];
|
||||
/** Available repositories for @repository: mention suggestions */
|
||||
repos?: ProjectRepositoryItem[];
|
||||
/** Room AI configs for @ai: mention suggestions */
|
||||
aiConfigs?: RoomAiConfig[];
|
||||
inputValue: string;
|
||||
cursorPosition: number;
|
||||
onSelect: (newValue: string, newCursorPosition: number) => void;
|
||||
@ -41,6 +53,7 @@ interface MentionPopoverProps {
|
||||
export function MentionPopover({
|
||||
members,
|
||||
repos = [],
|
||||
aiConfigs = [],
|
||||
inputValue,
|
||||
cursorPosition,
|
||||
onSelect,
|
||||
@ -109,12 +122,13 @@ export function MentionPopover({
|
||||
|
||||
if (category === 'repository') {
|
||||
return repos
|
||||
.filter((repo) => !item || repo.toLowerCase().includes(item))
|
||||
.filter((repo) => !item || repo.repo_name.toLowerCase().includes(item))
|
||||
.map((repo) => ({
|
||||
type: 'item' as const,
|
||||
category: 'repository' as const,
|
||||
label: repo,
|
||||
value: `@repository:${repo}`,
|
||||
label: repo.repo_name,
|
||||
value: `@repository:${repo.repo_name}`,
|
||||
mentionId: repo.uid,
|
||||
avatar: null,
|
||||
}));
|
||||
}
|
||||
@ -132,33 +146,34 @@ export function MentionPopover({
|
||||
type: 'item' as const,
|
||||
category: 'user' as const,
|
||||
label: username,
|
||||
value: `@user:${m.user}`,
|
||||
value: `@user:${username}`,
|
||||
mentionId: m.user,
|
||||
avatar: m.user_info?.avatar_url ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (category === 'ai') {
|
||||
return members
|
||||
.filter((m) => m.role === 'ai')
|
||||
.filter((m) => {
|
||||
const username = m.user_info?.username ?? m.user;
|
||||
return !item || username.toLowerCase().includes(item);
|
||||
return aiConfigs
|
||||
.filter((cfg) => {
|
||||
const name = cfg.modelName ?? cfg.model;
|
||||
return !item || name.toLowerCase().includes(item);
|
||||
})
|
||||
.map((m) => {
|
||||
const username = m.user_info?.username ?? m.user;
|
||||
.map((cfg) => {
|
||||
const label = cfg.modelName ?? cfg.model;
|
||||
return {
|
||||
type: 'item' as const,
|
||||
category: 'ai' as const,
|
||||
label: username,
|
||||
value: `@ai:${m.user}`,
|
||||
avatar: m.user_info?.avatar_url ?? null,
|
||||
label,
|
||||
value: `@ai:${label}`,
|
||||
mentionId: cfg.model,
|
||||
avatar: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [mentionState, members, repos]);
|
||||
}, [mentionState, members, repos, aiConfigs]);
|
||||
|
||||
const visibleSuggestions = useMemo(() => suggestions.slice(0, 8), [suggestions]);
|
||||
const validSelectedIndex = selectedIndex >= visibleSuggestions.length ? 0 : selectedIndex;
|
||||
@ -173,16 +188,27 @@ export function MentionPopover({
|
||||
|
||||
const before = inputValue.slice(0, mentionState.startPos);
|
||||
const after = inputValue.slice(cursorPosition);
|
||||
const spacer = suggestion.type === 'item' ? ' ' : '';
|
||||
const newValue = before + suggestion.value + spacer + after;
|
||||
const newCursorPos = mentionState.startPos + suggestion.value.length + spacer.length;
|
||||
|
||||
onSelect(newValue, newCursorPos);
|
||||
let newValue: string;
|
||||
let newCursorPos: number;
|
||||
|
||||
if (suggestion.type === 'item') {
|
||||
closePopover();
|
||||
} else {
|
||||
if (suggestion.type === 'category') {
|
||||
// User selected a category header (e.g., @user:) — keep the partial
|
||||
newValue = before + suggestion.value + after;
|
||||
newCursorPos = mentionState.startPos + suggestion.value.length;
|
||||
onOpenChange(true);
|
||||
} else {
|
||||
// Build new HTML mention string
|
||||
const html = buildMentionHtml(
|
||||
suggestion.category!,
|
||||
suggestion.mentionId!,
|
||||
suggestion.label,
|
||||
);
|
||||
const spacer = ' ';
|
||||
newValue = before + html + spacer + after;
|
||||
newCursorPos = mentionState.startPos + html.length + spacer.length;
|
||||
onSelect(newValue, newCursorPos);
|
||||
closePopover();
|
||||
}
|
||||
};
|
||||
}, [mentionState, inputValue, cursorPosition, onSelect, closePopover, onOpenChange]);
|
||||
@ -351,7 +377,11 @@ export function MentionPopover({
|
||||
{suggestion.type === 'category' ? (
|
||||
'>'
|
||||
) : suggestion.category === 'ai' ? (
|
||||
<ModelIcon modelId={suggestion.value.replace(/^@ai:/, '')} className="h-4 w-4" />
|
||||
suggestion.avatar ? (
|
||||
<ModelIcon modelId={suggestion.mentionId} className="h-4 w-4" />
|
||||
) : (
|
||||
<Bot className="h-4 w-4" />
|
||||
)
|
||||
) : (
|
||||
suggestion.label[0]?.toUpperCase()
|
||||
)}
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { parse, type Node, type MentionMentionType } from '@/lib/mention-ast';
|
||||
|
||||
type MentionType = 'repository' | 'user' | 'ai' | 'notify';
|
||||
|
||||
interface MentionToken {
|
||||
interface LegacyMentionToken {
|
||||
full: string;
|
||||
type: MentionType;
|
||||
name: string;
|
||||
@ -14,12 +14,17 @@ function isMentionNameChar(ch: string): boolean {
|
||||
return /[A-Za-z0-9._:\/-]/.test(ch);
|
||||
}
|
||||
|
||||
function extractMentionTokens(text: string): MentionToken[] {
|
||||
const tokens: MentionToken[] = [];
|
||||
/**
|
||||
* Parses legacy mention formats for backward compatibility with old messages:
|
||||
* - `<user>name</user>` (old backend format)
|
||||
* - `@type:name` (old colon format)
|
||||
* - `<type>name</type>` (old XML format)
|
||||
*/
|
||||
function extractLegacyMentionTokens(text: string): LegacyMentionToken[] {
|
||||
const tokens: LegacyMentionToken[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < text.length) {
|
||||
// Find next potential mention: <user> or <user: or @user:
|
||||
const angleOpen = text.indexOf('<', cursor);
|
||||
const atOpen = text.indexOf('@', cursor);
|
||||
|
||||
@ -36,13 +41,10 @@ function extractMentionTokens(text: string): MentionToken[] {
|
||||
}
|
||||
|
||||
const typeStart = next + 1;
|
||||
|
||||
// Check for colon format: <type:name> or @type:name
|
||||
const colon = text.indexOf(':', typeStart);
|
||||
const validTypes: MentionType[] = ['repository', 'user', 'ai', 'notify'];
|
||||
|
||||
if (colon >= 0 && colon - typeStart <= 20) {
|
||||
// Colon format: <type:name> or @type:name
|
||||
const typeRaw = text.slice(typeStart, colon).toLowerCase();
|
||||
if (!validTypes.includes(typeRaw as MentionType)) {
|
||||
cursor = next + 1;
|
||||
@ -54,7 +56,6 @@ function extractMentionTokens(text: string): MentionToken[] {
|
||||
end++;
|
||||
}
|
||||
|
||||
// For angle style, require closing >
|
||||
const closeBracket = style === 'angle' && text[end] === '>' ? end + 1 : end;
|
||||
const name = text.slice(colon + 1, style === 'angle' ? end : closeBracket);
|
||||
|
||||
@ -65,10 +66,7 @@ function extractMentionTokens(text: string): MentionToken[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for XML-like format: <type>name</type>
|
||||
// Only for angle style
|
||||
if (style === 'angle') {
|
||||
// Find space or > to determine type end
|
||||
let typeEnd = typeStart;
|
||||
while (typeEnd < text.length && /[A-Za-z]/.test(text[typeEnd])) {
|
||||
typeEnd++;
|
||||
@ -76,11 +74,8 @@ function extractMentionTokens(text: string): MentionToken[] {
|
||||
const typeCandidate = text.slice(typeStart, typeEnd);
|
||||
|
||||
if (validTypes.includes(typeCandidate as MentionType)) {
|
||||
// Found opening tag like <user>
|
||||
const closeTag = `</${typeCandidate}>`;
|
||||
const contentStart = typeEnd;
|
||||
|
||||
// Find the closing tag
|
||||
const tagClose = text.indexOf(closeTag, cursor);
|
||||
if (tagClose >= 0) {
|
||||
const name = text.slice(contentStart, tagClose);
|
||||
@ -99,7 +94,7 @@ function extractMentionTokens(text: string): MentionToken[] {
|
||||
}
|
||||
|
||||
function extractFirstMentionName(text: string, type: MentionType): string | null {
|
||||
const token = extractMentionTokens(text).find((item) => item.type === type);
|
||||
const token = extractLegacyMentionTokens(text).find((item) => item.type === type);
|
||||
return token?.name ?? null;
|
||||
}
|
||||
|
||||
@ -107,38 +102,73 @@ interface MessageContentWithMentionsProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
const mentionStyles: Record<MentionType, string> = {
|
||||
const mentionStyles: Record<string, string> = {
|
||||
user: 'inline-flex items-center rounded bg-blue-100/80 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 font-medium cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/60 transition-colors text-sm leading-5',
|
||||
repository: 'inline-flex items-center rounded bg-purple-100/80 px-1.5 py-0.5 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300 font-medium cursor-pointer hover:bg-purple-200 dark:hover:bg-purple-900/60 transition-colors text-sm leading-5',
|
||||
ai: 'inline-flex items-center rounded bg-green-100/80 px-1.5 py-0.5 text-green-700 dark:bg-green-900/40 dark:text-green-300 font-medium cursor-pointer hover:bg-green-200 dark:hover:bg-green-900/60 transition-colors text-sm leading-5',
|
||||
notify: 'inline-flex items-center rounded bg-yellow-100/80 px-1.5 py-0.5 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 font-medium cursor-pointer hover:bg-yellow-200 dark:hover:bg-yellow-900/60 transition-colors text-sm leading-5',
|
||||
};
|
||||
|
||||
/** Renders message content with @mention highlighting using styled spans */
|
||||
function renderNode(node: Node, index: number): React.ReactNode {
|
||||
if (node.type === 'text') {
|
||||
return <span key={index}>{node.text}</span>;
|
||||
}
|
||||
if (node.type === 'mention') {
|
||||
return (
|
||||
<span key={index} className={mentionStyles[node.mentionType] ?? mentionStyles.user}>
|
||||
@{node.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (node.type === 'ai_action') {
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center rounded bg-green-100/80 px-1.5 py-0.5 text-green-700 dark:bg-green-900/40 dark:text-green-300 font-mono text-xs"
|
||||
>
|
||||
/{node.action}
|
||||
{node.args ? ` ${node.args}` : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Renders message content with @mention highlighting using styled spans.
|
||||
* Supports the new `<mention type="..." id="...">label</mention>` format
|
||||
* and legacy formats for backward compatibility with old messages.
|
||||
*/
|
||||
export const MessageContentWithMentions = memo(function MessageContentWithMentions({
|
||||
content,
|
||||
}: MessageContentWithMentionsProps) {
|
||||
const processed = useMemo(() => {
|
||||
const tokens = extractMentionTokens(content);
|
||||
if (tokens.length === 0) return [{ type: 'text' as const, content }];
|
||||
const nodes = useMemo(() => {
|
||||
// Try the new AST parser first (handles <mention> and <ai> tags)
|
||||
const ast = parse(content);
|
||||
if (ast.length > 0) return ast;
|
||||
|
||||
const parts: Array<{ type: 'text'; content: string } | { type: 'mention'; mention: MentionToken }> = [];
|
||||
// Fall back to legacy parser for old-format messages
|
||||
const legacy = extractLegacyMentionTokens(content);
|
||||
if (legacy.length === 0) return [{ type: 'text' as const, text: content }];
|
||||
|
||||
const parts: Node[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
for (const token of tokens) {
|
||||
for (const token of legacy) {
|
||||
const idx = content.indexOf(token.full, cursor);
|
||||
if (idx === -1) continue;
|
||||
if (idx > cursor) {
|
||||
parts.push({ type: 'text', content: content.slice(cursor, idx) });
|
||||
parts.push({ type: 'text', text: content.slice(cursor, idx) });
|
||||
}
|
||||
parts.push({ type: 'mention', mention: token });
|
||||
parts.push({
|
||||
type: 'mention',
|
||||
mentionType: token.type as MentionMentionType,
|
||||
id: '',
|
||||
label: token.name,
|
||||
});
|
||||
cursor = idx + token.full.length;
|
||||
}
|
||||
|
||||
if (cursor < content.length) {
|
||||
parts.push({ type: 'text', content: content.slice(cursor) });
|
||||
parts.push({ type: 'text', text: content.slice(cursor) });
|
||||
}
|
||||
|
||||
return parts;
|
||||
}, [content]);
|
||||
|
||||
@ -151,18 +181,7 @@ export const MessageContentWithMentions = memo(function MessageContentWithMentio
|
||||
'[&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:overflow-x-auto',
|
||||
)}
|
||||
>
|
||||
{processed.map((part, i) =>
|
||||
part.type === 'mention' ? (
|
||||
<span
|
||||
key={i}
|
||||
className={mentionStyles[part.mention.type]}
|
||||
>
|
||||
@{part.mention.name}
|
||||
</span>
|
||||
) : (
|
||||
<span key={i}>{part.content}</span>
|
||||
),
|
||||
)}
|
||||
{nodes.map((node, i) => renderNode(node, i))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { RoomResponse, RoomMemberResponse, RoomMessageResponse, RoomThreadResponse } from '@/client';
|
||||
import type { ProjectRepositoryItem, RoomResponse, RoomMemberResponse, RoomMessageResponse, RoomThreadResponse } from '@/client';
|
||||
import { useRoom, type MessageWithMeta } from '@/contexts';
|
||||
import { type RoomAiConfig } from '@/contexts/room-context';
|
||||
import { useRoomDraft } from '@/hooks/useRoomDraft';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@ -30,7 +31,7 @@ const MENTION_PATTERN = /@([^:@\s]*)(:([^\s]*))?$/;
|
||||
const MENTION_POPOVER_KEYS = ['Enter', 'Tab', 'ArrowUp', 'ArrowDown'];
|
||||
|
||||
export interface ChatInputAreaHandle {
|
||||
insertMention: (name: string, type: 'user' | 'ai') => void;
|
||||
insertMention: (id: string, label: string, type: 'user' | 'ai') => void;
|
||||
}
|
||||
|
||||
interface ChatInputAreaProps {
|
||||
@ -38,6 +39,8 @@ interface ChatInputAreaProps {
|
||||
onSend: (content: string) => void;
|
||||
isSending: boolean;
|
||||
members: RoomMemberResponse[];
|
||||
repos?: ProjectRepositoryItem[];
|
||||
aiConfigs?: RoomAiConfig[];
|
||||
replyingTo?: { id: string; display_name?: string; content: string } | null;
|
||||
onCancelReply?: () => void;
|
||||
draft: string;
|
||||
@ -51,6 +54,8 @@ const ChatInputArea = memo(function ChatInputArea({
|
||||
onSend,
|
||||
isSending,
|
||||
members,
|
||||
repos,
|
||||
aiConfigs,
|
||||
replyingTo,
|
||||
onCancelReply,
|
||||
draft,
|
||||
@ -75,11 +80,14 @@ const ChatInputArea = memo(function ChatInputArea({
|
||||
}, [onDraftChange]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
insertMention: (name: string) => {
|
||||
insertMention: (id: string, label: string) => {
|
||||
if (!textareaRef.current) return;
|
||||
const value = textareaRef.current.value;
|
||||
const cursorPos = textareaRef.current.selectionStart;
|
||||
const mentionText = `<user>${name}</user> `;
|
||||
// Build new HTML mention: <mention type="user" id="uuid">label</mention>
|
||||
const escapedLabel = label.replace(/</g, '<').replace(/>/g, '>');
|
||||
const escapedId = id.replace(/"/g, '"');
|
||||
const mentionText = `<mention type="user" id="${escapedId}">${escapedLabel}</mention> `;
|
||||
const before = value.substring(0, cursorPos);
|
||||
const after = value.substring(cursorPos);
|
||||
const newValue = before + mentionText + after;
|
||||
@ -169,6 +177,8 @@ const ChatInputArea = memo(function ChatInputArea({
|
||||
{showMentionPopover && (
|
||||
<MentionPopover
|
||||
members={members}
|
||||
repos={repos}
|
||||
aiConfigs={aiConfigs}
|
||||
inputValue={draft}
|
||||
cursorPosition={cursorPosition}
|
||||
onSelect={handleMentionSelect}
|
||||
@ -244,6 +254,8 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
|
||||
wsClient,
|
||||
threads,
|
||||
refreshThreads,
|
||||
projectRepos,
|
||||
roomAiConfigs,
|
||||
} = useRoom();
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
@ -322,8 +334,8 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
|
||||
);
|
||||
|
||||
// Stable: chatInputRef is stable, no deps that change on message updates
|
||||
const handleMention = useCallback((name: string, type: 'user' | 'ai') => {
|
||||
chatInputRef.current?.insertMention(name, type);
|
||||
const handleMention = useCallback((id: string, label: string) => {
|
||||
chatInputRef.current?.insertMention(id, label, 'user');
|
||||
}, []);
|
||||
|
||||
const handleSelectSearchResult = useCallback((message: RoomMessageResponse) => {
|
||||
@ -530,6 +542,8 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
|
||||
onSend={handleSend}
|
||||
isSending={false}
|
||||
members={members}
|
||||
repos={projectRepos}
|
||||
aiConfigs={roomAiConfigs}
|
||||
replyingTo={replyingTo ? { id: replyingTo.id, display_name: replyingTo.display_name ?? undefined, content: replyingTo.content } : null}
|
||||
onCancelReply={() => setReplyingTo(null)}
|
||||
draft={draft}
|
||||
|
||||
@ -9,7 +9,7 @@ import type { ReactNode } from 'react';
|
||||
interface RoomParticipantsPanelProps {
|
||||
members: RoomMemberResponse[];
|
||||
membersLoading: boolean;
|
||||
onMention?: (name: string, type: 'user' | 'ai') => void;
|
||||
onMention?: (id: string, label: string, type: 'user' | 'ai') => void;
|
||||
}
|
||||
|
||||
export const RoomParticipantsPanel = memo(function RoomParticipantsPanel({
|
||||
@ -97,7 +97,7 @@ function ParticipantRow({
|
||||
onMention,
|
||||
}: {
|
||||
member: RoomMemberResponse;
|
||||
onMention?: (name: string, type: 'user' | 'ai') => void;
|
||||
onMention?: (id: string, label: string, type: 'user' | 'ai') => void;
|
||||
}) {
|
||||
const username = member.user_info?.username ?? member.user;
|
||||
const avatarUrl = member.user_info?.avatar_url;
|
||||
@ -110,7 +110,7 @@ function ParticipantRow({
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/60',
|
||||
)}
|
||||
onClick={() => onMention?.(username, 'user')}
|
||||
onClick={() => onMention?.(member.user, username, 'user')}
|
||||
disabled={!onMention}
|
||||
>
|
||||
<Avatar className="h-7 w-7">
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { memo, useState, useEffect, useCallback } from 'react';
|
||||
import type { ModelResponse, RoomResponse } from '@/client';
|
||||
import type { ModelResponse, RoomResponse, RoomAiResponse, RoomAiUpsertRequest } from '@/client';
|
||||
import { aiList, aiUpsert, aiDelete, modelList } from '@/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@ -15,7 +15,6 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Loader2, Plus, Trash2, Bot, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { ModelResponse, RoomAiResponse, RoomAiUpsertRequest } from '@/client';
|
||||
|
||||
interface RoomSettingsPanelProps {
|
||||
room: RoomResponse;
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
type ProjectRepositoryItem,
|
||||
type RoomCategoryResponse,
|
||||
type RoomMemberResponse,
|
||||
type RoomMessageResponse,
|
||||
@ -39,6 +40,11 @@ import {
|
||||
|
||||
export type { RoomWsStatus, RoomWsClient } from '@/lib/room-ws-client';
|
||||
|
||||
export interface RoomAiConfig {
|
||||
model: string;
|
||||
modelName?: string;
|
||||
}
|
||||
|
||||
export interface ReactionGroup {
|
||||
emoji: string;
|
||||
count: number;
|
||||
@ -146,6 +152,11 @@ interface RoomContextValue {
|
||||
updateRoom: (roomId: string, name?: string, isPublic?: boolean, categoryId?: string) => Promise<void>;
|
||||
deleteRoom: (roomId: string) => Promise<void>;
|
||||
streamingMessages: Map<string, string>;
|
||||
|
||||
/** Project repositories for @repository: mention suggestions */
|
||||
projectRepos: ProjectRepositoryItem[];
|
||||
/** Room AI configs for @ai: mention suggestions */
|
||||
roomAiConfigs: RoomAiConfig[];
|
||||
}
|
||||
|
||||
const RoomContext = createContext<RoomContextValue | null>(null);
|
||||
@ -415,6 +426,13 @@ export function RoomProvider({
|
||||
|
||||
const [streamingContent, setStreamingContent] = useState<Map<string, string>>(new Map());
|
||||
|
||||
// Project repos for @repository: mention suggestions
|
||||
const [projectRepos, setProjectRepos] = useState<ProjectRepositoryItem[]>([]);
|
||||
// Room AI configs for @ai: mention suggestions
|
||||
const [roomAiConfigs, setRoomAiConfigs] = useState<RoomAiConfig[]>([]);
|
||||
// Available models (for looking up AI model names)
|
||||
const [availableModels, setAvailableModels] = useState<{ id: string; name: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin;
|
||||
const client = createRoomWsClient(
|
||||
@ -1095,6 +1113,67 @@ export function RoomProvider({
|
||||
[activeRoomId],
|
||||
);
|
||||
|
||||
// Fetch project repos for @repository: mention suggestions
|
||||
const fetchProjectRepos = useCallback(async () => {
|
||||
if (!projectName) {
|
||||
setProjectRepos([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin;
|
||||
const resp = await fetch(`${baseUrl}/api/projects/${encodeURIComponent(projectName)}/repos`);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const json: { data?: { items?: ProjectRepositoryItem[] } } = await resp.json();
|
||||
setProjectRepos(json.data?.items ?? []);
|
||||
} catch {
|
||||
setProjectRepos([]);
|
||||
}
|
||||
}, [projectName]);
|
||||
|
||||
// Fetch room AI configs for @ai: mention suggestions
|
||||
const fetchRoomAiConfigs = useCallback(async () => {
|
||||
const client = wsClientRef.current;
|
||||
if (!activeRoomId || !client) {
|
||||
setRoomAiConfigs([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const configs = await client.aiList(activeRoomId);
|
||||
// Look up model names from the available models list
|
||||
setRoomAiConfigs(
|
||||
configs.map((cfg) => ({
|
||||
model: cfg.model,
|
||||
modelName: availableModels.find((m) => m.id === cfg.model)?.name,
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
setRoomAiConfigs([]);
|
||||
}
|
||||
}, [activeRoomId, availableModels]);
|
||||
|
||||
// Fetch available models (for AI model name lookup)
|
||||
const fetchAvailableModels = useCallback(async () => {
|
||||
try {
|
||||
const resp = await (await import('@/client')).modelList({});
|
||||
const inner = (resp.data as { data?: { data?: { id: string; name: string }[] } } | undefined);
|
||||
setAvailableModels(inner?.data?.data ?? []);
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjectRepos();
|
||||
}, [fetchProjectRepos]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoomAiConfigs();
|
||||
}, [fetchRoomAiConfigs]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAvailableModels();
|
||||
}, [fetchAvailableModels]);
|
||||
|
||||
const createRoom = useCallback(
|
||||
async (name: string, isPublic: boolean, categoryId?: string) => {
|
||||
const client = wsClientRef.current;
|
||||
@ -1222,6 +1301,8 @@ export function RoomProvider({
|
||||
updateRoom,
|
||||
deleteRoom,
|
||||
streamingMessages: streamingContent,
|
||||
projectRepos,
|
||||
roomAiConfigs,
|
||||
}),
|
||||
[
|
||||
wsStatus,
|
||||
@ -1270,6 +1351,8 @@ export function RoomProvider({
|
||||
updateRoom,
|
||||
deleteRoom,
|
||||
streamingContent,
|
||||
projectRepos,
|
||||
roomAiConfigs,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
165
src/lib/mention-ast.ts
Normal file
165
src/lib/mention-ast.ts
Normal file
@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Mention AST system — see architecture spec.
|
||||
*
|
||||
* Node types:
|
||||
* text — plain text
|
||||
* mention — @user:, @repository:, @ai: mentions
|
||||
* ai_action — <ai action="..."> structured AI commands (future)
|
||||
*
|
||||
* HTML serialization:
|
||||
* <mention type="user" id="uuid-or-username">label</mention>
|
||||
* <mention type="repository" id="repo-uid">repo_name</mention>
|
||||
* <mention type="ai" id="model-uid">model_name</mention>
|
||||
* <ai action="..."></ai>
|
||||
*
|
||||
* Key principle: always use ID for logic, label only for display.
|
||||
*/
|
||||
|
||||
// ─── AST Node Types ──────────────────────────────────────────────────────────
|
||||
|
||||
export type MentionMentionType = 'user' | 'repository' | 'ai';
|
||||
|
||||
export type TextNode = {
|
||||
type: 'text';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type MentionNode = {
|
||||
type: 'mention';
|
||||
mentionType: MentionMentionType;
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type AiActionNode = {
|
||||
type: 'ai_action';
|
||||
action: string;
|
||||
args?: string;
|
||||
};
|
||||
|
||||
export type Node = TextNode | MentionNode | AiActionNode;
|
||||
|
||||
export type Document = Node[];
|
||||
|
||||
// ─── Serialization (AST → HTML) ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Serialize a single AST node to HTML string.
|
||||
*/
|
||||
function serializeNode(node: Node): string {
|
||||
if (node.type === 'text') return node.text;
|
||||
if (node.type === 'ai_action') return `<ai action="${node.action}">${node.args ?? ''}</ai>`;
|
||||
// mention node
|
||||
const escapedId = node.id.replace(/"/g, '"');
|
||||
const escapedLabel = node.label.replace(/</g, '<').replace(/>/g, '>');
|
||||
return `<mention type="${node.mentionType}" id="${escapedId}">${escapedLabel}</mention>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a document (list of AST nodes) to HTML string.
|
||||
*/
|
||||
export function serialize(doc: Document): string {
|
||||
return doc.map(serializeNode).join('');
|
||||
}
|
||||
|
||||
// ─── Parsing (HTML → AST) ────────────────────────────────────────────────────
|
||||
|
||||
// Regex to match <mention type="..." id="...">label</mention>
|
||||
// Works whether attributes are on one line or spread across lines.
|
||||
const MENTION_RE =
|
||||
/<mention\s+type="([^"]+)"\s+id="([^"]+)"[^>]*>\s*([^<]*?)\s*<\/mention>/gi;
|
||||
|
||||
// Regex to match <ai action="...">args</ai>
|
||||
const AI_ACTION_RE = /<ai\s+action="([^"]+)"[^>]*>\s*([^<]*?)\s*<\/ai>/gi;
|
||||
|
||||
/**
|
||||
* Parse an HTML string into an AST document.
|
||||
* Falls back to a single text node if no structured tags are found.
|
||||
*/
|
||||
export function parse(html: string): Document {
|
||||
if (!html) return [];
|
||||
|
||||
const nodes: Document = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
// We interleave all three patterns to find the earliest match.
|
||||
const matchers: Array<{
|
||||
re: RegExp;
|
||||
type: 'mention' | 'ai_action';
|
||||
}> = [
|
||||
{ re: MENTION_RE, type: 'mention' },
|
||||
{ re: AI_ACTION_RE, type: 'ai_action' },
|
||||
];
|
||||
|
||||
// Reset regex lastIndex
|
||||
for (const m of matchers) m.re.lastIndex = 0;
|
||||
|
||||
while (true) {
|
||||
let earliest: { match: RegExpExecArray; type: 'mention' | 'ai_action' } | null = null;
|
||||
|
||||
for (const m of matchers) {
|
||||
m.re.lastIndex = lastIndex;
|
||||
const match = m.re.exec(html);
|
||||
if (match) {
|
||||
if (!earliest || match.index < earliest.match.index) {
|
||||
earliest = { match, type: m.type };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!earliest) break;
|
||||
|
||||
const { match, type } = earliest;
|
||||
|
||||
// Text before this match
|
||||
if (match.index > lastIndex) {
|
||||
const text = html.slice(lastIndex, match.index);
|
||||
if (text) nodes.push({ type: 'text', text });
|
||||
}
|
||||
|
||||
if (type === 'mention') {
|
||||
const mentionType = match[1] as MentionMentionType;
|
||||
const id = match[2];
|
||||
const label = match[3] ?? '';
|
||||
if (
|
||||
mentionType === 'user' ||
|
||||
mentionType === 'repository' ||
|
||||
mentionType === 'ai'
|
||||
) {
|
||||
nodes.push({ type: 'mention', mentionType, id, label });
|
||||
} else {
|
||||
// Unknown mention type — treat as text
|
||||
nodes.push({ type: 'text', text: match[0] });
|
||||
}
|
||||
} else if (type === 'ai_action') {
|
||||
const action = match[1];
|
||||
const args = match[2] ?? '';
|
||||
nodes.push({ type: 'ai_action', action, args });
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Trailing text
|
||||
if (lastIndex < html.length) {
|
||||
const text = html.slice(lastIndex);
|
||||
if (text) nodes.push({ type: 'text', text });
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// ─── Suggestion value builders ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the HTML mention string from a suggestion selection.
|
||||
*/
|
||||
export function buildMentionHtml(
|
||||
mentionType: MentionMentionType,
|
||||
id: string,
|
||||
label: string,
|
||||
): string {
|
||||
const escapedId = id.replace(/"/g, '"');
|
||||
const escapedLabel = label.replace(/</g, '<').replace(/>/g, '>');
|
||||
return `<mention type="${mentionType}" id="${escapedId}">${escapedLabel}</mention>`;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user