Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 31 additions & 15 deletions app/components/chat/ChatBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,27 @@ interface Message {
snapshot?: TimelineState | null;
}

const MENTION_TERMINATOR_PATTERN = /[\s,.;:!?()[\]{}"'`]/;

function hasMentionToken(value: string, itemName: string): boolean {
const normalizedValue = value.toLowerCase();
const mention = `@${itemName.toLowerCase()}`;
let index = normalizedValue.indexOf(mention);

while (index !== -1) {
const prevChar = index > 0 ? normalizedValue[index - 1] : undefined;
if (prevChar === undefined || /\s/.test(prevChar)) {
const nextChar = normalizedValue[index + mention.length];
if (nextChar === undefined || MENTION_TERMINATOR_PATTERN.test(nextChar)) {
return true;
}
}
index = normalizedValue.indexOf(mention, index + mention.length);
}

return false;
}
Comment on lines +85 to +102

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The hasMentionToken function currently matches any occurrence of @itemName regardless of what character precedes the @ symbol. This can lead to false positives, such as matching email addresses (e.g., user@domain.mp4 matching an asset named domain.mp4) or other random text containing @.

To prevent this, we should ensure that the character immediately preceding the @ symbol is either a whitespace character or that the @ is at the start of the string. This is also consistent with the mention trigger logic on line 419.

function hasMentionToken(value: string, itemName: string): boolean {
  const normalizedValue = value.toLowerCase();
  const mention = `@${itemName.toLowerCase()}`;
  let index = normalizedValue.indexOf(mention);

  while (index !== -1) {
    const prevChar = index > 0 ? normalizedValue[index - 1] : undefined;
    if (prevChar === undefined || /\s/.test(prevChar)) {
      const nextChar = normalizedValue[index + mention.length];
      if (nextChar === undefined || MENTION_TERMINATOR_PATTERN.test(nextChar)) {
        return true;
      }
    }
    index = normalizedValue.indexOf(mention, index + mention.length);
  }

  return false;
}


interface ChatBoxProps {
className?: string;
mediaBinItems: MediaBinItem[];
Expand Down Expand Up @@ -387,12 +408,9 @@ export function ChatBox({
textarea.style.height = newHeight + "px";
setTextareaHeight(newHeight);

// Clean up mentioned items that are no longer in the text
const mentionPattern = /@(\w+(?:\s+\w+)*)/g;
const currentMentions = Array.from(value.matchAll(mentionPattern)).map((match) => match[1]);
setMentionedItems((prev) =>
prev.filter((item) => currentMentions.some((mention) => mention.toLowerCase() === item.name.toLowerCase())),
);
// Clean up mentioned items that are no longer in the text.
// Media names often include punctuation like "clip-01.mp4", which \w-based parsing drops.
setMentionedItems((prev) => prev.filter((item) => hasMentionToken(value, item.name)));

// Check for @ mentions
const beforeCursor = value.slice(0, cursorPos);
Expand Down Expand Up @@ -804,7 +822,6 @@ export function ChatBox({
if (!target) throw new Error(`Clip not found: ${scrubber_id ?? scrubber_name}`);
handleDeleteScrubber(target.id);
aiResponseContent = `✅ Deleted "${target.name}".`;

} else if (fn === "LLMSetVolume") {
const parsed = SetVolumeArgsSchema.safeParse(args);
if (!parsed.success) throw new Error("Invalid arguments for LLMSetVolume");
Expand All @@ -816,8 +833,9 @@ export function ChatBox({
}
if (!target) throw new Error(`Clip not found: ${scrubber_id ?? scrubber_name}`);
handleUpdateScrubber({ ...target, volume: volume as number, muted: muted ?? false });
aiResponseContent = muted ? `✅ Muted "${target.name}".` : `✅ Set "${target.name}" volume to ${Math.round((volume as number) * 100)}%.`;

aiResponseContent = muted
? `✅ Muted "${target.name}".`
: `✅ Set "${target.name}" volume to ${Math.round((volume as number) * 100)}%.`;
} else if (fn === "LLMSetPlaybackSpeed") {
const parsed = SetPlaybackSpeedArgsSchema.safeParse(args);
if (!parsed.success) throw new Error("Invalid arguments for LLMSetPlaybackSpeed");
Expand All @@ -830,7 +848,6 @@ export function ChatBox({
if (!target) throw new Error(`Clip not found: ${scrubber_id ?? scrubber_name}`);
handleUpdateScrubber({ ...target, playbackRate: playback_rate as number });
aiResponseContent = `✅ Set "${target.name}" speed to ${playback_rate}×.`;

} else if (fn === "LLMSplitScrubber") {
if (!handleSplitScrubberAtRuler) throw new Error("Split handler unavailable");
const parsed = SplitScrubberArgsSchema.safeParse(args);
Expand All @@ -844,10 +861,10 @@ export function ChatBox({
if (!target) throw new Error(`Clip not found: ${scrubber_id ?? scrubber_name}`);
const rulerPx = (time_seconds as number) * pixelsPerSecond;
const count = handleSplitScrubberAtRuler(rulerPx, target.id);
aiResponseContent = count > 0
? `✅ Split "${target.name}" at ${time_seconds}s.`
: `❌ Could not split "${target.name}" — make sure ${time_seconds}s is within the clip.`;

aiResponseContent =
count > 0
? `✅ Split "${target.name}" at ${time_seconds}s.`
: `❌ Could not split "${target.name}" — make sure ${time_seconds}s is within the clip.`;
} else if (fn === "LLMCreateTrack") {
const parsed = CreateTrackArgsSchema.safeParse(args);
const n = parsed.success ? (parsed.data.count ?? 1) : 1;
Expand All @@ -857,7 +874,6 @@ export function ChatBox({
} else {
aiResponseContent = "❌ Cannot create track: handler unavailable.";
}

} else if (fn === "LLMSetResolution" || fn === "SetResolution") {
aiResponseContent = `ℹ️ Resolution change is not yet supported via chat.`;
} else {
Expand Down