Compare commits
18 Commits
a171d691c6
...
7be2f4eb61
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7be2f4eb61 | ||
|
|
17e878c8b8 | ||
|
|
14de80b24b | ||
|
|
245384ef50 | ||
|
|
b8a61b0802 | ||
|
|
3cd5b3003c | ||
|
|
13f5ff328c | ||
|
|
14bcc04991 | ||
|
|
9b966789fd | ||
|
|
a9fc6f9937 | ||
|
|
aacd9572d1 | ||
|
|
9246a9e6ab | ||
|
|
f9a3b51406 | ||
|
|
26682973e7 | ||
|
|
0ea6440ea3 | ||
|
|
6431709669 | ||
|
|
5ff45770ec | ||
|
|
4f1ea95b58 |
@ -1,4 +1,3 @@
|
|||||||
{{- /* Single unified Ingress for all services */ -}}
|
|
||||||
{{- $fullName := include "gitdata.fullname" . -}}
|
{{- $fullName := include "gitdata.fullname" . -}}
|
||||||
{{- $ns := include "gitdata.namespace" . -}}
|
{{- $ns := include "gitdata.namespace" . -}}
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
@ -12,6 +11,7 @@ metadata:
|
|||||||
annotations:
|
annotations:
|
||||||
cert-manager.io/cluster-issuer: cloudflare-acme-cluster-issuer
|
cert-manager.io/cluster-issuer: cloudflare-acme-cluster-issuer
|
||||||
nginx.ingress.kubernetes.io/proxy-body-size: "0"
|
nginx.ingress.kubernetes.io/proxy-body-size: "0"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
|
||||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||||
spec:
|
spec:
|
||||||
@ -24,24 +24,9 @@ spec:
|
|||||||
- static.gitdata.ai
|
- static.gitdata.ai
|
||||||
secretName: {{ $fullName }}-tls
|
secretName: {{ $fullName }}-tls
|
||||||
rules:
|
rules:
|
||||||
# SPA (embedded in app), with /api and /ws
|
|
||||||
- host: gitdata.ai
|
- host: gitdata.ai
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
- path: /api
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: {{ $fullName }}-app
|
|
||||||
port:
|
|
||||||
number: {{ .Values.app.service.port }}
|
|
||||||
- path: /ws
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: {{ $fullName }}-app
|
|
||||||
port:
|
|
||||||
number: {{ .Values.app.service.port }}
|
|
||||||
- path: /
|
- path: /
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
use std::sync::{Arc, LazyLock};
|
use std::sync::{Arc, LazyLock};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use actix_web::{HttpMessage, HttpRequest, HttpResponse, web};
|
use actix_web::{web, HttpMessage, HttpRequest, HttpResponse};
|
||||||
use actix_ws::Message as WsMessage;
|
use actix_ws::Message as WsMessage;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@ -232,30 +232,42 @@ pub struct WsOutEvent {
|
|||||||
pub(crate) fn validate_origin(req: &HttpRequest) -> bool {
|
pub(crate) fn validate_origin(req: &HttpRequest) -> bool {
|
||||||
static ALLOWED_ORIGINS: LazyLock<Vec<String>> = LazyLock::new(|| {
|
static ALLOWED_ORIGINS: LazyLock<Vec<String>> = LazyLock::new(|| {
|
||||||
// Build default origins from localhost + APP_DOMAIN_URL
|
// Build default origins from localhost + APP_DOMAIN_URL
|
||||||
let domain = std::env::var("APP_DOMAIN_URL")
|
let domain =
|
||||||
.unwrap_or_else(|_| "http://127.0.0.1".to_string());
|
std::env::var("APP_DOMAIN_URL").unwrap_or_else(|_| "http://127.0.0.1".to_string());
|
||||||
|
|
||||||
// Normalize: strip trailing slash, derive https/wss variants
|
// Normalize: strip trailing slash, derive https/wss variants
|
||||||
let domain = domain.trim_end_matches('/');
|
let domain = domain.trim_end_matches('/');
|
||||||
let https_domain = domain.replace("http://", "https://").replace("ws://", "wss://");
|
let https_domain = domain
|
||||||
let ws_domain = domain.replace("https://", "ws://").replace("http://", "ws://");
|
.replace("http://", "https://")
|
||||||
|
.replace("ws://", "wss://");
|
||||||
|
let ws_domain = domain
|
||||||
|
.replace("https://", "ws://")
|
||||||
|
.replace("http://", "ws://");
|
||||||
|
|
||||||
let mut defaults = vec![
|
let mut defaults = vec![
|
||||||
"http://localhost".to_string(),
|
"http://localhost".to_string(),
|
||||||
"https://localhost".to_string(),
|
"https://localhost".to_string(),
|
||||||
"http://127.0.0.1".to_string(),
|
"http://127.0.0.1".to_string(),
|
||||||
|
"http://gitdata.ai".to_string(),
|
||||||
|
"https://gitdata.ai".to_string(),
|
||||||
"https://127.0.0.1".to_string(),
|
"https://127.0.0.1".to_string(),
|
||||||
"ws://localhost".to_string(),
|
"ws://localhost".to_string(),
|
||||||
"wss://localhost".to_string(),
|
"wss://localhost".to_string(),
|
||||||
"ws://127.0.0.1".to_string(),
|
"ws://127.0.0.1".to_string(),
|
||||||
|
"ws://gitdata.ai".to_string(),
|
||||||
|
"wss://gitdata.ai".to_string(),
|
||||||
"wss://127.0.0.1".to_string(),
|
"wss://127.0.0.1".to_string(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Always include APP_DOMAIN_URL and APP_STATIC_DOMAIN origins
|
// Always include APP_DOMAIN_URL and APP_STATIC_DOMAIN origins
|
||||||
let mut add_origin = |origin: &str| {
|
let mut add_origin = |origin: &str| {
|
||||||
let origin = origin.trim_end_matches('/');
|
let origin = origin.trim_end_matches('/');
|
||||||
let https_v = origin.replace("http://", "https://").replace("ws://", "wss://");
|
let https_v = origin
|
||||||
let ws_v = origin.replace("https://", "ws://").replace("http://", "ws://");
|
.replace("http://", "https://")
|
||||||
|
.replace("ws://", "wss://");
|
||||||
|
let ws_v = origin
|
||||||
|
.replace("https://", "ws://")
|
||||||
|
.replace("http://", "ws://");
|
||||||
for v in [origin, &https_v, &ws_v] {
|
for v in [origin, &https_v, &ws_v] {
|
||||||
if !defaults.contains(&v.to_string()) && v != domain {
|
if !defaults.contains(&v.to_string()) && v != domain {
|
||||||
defaults.push(v.to_string());
|
defaults.push(v.to_string());
|
||||||
|
|||||||
@ -81,6 +81,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260414_000001_create_agent_task::Migration),
|
Box::new(m20260414_000001_create_agent_task::Migration),
|
||||||
Box::new(m20260415_000001_add_issue_id_to_agent_task::Migration),
|
Box::new(m20260415_000001_add_issue_id_to_agent_task::Migration),
|
||||||
Box::new(m20260416_000001_add_retry_count_to_agent_task::Migration),
|
Box::new(m20260416_000001_add_retry_count_to_agent_task::Migration),
|
||||||
|
Box::new(m20260417_000001_add_stream_to_room_ai::Migration),
|
||||||
// Repo tables
|
// Repo tables
|
||||||
Box::new(m20250628_000028_create_repo::Migration),
|
Box::new(m20250628_000028_create_repo::Migration),
|
||||||
Box::new(m20250628_000029_create_repo_branch::Migration),
|
Box::new(m20250628_000029_create_repo_branch::Migration),
|
||||||
@ -252,3 +253,4 @@ pub mod m20260413_000001_add_skill_commit_blob;
|
|||||||
pub mod m20260414_000001_create_agent_task;
|
pub mod m20260414_000001_create_agent_task;
|
||||||
pub mod m20260415_000001_add_issue_id_to_agent_task;
|
pub mod m20260415_000001_add_issue_id_to_agent_task;
|
||||||
pub mod m20260416_000001_add_retry_count_to_agent_task;
|
pub mod m20260416_000001_add_retry_count_to_agent_task;
|
||||||
|
pub mod m20260417_000001_add_stream_to_room_ai;
|
||||||
|
|||||||
36
libs/migrate/m20260417_000001_add_stream_to_room_ai.rs
Normal file
36
libs/migrate/m20260417_000001_add_stream_to_room_ai.rs
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
//! SeaORM migration: add `stream` column to `room_ai` table
|
||||||
|
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
impl MigrationName for Migration {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"m20260417_000001_add_stream_to_room_ai"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_raw(sea_orm::Statement::from_string(
|
||||||
|
sea_orm::DbBackend::Postgres,
|
||||||
|
"ALTER TABLE room_ai ADD COLUMN IF NOT EXISTS stream BOOLEAN NOT NULL DEFAULT true;",
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_raw(sea_orm::Statement::from_string(
|
||||||
|
sea_orm::DbBackend::Postgres,
|
||||||
|
"ALTER TABLE room_ai DROP COLUMN IF EXISTS stream;",
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -27,6 +27,15 @@ const DEFAULT_MAX_CONCURRENT_WORKERS: usize = 1024;
|
|||||||
static USER_MENTION_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex> =
|
static USER_MENTION_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex> =
|
||||||
LazyLock::new(|| regex_lite::Regex::new(r"<user>\s*([^<]+?)\s*</user>").unwrap());
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct RoomService {
|
pub struct RoomService {
|
||||||
pub db: AppDatabase,
|
pub db: AppDatabase,
|
||||||
@ -533,8 +542,12 @@ impl RoomService {
|
|||||||
Ok(())
|
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> {
|
pub fn extract_mentions(content: &str) -> Vec<Uuid> {
|
||||||
let mut mentioned = Vec::new();
|
let mut mentioned = Vec::new();
|
||||||
|
|
||||||
|
// Legacy <user>uuid</user> format
|
||||||
for cap in USER_MENTION_RE.captures_iter(content) {
|
for cap in USER_MENTION_RE.captures_iter(content) {
|
||||||
if let Some(inner) = cap.get(1) {
|
if let Some(inner) = cap.get(1) {
|
||||||
let token = inner.as_str().trim();
|
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
|
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> {
|
pub async fn resolve_mentions(&self, content: &str) -> Vec<Uuid> {
|
||||||
use models::users::User;
|
use models::users::User;
|
||||||
use sea_orm::EntityTrait;
|
use sea_orm::EntityTrait;
|
||||||
@ -556,6 +585,7 @@ impl RoomService {
|
|||||||
let mut resolved: Vec<Uuid> = Vec::new();
|
let mut resolved: Vec<Uuid> = Vec::new();
|
||||||
let mut seen_usernames: Vec<String> = 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) {
|
for cap in USER_MENTION_RE.captures_iter(content) {
|
||||||
if let Some(inner) = cap.get(1) {
|
if let Some(inner) = cap.get(1) {
|
||||||
let token = inner.as_str().trim();
|
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
|
resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,10 +1,12 @@
|
|||||||
import { memo, useMemo } from 'react';
|
import type { ProjectRepositoryItem, RoomMemberResponse } from '@/client';
|
||||||
|
import type { RoomAiConfig } from '@/components/room/MentionPopover';
|
||||||
|
import { memo, useCallback, useMemo } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { parse, type Node, type MentionMentionType } from '@/lib/mention-ast';
|
||||||
|
|
||||||
type MentionType = 'repository' | 'user' | 'ai' | 'notify';
|
type MentionType = 'repository' | 'user' | 'ai' | 'notify';
|
||||||
|
|
||||||
interface MentionToken {
|
interface LegacyMentionToken {
|
||||||
full: string;
|
full: string;
|
||||||
type: MentionType;
|
type: MentionType;
|
||||||
name: string;
|
name: string;
|
||||||
@ -14,12 +16,17 @@ function isMentionNameChar(ch: string): boolean {
|
|||||||
return /[A-Za-z0-9._:\/-]/.test(ch);
|
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;
|
let cursor = 0;
|
||||||
|
|
||||||
while (cursor < text.length) {
|
while (cursor < text.length) {
|
||||||
// Find next potential mention: <user> or <user: or @user:
|
|
||||||
const angleOpen = text.indexOf('<', cursor);
|
const angleOpen = text.indexOf('<', cursor);
|
||||||
const atOpen = text.indexOf('@', cursor);
|
const atOpen = text.indexOf('@', cursor);
|
||||||
|
|
||||||
@ -36,13 +43,10 @@ function extractMentionTokens(text: string): MentionToken[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const typeStart = next + 1;
|
const typeStart = next + 1;
|
||||||
|
|
||||||
// Check for colon format: <type:name> or @type:name
|
|
||||||
const colon = text.indexOf(':', typeStart);
|
const colon = text.indexOf(':', typeStart);
|
||||||
const validTypes: MentionType[] = ['repository', 'user', 'ai', 'notify'];
|
const validTypes: MentionType[] = ['repository', 'user', 'ai', 'notify'];
|
||||||
|
|
||||||
if (colon >= 0 && colon - typeStart <= 20) {
|
if (colon >= 0 && colon - typeStart <= 20) {
|
||||||
// Colon format: <type:name> or @type:name
|
|
||||||
const typeRaw = text.slice(typeStart, colon).toLowerCase();
|
const typeRaw = text.slice(typeStart, colon).toLowerCase();
|
||||||
if (!validTypes.includes(typeRaw as MentionType)) {
|
if (!validTypes.includes(typeRaw as MentionType)) {
|
||||||
cursor = next + 1;
|
cursor = next + 1;
|
||||||
@ -54,7 +58,6 @@ function extractMentionTokens(text: string): MentionToken[] {
|
|||||||
end++;
|
end++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For angle style, require closing >
|
|
||||||
const closeBracket = style === 'angle' && text[end] === '>' ? end + 1 : end;
|
const closeBracket = style === 'angle' && text[end] === '>' ? end + 1 : end;
|
||||||
const name = text.slice(colon + 1, style === 'angle' ? end : closeBracket);
|
const name = text.slice(colon + 1, style === 'angle' ? end : closeBracket);
|
||||||
|
|
||||||
@ -65,10 +68,7 @@ function extractMentionTokens(text: string): MentionToken[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for XML-like format: <type>name</type>
|
|
||||||
// Only for angle style
|
|
||||||
if (style === 'angle') {
|
if (style === 'angle') {
|
||||||
// Find space or > to determine type end
|
|
||||||
let typeEnd = typeStart;
|
let typeEnd = typeStart;
|
||||||
while (typeEnd < text.length && /[A-Za-z]/.test(text[typeEnd])) {
|
while (typeEnd < text.length && /[A-Za-z]/.test(text[typeEnd])) {
|
||||||
typeEnd++;
|
typeEnd++;
|
||||||
@ -76,11 +76,8 @@ function extractMentionTokens(text: string): MentionToken[] {
|
|||||||
const typeCandidate = text.slice(typeStart, typeEnd);
|
const typeCandidate = text.slice(typeStart, typeEnd);
|
||||||
|
|
||||||
if (validTypes.includes(typeCandidate as MentionType)) {
|
if (validTypes.includes(typeCandidate as MentionType)) {
|
||||||
// Found opening tag like <user>
|
|
||||||
const closeTag = `</${typeCandidate}>`;
|
const closeTag = `</${typeCandidate}>`;
|
||||||
const contentStart = typeEnd;
|
const contentStart = typeEnd;
|
||||||
|
|
||||||
// Find the closing tag
|
|
||||||
const tagClose = text.indexOf(closeTag, cursor);
|
const tagClose = text.indexOf(closeTag, cursor);
|
||||||
if (tagClose >= 0) {
|
if (tagClose >= 0) {
|
||||||
const name = text.slice(contentStart, tagClose);
|
const name = text.slice(contentStart, tagClose);
|
||||||
@ -99,49 +96,118 @@ function extractMentionTokens(text: string): MentionToken[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractFirstMentionName(text: string, type: MentionType): string | null {
|
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;
|
return token?.name ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MessageContentWithMentionsProps {
|
interface MessageContentWithMentionsProps {
|
||||||
content: string;
|
content: string;
|
||||||
|
/** Members list for resolving user mention IDs to display names */
|
||||||
|
members?: RoomMemberResponse[];
|
||||||
|
/** Repository list for resolving repository mention IDs to display names */
|
||||||
|
repos?: ProjectRepositoryItem[];
|
||||||
|
/** AI configs for resolving AI mention IDs to display names */
|
||||||
|
aiConfigs?: RoomAiConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
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',
|
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',
|
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',
|
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',
|
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,
|
||||||
|
resolveName: (type: string, id: string, label: string) => string,
|
||||||
|
): React.ReactNode {
|
||||||
|
if (node.type === 'text') {
|
||||||
|
return <span key={index}>{node.text}</span>;
|
||||||
|
}
|
||||||
|
if (node.type === 'mention') {
|
||||||
|
const displayName = resolveName(node.mentionType, node.id, node.label);
|
||||||
|
return (
|
||||||
|
<span key={index} className={mentionStyles[node.mentionType] ?? mentionStyles.user}>
|
||||||
|
@{displayName}
|
||||||
|
</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({
|
export const MessageContentWithMentions = memo(function MessageContentWithMentions({
|
||||||
content,
|
content,
|
||||||
|
members = [],
|
||||||
|
repos = [],
|
||||||
|
aiConfigs = [],
|
||||||
}: MessageContentWithMentionsProps) {
|
}: MessageContentWithMentionsProps) {
|
||||||
const processed = useMemo(() => {
|
const nodes = useMemo(() => {
|
||||||
const tokens = extractMentionTokens(content);
|
// Try the new AST parser first (handles <mention> and <ai> tags)
|
||||||
if (tokens.length === 0) return [{ type: 'text' as const, content }];
|
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;
|
let cursor = 0;
|
||||||
|
for (const token of legacy) {
|
||||||
for (const token of tokens) {
|
|
||||||
const idx = content.indexOf(token.full, cursor);
|
const idx = content.indexOf(token.full, cursor);
|
||||||
if (idx === -1) continue;
|
if (idx === -1) continue;
|
||||||
if (idx > cursor) {
|
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;
|
cursor = idx + token.full.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cursor < content.length) {
|
if (cursor < content.length) {
|
||||||
parts.push({ type: 'text', content: content.slice(cursor) });
|
parts.push({ type: 'text', text: content.slice(cursor) });
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts;
|
return parts;
|
||||||
}, [content]);
|
}, [content]);
|
||||||
|
|
||||||
|
// Resolve ID → display name for each mention type
|
||||||
|
const resolveName = useCallback(
|
||||||
|
(type: string, id: string, label: string): string => {
|
||||||
|
if (type === 'user') {
|
||||||
|
const member = members.find((m) => m.user === id);
|
||||||
|
return member?.user_info?.username ?? member?.user ?? label;
|
||||||
|
}
|
||||||
|
if (type === 'repository') {
|
||||||
|
const repo = repos.find((r) => r.uid === id);
|
||||||
|
return repo?.repo_name ?? label;
|
||||||
|
}
|
||||||
|
if (type === 'ai') {
|
||||||
|
const cfg = aiConfigs.find((c) => c.model === id);
|
||||||
|
return cfg?.modelName ?? cfg?.model ?? label;
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
},
|
||||||
|
[members, repos, aiConfigs],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -151,18 +217,7 @@ export const MessageContentWithMentions = memo(function MessageContentWithMentio
|
|||||||
'[&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:overflow-x-auto',
|
'[&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:overflow-x-auto',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{processed.map((part, i) =>
|
{nodes.map((node, i) => renderNode(node, i, resolveName))}
|
||||||
part.type === 'mention' ? (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className={mentionStyles[part.mention.type]}
|
|
||||||
>
|
|
||||||
@{part.mention.name}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span key={i}>{part.content}</span>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
import type { RoomResponse, RoomMemberResponse, RoomMessageResponse, RoomThreadResponse } from '@/client';
|
import type { ProjectRepositoryItem, RoomResponse, RoomMemberResponse, RoomMessageResponse, RoomThreadResponse } from '@/client';
|
||||||
import { useRoom, type MessageWithMeta } from '@/contexts';
|
import { useRoom, type MessageWithMeta } from '@/contexts';
|
||||||
|
import { type RoomAiConfig } from '@/contexts/room-context';
|
||||||
import { useRoomDraft } from '@/hooks/useRoomDraft';
|
import { useRoomDraft } from '@/hooks/useRoomDraft';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { buildMentionHtml } from '@/lib/mention-ast';
|
||||||
|
import { mentionSelectedIdxRef, mentionVisibleRef } from '@/lib/mention-refs';
|
||||||
import { ChevronLeft, Hash, Send, Settings, Timer, Trash2, Users, X, Search, Bell } from 'lucide-react';
|
import { ChevronLeft, Hash, Send, Settings, Timer, Trash2, Users, X, Search, Bell } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
@ -29,8 +32,11 @@ import { RoomThreadPanel } from './RoomThreadPanel';
|
|||||||
const MENTION_PATTERN = /@([^:@\s]*)(:([^\s]*))?$/;
|
const MENTION_PATTERN = /@([^:@\s]*)(:([^\s]*))?$/;
|
||||||
const MENTION_POPOVER_KEYS = ['Enter', 'Tab', 'ArrowUp', 'ArrowDown'];
|
const MENTION_POPOVER_KEYS = ['Enter', 'Tab', 'ArrowUp', 'ArrowDown'];
|
||||||
|
|
||||||
|
|
||||||
export interface ChatInputAreaHandle {
|
export interface ChatInputAreaHandle {
|
||||||
insertMention: (name: string, type: 'user' | 'ai') => void;
|
insertMention: (id: string, label: string, type: 'user' | 'ai') => void;
|
||||||
|
/** Insert a category prefix (e.g. 'ai') into the textarea and trigger React onChange */
|
||||||
|
insertCategory: (category: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatInputAreaProps {
|
interface ChatInputAreaProps {
|
||||||
@ -38,6 +44,10 @@ interface ChatInputAreaProps {
|
|||||||
onSend: (content: string) => void;
|
onSend: (content: string) => void;
|
||||||
isSending: boolean;
|
isSending: boolean;
|
||||||
members: RoomMemberResponse[];
|
members: RoomMemberResponse[];
|
||||||
|
repos?: ProjectRepositoryItem[];
|
||||||
|
reposLoading?: boolean;
|
||||||
|
aiConfigs?: RoomAiConfig[];
|
||||||
|
aiConfigsLoading?: boolean;
|
||||||
replyingTo?: { id: string; display_name?: string; content: string } | null;
|
replyingTo?: { id: string; display_name?: string; content: string } | null;
|
||||||
onCancelReply?: () => void;
|
onCancelReply?: () => void;
|
||||||
draft: string;
|
draft: string;
|
||||||
@ -51,6 +61,10 @@ const ChatInputArea = memo(function ChatInputArea({
|
|||||||
onSend,
|
onSend,
|
||||||
isSending,
|
isSending,
|
||||||
members,
|
members,
|
||||||
|
repos,
|
||||||
|
reposLoading,
|
||||||
|
aiConfigs,
|
||||||
|
aiConfigsLoading,
|
||||||
replyingTo,
|
replyingTo,
|
||||||
onCancelReply,
|
onCancelReply,
|
||||||
draft,
|
draft,
|
||||||
@ -62,7 +76,32 @@ const ChatInputArea = memo(function ChatInputArea({
|
|||||||
const [cursorPosition, setCursorPosition] = useState(0);
|
const [cursorPosition, setCursorPosition] = useState(0);
|
||||||
const [showMentionPopover, setShowMentionPopover] = useState(false);
|
const [showMentionPopover, setShowMentionPopover] = useState(false);
|
||||||
|
|
||||||
const handleMentionSelect = useCallback((newValue: string, newCursorPos: number) => {
|
const handleMentionSelect = useCallback((_newValue: string, _newCursorPos: number) => {
|
||||||
|
if (!textareaRef.current) return;
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
const cursorPos = textarea.selectionStart;
|
||||||
|
const textBefore = textarea.value.substring(0, cursorPos);
|
||||||
|
const atMatch = textBefore.match(MENTION_PATTERN);
|
||||||
|
if (!atMatch) return;
|
||||||
|
|
||||||
|
const [fullMatch] = atMatch;
|
||||||
|
const startPos = cursorPos - fullMatch.length;
|
||||||
|
|
||||||
|
const before = textarea.value.substring(0, startPos);
|
||||||
|
const after = textarea.value.substring(cursorPos);
|
||||||
|
|
||||||
|
const suggestion = mentionVisibleRef.current[mentionSelectedIdxRef.current];
|
||||||
|
if (!suggestion || suggestion.type !== 'item') return;
|
||||||
|
|
||||||
|
const html = buildMentionHtml(
|
||||||
|
suggestion.category!,
|
||||||
|
suggestion.mentionId!,
|
||||||
|
suggestion.label,
|
||||||
|
);
|
||||||
|
const spacer = ' ';
|
||||||
|
const newValue = before + html + spacer + after;
|
||||||
|
const newCursorPos = startPos + html.length + spacer.length;
|
||||||
|
|
||||||
onDraftChange(newValue);
|
onDraftChange(newValue);
|
||||||
setShowMentionPopover(false);
|
setShowMentionPopover(false);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -72,14 +111,17 @@ const ChatInputArea = memo(function ChatInputArea({
|
|||||||
textareaRef.current.focus();
|
textareaRef.current.focus();
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
}, [onDraftChange]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []); // Intentionally use refs, not state
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
insertMention: (name: string) => {
|
insertMention: (id: string, label: string) => {
|
||||||
if (!textareaRef.current) return;
|
if (!textareaRef.current) return;
|
||||||
const value = textareaRef.current.value;
|
const value = textareaRef.current.value;
|
||||||
const cursorPos = textareaRef.current.selectionStart;
|
const cursorPos = textareaRef.current.selectionStart;
|
||||||
const mentionText = `<user>${name}</user> `;
|
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 before = value.substring(0, cursorPos);
|
||||||
const after = value.substring(cursorPos);
|
const after = value.substring(cursorPos);
|
||||||
const newValue = before + mentionText + after;
|
const newValue = before + mentionText + after;
|
||||||
@ -91,6 +133,26 @@ const ChatInputArea = memo(function ChatInputArea({
|
|||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
},
|
},
|
||||||
|
insertCategory: (category: string) => {
|
||||||
|
// Enter a category: e.g. @ai → @ai:
|
||||||
|
if (!textareaRef.current) return;
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
const textBefore = textarea.value.substring(0, textarea.selectionStart);
|
||||||
|
const atMatch = textBefore.match(MENTION_PATTERN);
|
||||||
|
if (!atMatch) return;
|
||||||
|
const [fullMatch] = atMatch;
|
||||||
|
const startPos = textarea.selectionStart - fullMatch.length;
|
||||||
|
const before = textarea.value.substring(0, startPos);
|
||||||
|
const afterPartial = textarea.value.substring(startPos + fullMatch.length);
|
||||||
|
const newValue = before + '@' + category + ':' + afterPartial;
|
||||||
|
const newCursorPos = startPos + 1 + category.length + 1; // after '@ai:'
|
||||||
|
// Directly update React state to trigger mention detection
|
||||||
|
onDraftChange(newValue);
|
||||||
|
setCursorPosition(newCursorPos);
|
||||||
|
const textBefore2 = newValue.substring(0, newCursorPos);
|
||||||
|
const match = textBefore2.match(MENTION_PATTERN);
|
||||||
|
setShowMentionPopover(!!match);
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
@ -110,12 +172,49 @@ const ChatInputArea = memo(function ChatInputArea({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (showMentionPopover && MENTION_POPOVER_KEYS.includes(e.key)) {
|
// Detect mention directly from DOM — avoids stale React state between handleChange and handleKeyDown
|
||||||
|
const hasMention = textareaRef.current
|
||||||
|
? textareaRef.current.value.substring(0, textareaRef.current.selectionStart).match(MENTION_PATTERN) !== null
|
||||||
|
: false;
|
||||||
|
|
||||||
|
if (hasMention && MENTION_POPOVER_KEYS.includes(e.key) && !e.ctrlKey && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
// Enter/Tab: do mention selection directly here using module-level refs
|
||||||
|
if ((e.key === 'Enter' || e.key === 'Tab')) {
|
||||||
|
const suggestion = mentionVisibleRef.current[mentionSelectedIdxRef.current];
|
||||||
|
if (suggestion && suggestion.type === 'item') {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
const cursorPos = textarea.selectionStart;
|
||||||
|
const textBefore = textarea.value.substring(0, cursorPos);
|
||||||
|
const atMatch = textBefore.match(MENTION_PATTERN);
|
||||||
|
if (!atMatch) return;
|
||||||
|
const [fullMatch] = atMatch;
|
||||||
|
const startPos = cursorPos - fullMatch.length;
|
||||||
|
const before = textarea.value.substring(0, startPos);
|
||||||
|
const after = textarea.value.substring(cursorPos);
|
||||||
|
const html = buildMentionHtml(suggestion.category!, suggestion.mentionId!, suggestion.label);
|
||||||
|
const spacer = ' ';
|
||||||
|
const newValue = before + html + spacer + after;
|
||||||
|
const newCursorPos = startPos + html.length + spacer.length;
|
||||||
|
onDraftChange(newValue);
|
||||||
|
setShowMentionPopover(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.value = newValue;
|
||||||
|
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos);
|
||||||
|
textareaRef.current.focus();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
// Shift+Enter → let textarea naturally insert newline (pass through)
|
||||||
|
|
||||||
|
// Ctrl+Enter → send message
|
||||||
|
if (e.key === 'Enter' && e.ctrlKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const content = e.currentTarget.value.trim();
|
const content = e.currentTarget.value.trim();
|
||||||
if (content && !isSending) {
|
if (content && !isSending) {
|
||||||
@ -123,6 +222,12 @@ const ChatInputArea = memo(function ChatInputArea({
|
|||||||
onClearDraft();
|
onClearDraft();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plain Enter (no modifiers) → only trigger mention select; otherwise do nothing
|
||||||
|
if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
// Do nothing — Shift+Enter falls through to let textarea insert newline
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -169,11 +274,33 @@ const ChatInputArea = memo(function ChatInputArea({
|
|||||||
{showMentionPopover && (
|
{showMentionPopover && (
|
||||||
<MentionPopover
|
<MentionPopover
|
||||||
members={members}
|
members={members}
|
||||||
|
repos={repos}
|
||||||
|
reposLoading={reposLoading}
|
||||||
|
aiConfigs={aiConfigs}
|
||||||
|
aiConfigsLoading={aiConfigsLoading}
|
||||||
inputValue={draft}
|
inputValue={draft}
|
||||||
cursorPosition={cursorPosition}
|
cursorPosition={cursorPosition}
|
||||||
onSelect={handleMentionSelect}
|
onSelect={handleMentionSelect}
|
||||||
textareaRef={textareaRef}
|
textareaRef={textareaRef}
|
||||||
onOpenChange={setShowMentionPopover}
|
onOpenChange={setShowMentionPopover}
|
||||||
|
onCategoryEnter={(category: string) => {
|
||||||
|
// Enter a category: @ai → @ai: to show next-level items
|
||||||
|
if (!textareaRef.current) return;
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
const textBefore = textarea.value.substring(0, textarea.selectionStart);
|
||||||
|
const atMatch = textBefore.match(MENTION_PATTERN);
|
||||||
|
if (!atMatch) return;
|
||||||
|
const [fullMatch] = atMatch;
|
||||||
|
const startPos = textarea.selectionStart - fullMatch.length;
|
||||||
|
const before = textarea.value.substring(0, startPos);
|
||||||
|
const afterPartial = textarea.value.substring(startPos + fullMatch.length);
|
||||||
|
const newValue = before + '@' + category + ':' + afterPartial;
|
||||||
|
const newCursorPos = startPos + 1 + category.length + 1;
|
||||||
|
onDraftChange(newValue);
|
||||||
|
setCursorPosition(newCursorPos);
|
||||||
|
const match2 = newValue.substring(0, newCursorPos).match(MENTION_PATTERN);
|
||||||
|
setShowMentionPopover(!!match2);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -244,6 +371,10 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
|
|||||||
wsClient,
|
wsClient,
|
||||||
threads,
|
threads,
|
||||||
refreshThreads,
|
refreshThreads,
|
||||||
|
projectRepos,
|
||||||
|
reposLoading,
|
||||||
|
roomAiConfigs,
|
||||||
|
aiConfigsLoading,
|
||||||
} = useRoom();
|
} = useRoom();
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
@ -322,8 +453,8 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Stable: chatInputRef is stable, no deps that change on message updates
|
// Stable: chatInputRef is stable, no deps that change on message updates
|
||||||
const handleMention = useCallback((name: string, type: 'user' | 'ai') => {
|
const handleMention = useCallback((id: string, label: string) => {
|
||||||
chatInputRef.current?.insertMention(name, type);
|
chatInputRef.current?.insertMention(id, label, 'user');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSelectSearchResult = useCallback((message: RoomMessageResponse) => {
|
const handleSelectSearchResult = useCallback((message: RoomMessageResponse) => {
|
||||||
@ -530,6 +661,10 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
|
|||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
isSending={false}
|
isSending={false}
|
||||||
members={members}
|
members={members}
|
||||||
|
repos={projectRepos}
|
||||||
|
reposLoading={reposLoading}
|
||||||
|
aiConfigs={roomAiConfigs}
|
||||||
|
aiConfigsLoading={aiConfigsLoading}
|
||||||
replyingTo={replyingTo ? { id: replyingTo.id, display_name: replyingTo.display_name ?? undefined, content: replyingTo.content } : null}
|
replyingTo={replyingTo ? { id: replyingTo.id, display_name: replyingTo.display_name ?? undefined, content: replyingTo.content } : null}
|
||||||
onCancelReply={() => setReplyingTo(null)}
|
onCancelReply={() => setReplyingTo(null)}
|
||||||
draft={draft}
|
draft={draft}
|
||||||
|
|||||||
@ -92,7 +92,7 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
|
|||||||
const isStreaming = !!message.is_streaming;
|
const isStreaming = !!message.is_streaming;
|
||||||
const isEdited = !!message.edited_at;
|
const isEdited = !!message.edited_at;
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
const { wsClient, streamingMessages } = useRoom();
|
const { wsClient, streamingMessages, members, projectRepos, roomAiConfigs } = useRoom();
|
||||||
const isOwner = user?.uid === getSenderUserUid(message);
|
const isOwner = user?.uid === getSenderUserUid(message);
|
||||||
const isRevoked = !!message.revoked;
|
const isRevoked = !!message.revoked;
|
||||||
const isFailed = message.isOptimisticError === true;
|
const isFailed = message.isOptimisticError === true;
|
||||||
@ -315,7 +315,12 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
|
|||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="max-w-full min-w-0 overflow-hidden whitespace-pre-wrap break-words">
|
<div className="max-w-full min-w-0 overflow-hidden whitespace-pre-wrap break-words">
|
||||||
<MessageContentWithMentions content={displayContent} />
|
<MessageContentWithMentions
|
||||||
|
content={displayContent}
|
||||||
|
members={members}
|
||||||
|
repos={projectRepos}
|
||||||
|
aiConfigs={roomAiConfigs}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import type { ReactNode } from 'react';
|
|||||||
interface RoomParticipantsPanelProps {
|
interface RoomParticipantsPanelProps {
|
||||||
members: RoomMemberResponse[];
|
members: RoomMemberResponse[];
|
||||||
membersLoading: boolean;
|
membersLoading: boolean;
|
||||||
onMention?: (name: string, type: 'user' | 'ai') => void;
|
onMention?: (id: string, label: string, type: 'user' | 'ai') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RoomParticipantsPanel = memo(function RoomParticipantsPanel({
|
export const RoomParticipantsPanel = memo(function RoomParticipantsPanel({
|
||||||
@ -97,7 +97,7 @@ function ParticipantRow({
|
|||||||
onMention,
|
onMention,
|
||||||
}: {
|
}: {
|
||||||
member: RoomMemberResponse;
|
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 username = member.user_info?.username ?? member.user;
|
||||||
const avatarUrl = member.user_info?.avatar_url;
|
const avatarUrl = member.user_info?.avatar_url;
|
||||||
@ -110,7 +110,7 @@ function ParticipantRow({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/60',
|
'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}
|
disabled={!onMention}
|
||||||
>
|
>
|
||||||
<Avatar className="h-7 w-7">
|
<Avatar className="h-7 w-7">
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { memo, useState, useEffect, useCallback } from 'react';
|
import { memo, useState, useEffect, useCallback } from 'react';
|
||||||
import type { RoomResponse } from '@/client';
|
import type { ModelResponse, RoomResponse, RoomAiResponse, RoomAiUpsertRequest } from '@/client';
|
||||||
import { aiList, aiUpsert, aiDelete, modelList } from '@/client';
|
import { aiList, aiUpsert, aiDelete, modelList } from '@/client';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@ -15,7 +15,6 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Loader2, Plus, Trash2, Bot, ChevronDown, ChevronRight } from 'lucide-react';
|
import { Loader2, Plus, Trash2, Bot, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { ModelResponse, RoomAiResponse, RoomAiUpsertRequest } from '@/client';
|
|
||||||
|
|
||||||
interface RoomSettingsPanelProps {
|
interface RoomSettingsPanelProps {
|
||||||
room: RoomResponse;
|
room: RoomResponse;
|
||||||
@ -68,8 +67,8 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
|||||||
setAiConfigsLoading(true);
|
setAiConfigsLoading(true);
|
||||||
try {
|
try {
|
||||||
const resp = await aiList({ path: { room_id: room.id } });
|
const resp = await aiList({ path: { room_id: room.id } });
|
||||||
const inner = (resp.data as Record<string, unknown>)?.['200'] as Record<string, unknown> | undefined;
|
const inner = resp.data as { data?: RoomAiResponse[] } | undefined;
|
||||||
setAiConfigs(Array.isArray(inner?.['data']) ? (inner['data'] as RoomAiResponse[]) : []);
|
setAiConfigs(Array.isArray(inner?.data) ? inner.data : []);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
} finally {
|
} finally {
|
||||||
@ -79,6 +78,7 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAiConfigs();
|
loadAiConfigs();
|
||||||
|
loadModels();
|
||||||
}, [loadAiConfigs]);
|
}, [loadAiConfigs]);
|
||||||
|
|
||||||
// Load available models
|
// Load available models
|
||||||
@ -86,8 +86,8 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
|||||||
setModelsLoading(true);
|
setModelsLoading(true);
|
||||||
try {
|
try {
|
||||||
const resp = await modelList({});
|
const resp = await modelList({});
|
||||||
const raw = resp.data as Record<string, unknown> | undefined;
|
const inner = resp.data as { data?: ModelResponse[] } | undefined;
|
||||||
setAvailableModels(Array.isArray(raw?.['200']) ? (raw['200'] as ModelResponse[]) : []);
|
setAvailableModels(Array.isArray(inner?.data) ? inner.data : []);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to load models');
|
toast.error('Failed to load models');
|
||||||
} finally {
|
} finally {
|
||||||
@ -215,8 +215,8 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Bot className="h-4 w-4 text-muted-foreground shrink-0" />
|
<Bot className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
<span className="text-sm truncate font-mono text-foreground">
|
<span className="text-sm truncate text-foreground">
|
||||||
{config.model}
|
{availableModels.find((m) => m.id === config.model)?.name ?? config.model}
|
||||||
</span>
|
</span>
|
||||||
{config.stream && (
|
{config.stream && (
|
||||||
<span className="rounded bg-green-500/10 px-1 py-0.5 text-[10px] text-green-600 dark:text-green-400 shrink-0">
|
<span className="rounded bg-green-500/10 px-1 py-0.5 text-[10px] text-green-600 dark:text-green-400 shrink-0">
|
||||||
@ -266,7 +266,11 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
|||||||
) : (
|
) : (
|
||||||
<Select value={selectedModelId} onValueChange={(v) => { if (v !== null) setSelectedModelId(v); }}>
|
<Select value={selectedModelId} onValueChange={(v) => { if (v !== null) setSelectedModelId(v); }}>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Select a model..." />
|
<SelectValue placeholder="Select a model...">
|
||||||
|
{selectedModelId
|
||||||
|
? availableModels.find((m) => m.id === selectedModelId)?.name ?? selectedModelId
|
||||||
|
: null}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{availableModels.map((model) => (
|
{availableModels.map((model) => (
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
|
type ProjectRepositoryItem,
|
||||||
type RoomCategoryResponse,
|
type RoomCategoryResponse,
|
||||||
type RoomMemberResponse,
|
type RoomMemberResponse,
|
||||||
type RoomMessageResponse,
|
type RoomMessageResponse,
|
||||||
@ -39,6 +40,11 @@ import {
|
|||||||
|
|
||||||
export type { RoomWsStatus, RoomWsClient } from '@/lib/room-ws-client';
|
export type { RoomWsStatus, RoomWsClient } from '@/lib/room-ws-client';
|
||||||
|
|
||||||
|
export interface RoomAiConfig {
|
||||||
|
model: string;
|
||||||
|
modelName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReactionGroup {
|
export interface ReactionGroup {
|
||||||
emoji: string;
|
emoji: string;
|
||||||
count: number;
|
count: number;
|
||||||
@ -146,6 +152,13 @@ interface RoomContextValue {
|
|||||||
updateRoom: (roomId: string, name?: string, isPublic?: boolean, categoryId?: string) => Promise<void>;
|
updateRoom: (roomId: string, name?: string, isPublic?: boolean, categoryId?: string) => Promise<void>;
|
||||||
deleteRoom: (roomId: string) => Promise<void>;
|
deleteRoom: (roomId: string) => Promise<void>;
|
||||||
streamingMessages: Map<string, string>;
|
streamingMessages: Map<string, string>;
|
||||||
|
|
||||||
|
/** Project repositories for @repository: mention suggestions */
|
||||||
|
projectRepos: ProjectRepositoryItem[];
|
||||||
|
reposLoading?: boolean;
|
||||||
|
/** Room AI configs for @ai: mention suggestions */
|
||||||
|
roomAiConfigs: RoomAiConfig[];
|
||||||
|
aiConfigsLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RoomContext = createContext<RoomContextValue | null>(null);
|
const RoomContext = createContext<RoomContextValue | null>(null);
|
||||||
@ -415,6 +428,15 @@ export function RoomProvider({
|
|||||||
|
|
||||||
const [streamingContent, setStreamingContent] = useState<Map<string, string>>(new Map());
|
const [streamingContent, setStreamingContent] = useState<Map<string, string>>(new Map());
|
||||||
|
|
||||||
|
// Project repos for @repository: mention suggestions
|
||||||
|
const [projectRepos, setProjectRepos] = useState<ProjectRepositoryItem[]>([]);
|
||||||
|
const [reposLoading, setReposLoading] = useState(false);
|
||||||
|
// Room AI configs for @ai: mention suggestions
|
||||||
|
const [roomAiConfigs, setRoomAiConfigs] = useState<RoomAiConfig[]>([]);
|
||||||
|
const [aiConfigsLoading, setAiConfigsLoading] = useState(false);
|
||||||
|
// Available models (for looking up AI model names)
|
||||||
|
const [availableModels, setAvailableModels] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin;
|
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin;
|
||||||
const client = createRoomWsClient(
|
const client = createRoomWsClient(
|
||||||
@ -1095,6 +1117,73 @@ export function RoomProvider({
|
|||||||
[activeRoomId],
|
[activeRoomId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fetch project repos for @repository: mention suggestions
|
||||||
|
const fetchProjectRepos = useCallback(async () => {
|
||||||
|
if (!projectName) {
|
||||||
|
setProjectRepos([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setReposLoading(true);
|
||||||
|
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([]);
|
||||||
|
} finally {
|
||||||
|
setReposLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectName]);
|
||||||
|
|
||||||
|
// Fetch room AI configs for @ai: mention suggestions
|
||||||
|
const fetchRoomAiConfigs = useCallback(async () => {
|
||||||
|
const client = wsClientRef.current;
|
||||||
|
if (!activeRoomId || !client) {
|
||||||
|
setRoomAiConfigs([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAiConfigsLoading(true);
|
||||||
|
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([]);
|
||||||
|
} finally {
|
||||||
|
setAiConfigsLoading(false);
|
||||||
|
}
|
||||||
|
}, [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(
|
const createRoom = useCallback(
|
||||||
async (name: string, isPublic: boolean, categoryId?: string) => {
|
async (name: string, isPublic: boolean, categoryId?: string) => {
|
||||||
const client = wsClientRef.current;
|
const client = wsClientRef.current;
|
||||||
@ -1222,6 +1311,10 @@ export function RoomProvider({
|
|||||||
updateRoom,
|
updateRoom,
|
||||||
deleteRoom,
|
deleteRoom,
|
||||||
streamingMessages: streamingContent,
|
streamingMessages: streamingContent,
|
||||||
|
projectRepos,
|
||||||
|
reposLoading,
|
||||||
|
roomAiConfigs,
|
||||||
|
aiConfigsLoading,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
wsStatus,
|
wsStatus,
|
||||||
@ -1270,6 +1363,10 @@ export function RoomProvider({
|
|||||||
updateRoom,
|
updateRoom,
|
||||||
deleteRoom,
|
deleteRoom,
|
||||||
streamingContent,
|
streamingContent,
|
||||||
|
projectRepos,
|
||||||
|
reposLoading,
|
||||||
|
roomAiConfigs,
|
||||||
|
aiConfigsLoading,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
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>`;
|
||||||
|
}
|
||||||
9
src/lib/mention-refs.ts
Normal file
9
src/lib/mention-refs.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import type { MentionSuggestion } from '@/components/room/MentionPopover';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module-level refs shared between ChatInputArea and MentionPopover.
|
||||||
|
* Avoids stale closure / TDZ issues when components are defined in different
|
||||||
|
* parts of the same file.
|
||||||
|
*/
|
||||||
|
export const mentionSelectedIdxRef = { current: 0 };
|
||||||
|
export const mentionVisibleRef = { current: [] as MentionSuggestion[] };
|
||||||
Loading…
Reference in New Issue
Block a user