-
Notifications
You must be signed in to change notification settings - Fork 827
feat(ui): add video clipboard paste support #1325
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,12 +2,13 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import asyncio | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import json | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import re | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import sys | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from functools import partial | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from pathlib import Path | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from kosong.chat_provider import ChatProviderError | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from kosong.message import Message | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from kosong.message import ContentPart, Message, TextPart | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from rich import print | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from kimi_cli.cli import InputFormat, OutputFormat | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -20,10 +21,95 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| run_soul, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from kimi_cli.soul.kimisoul import KimiSoul | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from kimi_cli.tools.file.utils import VIDEO_EXTENSIONS | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from kimi_cli.ui.print.visualize import visualize | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from kimi_cli.utils.logging import logger | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from kimi_cli.utils.signals import install_sigint_handler | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _extract_video_paths(text: str) -> list[tuple[int, int, Path]]: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Extract video file paths from text. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Returns list of (start, end, path) tuples for each video file found. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Only includes paths that actually exist as files. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Handles paths with spaces and special characters in filenames by trying | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| progressively longer paths from the extension backwards. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| results: list[tuple[int, int, Path]] = [] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| video_exts = "|".join(ext.lstrip(".") for ext in VIDEO_EXTENSIONS.keys()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Find all video extension occurrences (not using \b to avoid issues with [ or other chars) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Match extensions followed by space, punctuation, or end of string | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for match in re.finditer(rf"\.({video_exts})(?=\s|$|[.,;!?])", text, re.IGNORECASE): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ext_end = match.end() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Try progressively longer paths from the extension backwards | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Start from the beginning of the text and expand until we find a valid file | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| best_match: tuple[int, Path] | None = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Try each possible start position, preferring longer paths | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for start_candidate in range(0, ext_end): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Must start at word boundary or with @ or / | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if start_candidate > 0 and text[start_candidate - 1] not in " \t\n": | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| path_str = text[start_candidate:ext_end] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Remove @ prefix for validation | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| check_path_str = path_str[1:] if path_str.startswith("@") else path_str | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| path = Path(check_path_str) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Check if this is a valid video file | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if path.suffix.lower() in VIDEO_EXTENSIONS and path.is_file(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Found a valid file - update best match (preferring longer paths) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| best_match = (start_candidate, path) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+50
to
+64
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 The loop in Root CauseThe loop at for start_candidate in range(0, ext_end):
...
if path.suffix.lower() in VIDEO_EXTENSIONS and path.is_file():
best_match = (start_candidate, path) # overwrites previous longer matchEach valid match overwrites the previous one. Since For example, given the command Impact: When multiple substrings ending at the same video extension resolve to existing files, the wrong (shorter) path is selected and the original text is incorrectly sliced, losing part of the user's command text or referencing the wrong file.
Suggested change
Was this helpful? React with 👍 or 👎 to provide feedback. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if best_match is not None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| start_pos, path = best_match | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| results.append((start_pos, ext_end, path)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return results | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _build_content_parts(command: str) -> list[ContentPart]: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Build content parts from command, detecting video files. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Similar to the web UI, video files are wrapped in <video> tags | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| so the agent can use ReadMediaFile tool to read them. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| video_paths = _extract_video_paths(command) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not video_paths: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # No videos found, return simple text | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return [TextPart(text=command)] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| parts: list[ContentPart] = [] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| last_end: int = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for start, end, path in video_paths: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Add text before this video | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if start > last_end: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| text_before = command[last_end:start] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if text_before: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| parts.append(TextPart(text=text_before)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Add video reference | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| file_path = str(path) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Try to get mime type from extension | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| suffix = path.suffix.lower() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| mime_type = VIDEO_EXTENSIONS.get(suffix, "video/mp4") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| parts.append(TextPart(text=f'<video path="{file_path}" content_type="{mime_type}">')) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| parts.append(TextPart(text="</video>\n\n")) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| last_end = end | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Add any remaining text after the last video | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if last_end < len(command): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| text_after = command[last_end:] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if text_after: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| parts.append(TextPart(text=text_after)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return parts | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class Print: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -79,11 +165,15 @@ def _handler(): | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if command: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.info("Running agent with command: {command}", command=command) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Build content parts, detecting video files | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| content_parts = _build_content_parts(command) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if self.output_format == "text" and not self.final_only: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| print(command) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await run_soul( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.soul, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| command, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| content_parts, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| partial(visualize, self.output_format, self.final_only), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cancel_event, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.soul.wire_file if isinstance(self.soul, KimiSoul) else None, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_extract_video_pathsonly matches video extensions when the next character is whitespace, end-of-string, or[.,;!?], so quoted paths like"/tmp/my clip.mp4"or'/tmp/my clip.mp4'are never detected. This is a common way to include paths with spaces in--print -cinput, and in that case the command is sent as plain text without<video ...>tags, so automatic video attachment handling is skipped.Useful? React with 👍 / 👎.