diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml index 5d64a7701..83cc82527 100644 --- a/.github/ISSUE_TEMPLATE/feature.yml +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -27,14 +27,6 @@ body: - label: Bob (IT / power user) - label: Susan (non-technical) - label: Other team roles - - type: textarea - id: opencode_alignment - attributes: - label: OpenCode primitive alignment - description: Is there an existing OpenCode primitive or API that covers this? If not, why is a thin iPolloWork layer still needed? - placeholder: session.*, permission.*, skills/plugins, mcp... - validations: - required: true - type: textarea id: doc_alignment attributes: diff --git a/README.md b/README.md index fd4eac284..598f04a0a 100644 --- a/README.md +++ b/README.md @@ -4,22 +4,33 @@ English · 简体中文 · 繁體中文 · 日本語

-> **Build with agents. Edit everything.** +

+ + Watch the iPolloWork demo + +

+ +

+ ▶ Watch the iPolloWork demo +

+ +> **An AI workbench that builds, runs, and keeps results editable.** + +**The source-available, local-first alternative to Codex and Claude Code for work beyond the terminal.** + +iPolloWork gives agents one workspace for repositories, local files, browser tasks, documents, presentations, websites, design, and video. Describe the outcome; the agent plans and executes; you inspect the work, approve actions, and keep editing the result in the same place. -**The next-generation, source-available alternative to Codex and Claude Code — built not only for coding, but for getting real work finished.** +Codex-style coding is only the starting point. When the output is a deck, web page, visual design, or video, iPolloWork keeps it editable instead of handing you a finished file or a chat transcript. -iPolloWork turns AI agents into a complete visual workspace for code, office work, websites, presentations, design, and video. Describe the outcome, let the agent build it, then keep editing the result yourself: rewrite text in place, replace images, change colors and typography, move and resize elements, switch between desktop and mobile views, or refine video scenes on a timeline — as naturally as editing a slide in PowerPoint. +## What makes it different -This is not another chat wrapper. iPolloWork brings conversation, files, browser, editable canvas, design tools, video studio, task history, permissions, Skills, plugins, and MCP into one local-first desktop experience. +- **Agent-first execution** — plan work, use tools, read and modify files, run commands, and continue from the current state. +- **Editable results** — move from code to documents, websites, presentations, design, and video; keep changing text, images, layout, and scenes after generation. +- **Local control** — run on your machine, bring your own model or provider, approve permissions, and extend the workspace with Skills, plugins, MCP servers, and browser automation. -## One agent workspace. Every kind of work. +## The short version -- **Code** — understand repositories, plan changes, write code, run tools, and review the result with a full agent workflow. -- **Office** — research, draft documents, work with spreadsheets, and turn ideas into polished presentations instead of stopping at a text response. -- **Design** — generate websites, slides, and visual assets, then directly edit text, images, colors, typography, layout, and responsive states on the canvas. -- **Video** — create and refine visual scenes in an integrated studio with editable content and timeline controls. -- **Extensible agents** — connect any model and expand the workspace with Skills, plugins, MCP servers, and browser automation. -- **Local-first by default** — work directly with your files on macOS, Windows, or Linux; use the desktop app, browser UI, or headless server, and connect iPolloCloud only when you need team and commercial capabilities. +iPolloWork is for people who want a Codex-like agent with a visual, local-first workspace around it. It is not a chat-only wrapper and it does not require iPolloCloud for local use. This source-available repository contains the Work client and its local runtime integration. Accounts, organization administration, hosted worker management, payments, and mobile Apps are separate iPolloCloud capabilities and are not required for local use. diff --git a/apps/app/index.html b/apps/app/index.html index 8903c839f..b1a89f93e 100644 --- a/apps/app/index.html +++ b/apps/app/index.html @@ -23,8 +23,39 @@ } })(); + +
+ +
diff --git a/apps/app/package.json b/apps/app/package.json index 420bf99e3..81bb9e5c1 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -1,7 +1,7 @@ { "name": "@ipollowork/app", "private": true, - "version": "0.17.26", + "version": "0.17.30", "type": "module", "scripts": { "test": "bun test --isolate tests/", diff --git a/apps/app/public/ipollowork-app-loading-v3.gif b/apps/app/public/ipollowork-app-loading-v3.gif new file mode 100644 index 000000000..b3993bcdd Binary files /dev/null and b/apps/app/public/ipollowork-app-loading-v3.gif differ diff --git a/apps/app/public/ipollowork-logo-square.svg b/apps/app/public/ipollowork-logo-square.svg index e008353a0..a90d666e3 100644 --- a/apps/app/public/ipollowork-logo-square.svg +++ b/apps/app/public/ipollowork-logo-square.svg @@ -1,7 +1,5 @@ - - iPollo logo - - - - + + + + diff --git a/apps/app/public/ipollowork-logo.svg b/apps/app/public/ipollowork-logo.svg index e008353a0..a90d666e3 100644 --- a/apps/app/public/ipollowork-logo.svg +++ b/apps/app/public/ipollowork-logo.svg @@ -1,7 +1,5 @@ - - iPollo logo - - - - + + + + diff --git a/apps/app/public/ipollowork-mark.svg b/apps/app/public/ipollowork-mark.svg index e008353a0..a90d666e3 100644 --- a/apps/app/public/ipollowork-mark.svg +++ b/apps/app/public/ipollowork-mark.svg @@ -1,7 +1,5 @@ - - iPollo logo - - - - + + + + diff --git a/apps/app/public/ipollowork-thinking-logo-v2.gif b/apps/app/public/ipollowork-thinking-logo-v2.gif new file mode 100644 index 000000000..2baeb0c49 Binary files /dev/null and b/apps/app/public/ipollowork-thinking-logo-v2.gif differ diff --git a/apps/app/src/app/index.css b/apps/app/src/app/index.css index 4b5e14807..15438845a 100644 --- a/apps/app/src/app/index.css +++ b/apps/app/src/app/index.css @@ -503,11 +503,77 @@ select:disabled { background-color: rgba(var(--dls-secondary-rgb, 120, 120, 120), 0.22); } +@keyframes ipollowork-loading-label { + 0%, + 100% { + opacity: 0.62; + } + 50% { + opacity: 1; + } +} + +@keyframes ipollowork-loading-dots { + 0%, + 19% { + content: ""; + } + 20%, + 39% { + content: "."; + } + 40%, + 59% { + content: ".."; + } + 60%, + 100% { + content: "..."; + } +} + +.ipollowork-loading-logo-shell { + display: grid; + width: 66px; + height: 58px; + place-items: center; +} + +.ipollowork-loading-logo { + width: 66px; + height: 58px; + object-fit: contain; +} + +.ipollowork-loading-label { + animation: ipollowork-loading-label 1.8s ease-in-out infinite; +} + +.ipollowork-loading-dots { + display: inline-block; + width: 1.2em; + text-align: left; +} + +.ipollowork-loading-dots::after { + content: ""; + animation: ipollowork-loading-dots 1.2s steps(1, end) infinite; +} + @media (prefers-reduced-motion: reduce) { - .ow-dot-ticker { + .ow-dot-ticker, + .ipollowork-loading-label, + .ipollowork-loading-dots::after { animation: none; + } + + .ow-dot-ticker { background-color: rgba(var(--dls-accent-rgb), 0.6); } + + .ipollowork-loading-dots::after { + content: "..."; + } } .dark, diff --git a/apps/app/src/app/lib/ipollowork-server.ts b/apps/app/src/app/lib/ipollowork-server.ts index f8c486e4d..0d9cffa04 100644 --- a/apps/app/src/app/lib/ipollowork-server.ts +++ b/apps/app/src/app/lib/ipollowork-server.ts @@ -524,6 +524,7 @@ export type iPolloWorkUserEnvItem = { export type iPolloWorkAuthorizationServiceId = | "openai-images" | "aliyun-bailian" + | "baidu-speech" | "volcengine-video" | "aliyun-oss" | "wasabi" diff --git a/apps/app/src/components/chat/artifact.tsx b/apps/app/src/components/chat/artifact.tsx index 3dc491bbf..e85d50012 100644 --- a/apps/app/src/components/chat/artifact.tsx +++ b/apps/app/src/components/chat/artifact.tsx @@ -22,14 +22,16 @@ import { canOpenArtifact, canPreviewArtifact, groupConversationOutputArtifacts, + isDesignArtifact, isConversationOutputArtifact, - isVideoHtmlArtifact, + isVideoStudioArtifact, useArtifacts, usePreviewArtifact, } from "@/lib/artifacts"; interface ArtifactButtonProps { artifact: ArtifactItem + onOpenDesignStudio?: () => void onOpenVideoStudio?: () => void compact?: boolean } @@ -42,13 +44,13 @@ function compactArtifactTitle(name: string) { : name; } -function ArtifactButton({ artifact, onOpenVideoStudio, compact = false }: ArtifactButtonProps) { +function ArtifactButton({ artifact, onOpenDesignStudio, onOpenVideoStudio, compact = false }: ArtifactButtonProps) { const previewArtifact = usePreviewArtifact(); - const isVideoHtml = isVideoHtmlArtifact(artifact); + const canOpenDesignStudio = isDesignArtifact(artifact) && Boolean(onOpenDesignStudio); + const canOpenVideoStudio = isVideoStudioArtifact(artifact) && Boolean(onOpenVideoStudio); const canOpen = canOpenArtifact(artifact); const canPreview = canPreviewArtifact(artifact); - const canOpenVideoStudio = isVideoHtml && Boolean(onOpenVideoStudio); - const canActivate = canOpen || canOpenVideoStudio; + const canActivate = canOpen || canOpenDesignStudio || canOpenVideoStudio; const title = compactArtifactTitle(artifact.name); const content = ( @@ -58,9 +60,13 @@ function ArtifactButton({ artifact, onOpenVideoStudio, compact = false }: Artifa {title} - {(!compact || canOpenVideoStudio) && canActivate ? ( + {(!compact || canOpenDesignStudio || canOpenVideoStudio) && canActivate ? ( - {canOpenVideoStudio ? t("session.outputs.action_video_preview_edit") : t("session.outputs.action_browse_edit")} + {canOpenDesignStudio + ? t("session.outputs.action_design_studio") + : canOpenVideoStudio + ? t("session.outputs.action_video_studio") + : t("session.outputs.action_browse_edit")} ) : null} @@ -84,13 +90,23 @@ function ArtifactButton({ artifact, onOpenVideoStudio, compact = false }: Artifa { + if (canOpenDesignStudio) { + onOpenDesignStudio?.(); + return; + } if (canOpenVideoStudio) { onOpenVideoStudio?.(); return; } previewArtifact(artifact); }} - title={canOpenVideoStudio ? t("session.outputs.open_video_studio") : canPreview ? `Preview ${artifact.name}` : `Open ${artifact.name}`} + title={canOpenDesignStudio + ? t("session.outputs.open_design_studio") + : canOpenVideoStudio + ? t("session.outputs.open_video_studio") + : canPreview + ? `Preview ${artifact.name}` + : `Open ${artifact.name}`} > {content} @@ -99,22 +115,23 @@ function ArtifactButton({ artifact, onOpenVideoStudio, compact = false }: Artifa interface OutputGroupRowProps { group: ReturnType[number] + onOpenDesignStudio?: () => void onOpenVideoStudio?: () => void } -function OutputGroupRow({ group, onOpenVideoStudio }: OutputGroupRowProps) { +function OutputGroupRow({ group, onOpenDesignStudio, onOpenVideoStudio }: OutputGroupRowProps) { const [expanded, setExpanded] = useState(false); const childArtifacts = group.artifacts.filter((artifact) => artifact.id !== group.primary.id); if (!group.bundled || childArtifacts.length === 0) { - return ; + return ; } return (
- +
) : null} @@ -144,10 +161,11 @@ function OutputGroupRow({ group, onOpenVideoStudio }: OutputGroupRowProps) { interface ArtifactListProps { messages: UIMessage[] includeTargetFallbacks?: boolean + onOpenDesignStudio?: () => void onOpenVideoStudio?: () => void } -export function ArtifactList({ messages, includeTargetFallbacks = false, onOpenVideoStudio }: ArtifactListProps) { +export function ArtifactList({ messages, includeTargetFallbacks = false, onOpenDesignStudio, onOpenVideoStudio }: ArtifactListProps) { const artifacts = useArtifacts(messages, { includeTargetFallbacks }); if (artifacts.length === 0) { @@ -158,7 +176,7 @@ export function ArtifactList({ messages, includeTargetFallbacks = false, onOpenV
{artifacts.map((artifact) => ( - + ))}
@@ -169,10 +187,11 @@ interface ConversationOutputPanelProps { messages: UIMessage[] openTargets?: OpenTarget[] onOpenTarget?: (target: OpenTarget, options?: OpenTargetOptions) => void + onOpenDesignStudio?: () => void onOpenVideoStudio?: () => void } -function ConversationOutputPanelContent({ messages, onOpenVideoStudio }: Omit) { +function ConversationOutputPanelContent({ messages, onOpenDesignStudio, onOpenVideoStudio }: Omit) { const artifacts = useArtifacts(messages, { includeTargetFallbacks: false }); const outputs = artifacts.filter(isConversationOutputArtifact); const outputGroups = groupConversationOutputArtifacts(outputs); @@ -188,7 +207,7 @@ function ConversationOutputPanelContent({ messages, onOpenVideoStudio }: Omit {outputGroups.map((group) => ( - + ))}
) : ( @@ -217,12 +236,12 @@ export function ConversationOutputTrigger({ active, disabled, onClick }: { activ } /** Right-side conversation output surface. It looks like a floating card but never covers chat content. */ -export function ConversationOutputPanel({ messages, openTargets = [], onOpenTarget, onOpenVideoStudio }: ConversationOutputPanelProps) { +export function ConversationOutputPanel({ messages, openTargets = [], onOpenTarget, onOpenDesignStudio, onOpenVideoStudio }: ConversationOutputPanelProps) { return (
- +
diff --git a/apps/app/src/components/chat/message-list.tsx b/apps/app/src/components/chat/message-list.tsx index b1279fc54..e804b8174 100644 --- a/apps/app/src/components/chat/message-list.tsx +++ b/apps/app/src/components/chat/message-list.tsx @@ -11,7 +11,6 @@ import { Split, Undo2, } from "lucide-react" -import { PaperGrainGradient } from "@ipollowork/ui/react" import { DynamicToolUIPart, isFileUIPart, @@ -21,6 +20,7 @@ import { } from "ai" import type { SessionStatus } from "@opencode-ai/sdk/v2/client" import { openDesktopUrl } from "@/app/lib/desktop" +import { publicAssetUrl } from "@/app/lib/public-asset" import { SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX } from "@/app/types" import { ApplyPatchTool } from "@/components/tools/apply-patch" import { BashTool } from "@/components/tools/bash" @@ -306,10 +306,12 @@ type AssistantMessageProps = { isLastMessage: boolean isStreaming: boolean isLastStep: boolean + onOpenDesignStudio?: () => void + onOpenVideoStudio?: () => void } const AssistantMessage = React.memo( - ({ message }: AssistantMessageProps) => { + ({ message, onOpenDesignStudio, onOpenVideoStudio }: AssistantMessageProps) => { const { showThinking, highlightQuery } = useMessageList() const assistantRenderGroups = React.useMemo( () => getAssistantRenderGroups(message.parts, showThinking), @@ -363,7 +365,7 @@ const AssistantMessage = React.memo( ) })} - + ) @@ -540,10 +542,12 @@ type MessageComponentProps = { isLastMessage: boolean isStreaming: boolean isLastStep: boolean + onOpenDesignStudio?: () => void + onOpenVideoStudio?: () => void } const MessageComponent = React.memo( - ({ message, isLastMessage, isStreaming, isLastStep }: MessageComponentProps) => { + ({ message, isLastMessage, isStreaming, isLastStep, onOpenDesignStudio, onOpenVideoStudio }: MessageComponentProps) => { if (isSessionErrorMessage(message)) { return } @@ -564,6 +568,8 @@ const MessageComponent = React.memo( isLastMessage={isLastMessage} isStreaming={isStreaming} isLastStep={isLastStep} + onOpenDesignStudio={onOpenDesignStudio} + onOpenVideoStudio={onOpenVideoStudio} /> ) } @@ -582,19 +588,13 @@ MessageComponent.displayName = "MessageComponent" const LoadingMessage = React.memo(({ label }: { label?: string }) => (
-
-
- -
+
+ {label ?? "Thinking…"}
@@ -700,12 +700,16 @@ interface AssistantMessageGroupProps { items: UIMessageWithIndex[] messages: UIMessage[] isStreaming: boolean + onOpenDesignStudio?: () => void + onOpenVideoStudio?: () => void } function MessageGroup({ items, messages, isStreaming, + onOpenDesignStudio, + onOpenVideoStudio, }: AssistantMessageGroupProps) { const { onRevertToUserMessage, onForkAtMessage } = useMessageList() const lastItem = items[items.length - 1] @@ -755,6 +759,8 @@ function MessageGroup({ isLastMessage={isLastMessage} isStreaming={isLastMessage && isStreaming} isLastStep={groupIndex === items.length - 1} + onOpenDesignStudio={onOpenDesignStudio} + onOpenVideoStudio={onOpenVideoStudio} />
) @@ -810,9 +816,11 @@ interface MessageListProps { messages: UIMessage[] status: ThreadStatus retryStatus?: RetryStatus | null + onOpenDesignStudio?: () => void + onOpenVideoStudio?: () => void } -export function MessageList({ messages, status, retryStatus }: MessageListProps) { +export function MessageList({ messages, status, retryStatus, onOpenDesignStudio, onOpenVideoStudio }: MessageListProps) { const isStreaming = status === "streaming" || status === "retrying" const items = React.useMemo(() => groupMessages(messages, status), [messages, status]); const error = useSessionErrorMessage(); @@ -831,6 +839,8 @@ export function MessageList({ messages, status, retryStatus }: MessageListProps) items={item.messages} messages={messages} isStreaming={isStreaming} + onOpenDesignStudio={onOpenDesignStudio} + onOpenVideoStudio={onOpenVideoStudio} /> ) } diff --git a/apps/app/src/components/ui/sonner.tsx b/apps/app/src/components/ui/sonner.tsx index 5a25d61d6..30033ce17 100644 --- a/apps/app/src/components/ui/sonner.tsx +++ b/apps/app/src/components/ui/sonner.tsx @@ -146,6 +146,13 @@ function ToastCard({ id, type, title, description, action, cancel, notification sonnerToast.dismiss(id) } + const stopToastPointerGesture = (event: React.PointerEvent) => { + // Sonner listens for pointer gestures on the outer toast to enable swipe + // dismissal. A click on the X icon bubbles through its SVG target first, + // so stop the gesture before it can compete with the close action. + event.stopPropagation() + } + if (notification) { return (
@@ -153,7 +160,7 @@ function ToastCard({ id, type, title, description, action, cancel, notification

{title}

-
@@ -169,7 +176,7 @@ function ToastCard({ id, type, title, description, action, cancel, notification

{title}

-
diff --git a/apps/app/src/i18n/locales/ca.ts b/apps/app/src/i18n/locales/ca.ts index efaa4de60..ae0915b94 100644 --- a/apps/app/src/i18n/locales/ca.ts +++ b/apps/app/src/i18n/locales/ca.ts @@ -109,13 +109,14 @@ export default { "common.submit": "Envia", "common.unknown": "Desconegut", "composer.agent_label": "Agent", - "composer.any_file_type_supported": "S'admet qualsevol tipus de fitxer.", + "composer.any_file_type_supported": "S'admeten documents, fulls de càlcul, presentacions, PDF, text, codi i imatges habituals.", "composer.attach_files": "Adjuntar fitxers", "composer.attachments_unavailable": "Els fitxers adjunts no estan disponibles.", "composer.behavior_label": "Comportament", "composer.configure": "Configura", "composer.default_agent": "Agent per defecte", - "composer.file_exceeds_limit": "{name} supera el límit de 8 MB.", + "composer.file_exceeds_limit": "{name} supera el límit de 25 MB.", + "composer.files_exceed_limit": "{count} fitxers superen el límit de 25 MB.", "composer.file_kind": "Fitxer", "composer.image_kind": "Imatge", "composer.inserted_links_unsupported": "S'han inserit enllaços per a fitxers no compatibles.", diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts index 2a6c08152..43da4357e 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -376,13 +376,14 @@ export default { "composer.app_kind": "App · Computer Use", "composer.computer_use_permissions_missing": "Computer Use needs macOS permissions before it can control {app}.", "composer.computer_use_permissions_setup": "Set up", - "composer.any_file_type_supported": "Any file type is supported.", + "composer.any_file_type_supported": "Common documents, spreadsheets, presentations, PDFs, text, code, and images are supported.", "composer.attach_files": "Attach files", "composer.attachments_unavailable": "Attachments are unavailable.", "composer.behavior_label": "Behavior", "composer.configure": "Configure", "composer.default_agent": "Default agent", - "composer.file_exceeds_limit": "{name} exceeds the 8MB limit.", + "composer.file_exceeds_limit": "{name} exceeds the 25MB limit.", + "composer.files_exceed_limit": "{count} files exceed the 25MB limit.", "composer.file_kind": "File", "composer.image_kind": "Image", "composer.inserted_links_unsupported": "Inserted links for unsupported files.", @@ -675,9 +676,12 @@ export default { "den.removed_provider": "Removed {name}.", "den.removing": "Removing...", "den.sign_out": "Sign out", - "den.signed_out": "Signed out", - "den.signin_button": "Sign in", - "den.signin_code_note": "Accepts an ipollowork://den-auth link or the raw one-time grant.", + "den.signed_out": "Signed out", + "den.signin_button": "Sign in", + "den.signin_coming_soon_action": "Continue locally", + "den.signin_coming_soon_description": "Cloud sign-in is being prepared. You can keep using iPolloWork locally today.", + "den.signin_coming_soon_title": "Sign-in is coming soon", + "den.signin_code_note": "Accepts an ipollowork://den-auth link or the raw one-time grant.", "den.signin_link_copied": "Copied", "den.signin_link_hint": "If your browser doesn't bounce back into iPolloWork automatically, paste the sign-in link or one-time code from iPolloWork Cloud here.", "den.signin_link_label": "Sign-in link or one-time code", @@ -1122,12 +1126,18 @@ export default { "session.outputs.empty": "No files yet", "session.outputs.empty_hint": "Files created in this conversation will appear here.", "session.outputs.action_browse_edit": "Browse / edit", + "session.outputs.action_design_studio": "Edit in Design", "session.outputs.action_video_studio": "Edit in Video Studio", "session.outputs.action_video_preview_edit": "Preview / edit", "session.outputs.open_video_studio": "Open Video Studio", + "session.outputs.open_design_studio": "Open Design", "session.outputs.bundle_count": "{count} files", "session.outputs.expand_bundle": "Show included files", "session.outputs.collapse_bundle": "Hide included files", + "design.export_actions": "Export options", + "design.download_pptx": "Download PPTX", + "design.download_pdf": "Download PDF", + "design.publish_web": "Publish as webpage", "session.side_panel.web": "Web", "session.side_panel.code": "Code", "session.side_panel.files": "Files", diff --git a/apps/app/src/i18n/locales/es.ts b/apps/app/src/i18n/locales/es.ts index 2785e2b59..1b157e340 100644 --- a/apps/app/src/i18n/locales/es.ts +++ b/apps/app/src/i18n/locales/es.ts @@ -109,13 +109,14 @@ export default { "common.submit": "Enviar", "common.unknown": "Desconocido", "composer.agent_label": "Agente", - "composer.any_file_type_supported": "Se admite cualquier tipo de archivo.", + "composer.any_file_type_supported": "Se admiten documentos, hojas de cálculo, presentaciones, PDF, texto, código e imágenes comunes.", "composer.attach_files": "Adjuntar archivos", "composer.attachments_unavailable": "Los archivos adjuntos no están disponibles.", "composer.behavior_label": "Comportamiento", "composer.configure": "Configurar", "composer.default_agent": "Agente predeterminado", - "composer.file_exceeds_limit": "{name} supera el límite de 8 MB.", + "composer.file_exceeds_limit": "{name} supera el límite de 25 MB.", + "composer.files_exceed_limit": "{count} archivos superan el límite de 25 MB.", "composer.file_kind": "Archivo", "composer.image_kind": "Imagen", "composer.inserted_links_unsupported": "Enlaces insertados para archivos no compatibles.", diff --git a/apps/app/src/i18n/locales/fr.ts b/apps/app/src/i18n/locales/fr.ts index 9f4339699..f6a654e20 100644 --- a/apps/app/src/i18n/locales/fr.ts +++ b/apps/app/src/i18n/locales/fr.ts @@ -109,13 +109,14 @@ export default { "common.submit": "Envoyer", "common.unknown": "Inconnu", "composer.agent_label": "Agent", - "composer.any_file_type_supported": "Tous les types de fichiers sont pris en charge.", + "composer.any_file_type_supported": "Les documents, feuilles de calcul, présentations, PDF, textes, codes et images courants sont pris en charge.", "composer.attach_files": "Joindre des fichiers", "composer.attachments_unavailable": "Les pièces jointes ne sont pas disponibles.", "composer.behavior_label": "Comportement", "composer.configure": "Configurer", "composer.default_agent": "Agent par défaut", - "composer.file_exceeds_limit": "{name} dépasse la limite de 8 Mo.", + "composer.file_exceeds_limit": "{name} dépasse la limite de 25 Mo.", + "composer.files_exceed_limit": "{count} fichiers dépassent la limite de 25 Mo.", "composer.file_kind": "Fichier", "composer.image_kind": "Image", "composer.inserted_links_unsupported": "Liens insérés pour des fichiers non pris en charge.", diff --git a/apps/app/src/i18n/locales/ja.ts b/apps/app/src/i18n/locales/ja.ts index 1d4921f8c..b43bfb0c4 100644 --- a/apps/app/src/i18n/locales/ja.ts +++ b/apps/app/src/i18n/locales/ja.ts @@ -108,13 +108,14 @@ export default { "common.submit": "送信", "common.unknown": "不明", "composer.agent_label": "エージェント", - "composer.any_file_type_supported": "すべてのファイル形式に対応しています。", + "composer.any_file_type_supported": "一般的な文書、表計算、プレゼンテーション、PDF、テキスト、コード、画像に対応しています。", "composer.attach_files": "ファイルを添付", "composer.attachments_unavailable": "添付ファイルは利用できません。", "composer.behavior_label": "動作", "composer.configure": "設定", "composer.default_agent": "デフォルトエージェント", - "composer.file_exceeds_limit": "{name}は8MBの制限を超えています。", + "composer.file_exceeds_limit": "{name}は25MBの制限を超えています。", + "composer.files_exceed_limit": "{count}個のファイルが25MBの制限を超えています。", "composer.file_kind": "ファイル", "composer.image_kind": "画像", "composer.inserted_links_unsupported": "未対応ファイルのリンクを挿入しました。", diff --git a/apps/app/src/i18n/locales/pt-BR.ts b/apps/app/src/i18n/locales/pt-BR.ts index 27190d444..a478e4402 100644 --- a/apps/app/src/i18n/locales/pt-BR.ts +++ b/apps/app/src/i18n/locales/pt-BR.ts @@ -109,13 +109,14 @@ export default { "common.submit": "Enviar", "common.unknown": "Desconhecido", "composer.agent_label": "Agente", - "composer.any_file_type_supported": "Qualquer tipo de arquivo é aceito.", + "composer.any_file_type_supported": "Compatível com documentos, planilhas, apresentações, PDFs, textos, códigos e imagens comuns.", "composer.attach_files": "Anexar arquivos", "composer.attachments_unavailable": "Anexos não estão disponíveis.", "composer.behavior_label": "Comportamento", "composer.configure": "Configurar", "composer.default_agent": "Agente padrão", - "composer.file_exceeds_limit": "{name} excede o limite de 8MB.", + "composer.file_exceeds_limit": "{name} excede o limite de 25MB.", + "composer.files_exceed_limit": "{count} arquivos excedem o limite de 25MB.", "composer.file_kind": "Arquivo", "composer.image_kind": "Imagem", "composer.inserted_links_unsupported": "Links inseridos para arquivos não suportados.", diff --git a/apps/app/src/i18n/locales/ru.ts b/apps/app/src/i18n/locales/ru.ts index 75746a5ff..32bc87a4f 100644 --- a/apps/app/src/i18n/locales/ru.ts +++ b/apps/app/src/i18n/locales/ru.ts @@ -110,13 +110,14 @@ export default { "common.submit": "Отправить", "common.unknown": "Неизвестно", "composer.agent_label": "Агент", - "composer.any_file_type_supported": "Поддерживаются файлы любого типа.", + "composer.any_file_type_supported": "Поддерживаются распространённые документы, таблицы, презентации, PDF, текст, код и изображения.", "composer.attach_files": "Прикрепить файлы", "composer.attachments_unavailable": "Вложения недоступны.", "composer.behavior_label": "Поведение", "composer.configure": "Настроить", "composer.default_agent": "Агент по умолчанию", - "composer.file_exceeds_limit": "{name} превышает лимит 8 МБ.", + "composer.file_exceeds_limit": "{name} превышает лимит 25 МБ.", + "composer.files_exceed_limit": "{count} файлов превышают лимит 25 МБ.", "composer.file_kind": "Файл", "composer.image_kind": "Изображение", "composer.inserted_links_unsupported": "Вставлены ссылки для неподдерживаемых файлов.", diff --git a/apps/app/src/i18n/locales/th.ts b/apps/app/src/i18n/locales/th.ts index 7c3a625aa..7e98646d2 100644 --- a/apps/app/src/i18n/locales/th.ts +++ b/apps/app/src/i18n/locales/th.ts @@ -109,13 +109,14 @@ export default { "common.submit": "ส่ง", "common.unknown": "ไม่ทราบ", "composer.agent_label": "Agent", - "composer.any_file_type_supported": "รองรับไฟล์ทุกประเภท", + "composer.any_file_type_supported": "รองรับเอกสาร สเปรดชีต งานนำเสนอ PDF ข้อความ โค้ด และรูปภาพทั่วไป", "composer.attach_files": "แนบไฟล์", "composer.attachments_unavailable": "ไฟล์แนบไม่พร้อมใช้งาน", "composer.behavior_label": "พฤติกรรม", "composer.configure": "ตั้งค่า", "composer.default_agent": "Agent เริ่มต้น", - "composer.file_exceeds_limit": "{name} เกินขีดจำกัด 8MB", + "composer.file_exceeds_limit": "{name} เกินขีดจำกัด 25MB", + "composer.files_exceed_limit": "{count} ไฟล์เกินขีดจำกัด 25MB", "composer.file_kind": "ไฟล์", "composer.image_kind": "รูปภาพ", "composer.inserted_links_unsupported": "แทรกลิงก์สำหรับไฟล์ที่ไม่รองรับ", diff --git a/apps/app/src/i18n/locales/vi.ts b/apps/app/src/i18n/locales/vi.ts index 8193c6c9e..a45c99456 100644 --- a/apps/app/src/i18n/locales/vi.ts +++ b/apps/app/src/i18n/locales/vi.ts @@ -109,13 +109,14 @@ export default { "common.submit": "Gửi", "common.unknown": "Không rõ", "composer.agent_label": "Agent", - "composer.any_file_type_supported": "Hỗ trợ mọi loại tệp.", + "composer.any_file_type_supported": "Hỗ trợ tài liệu, bảng tính, bản trình bày, PDF, văn bản, mã nguồn và hình ảnh phổ biến.", "composer.attach_files": "Đính kèm tệp", "composer.attachments_unavailable": "Đính kèm không khả dụng.", "composer.behavior_label": "Hành vi", "composer.configure": "Cấu hình", "composer.default_agent": "Agent mặc định", - "composer.file_exceeds_limit": "{name} vượt quá giới hạn 8MB.", + "composer.file_exceeds_limit": "{name} vượt quá giới hạn 25MB.", + "composer.files_exceed_limit": "{count} tệp vượt quá giới hạn 25MB.", "composer.file_kind": "Tệp", "composer.image_kind": "Hình ảnh", "composer.inserted_links_unsupported": "Đã chèn liên kết cho các tệp không được hỗ trợ.", diff --git a/apps/app/src/i18n/locales/zh.ts b/apps/app/src/i18n/locales/zh.ts index 8ab8ce5d4..fbc40ed5a 100644 --- a/apps/app/src/i18n/locales/zh.ts +++ b/apps/app/src/i18n/locales/zh.ts @@ -370,13 +370,14 @@ export default { "common.submit": "提交", "common.unknown": "未知", "composer.agent_label": "智能体", - "composer.any_file_type_supported": "支持任何文件类型。", + "composer.any_file_type_supported": "支持常见文档、表格、演示文稿、PDF、文本、代码和图片。", "composer.attach_files": "附加文件", "composer.attachments_unavailable": "附件功能不可用。", "composer.behavior_label": "行为", "composer.configure": "配置", "composer.default_agent": "默认智能体", - "composer.file_exceeds_limit": "{name}超过8MB限制。", + "composer.file_exceeds_limit": "{name}超过25MB限制。", + "composer.files_exceed_limit": "{count}个文件超过25MB限制。", "composer.file_kind": "文件", "composer.image_kind": "图片", "composer.inserted_links_unsupported": "已为不支持的文件插入链接。", @@ -573,9 +574,12 @@ export default { "den.removed_provider": "已移除{name}。", "den.removing": "移除中…", "den.sign_out": "退出登录", - "den.signed_out": "已退出", - "den.signin_button": "登录", - "den.signin_code_note": "支持ipollowork://den-auth链接或一次性授权码。", + "den.signed_out": "已退出", + "den.signin_button": "登录", + "den.signin_coming_soon_action": "继续本地使用", + "den.signin_coming_soon_description": "Cloud 登录服务正在准备中。现在你可以继续在本地使用 iPolloWork。", + "den.signin_coming_soon_title": "登录即将开放", + "den.signin_code_note": "支持ipollowork://den-auth链接或一次性授权码。", "den.signin_link_hint": "如果浏览器未自动跳回iPolloWork,请在此粘贴iPolloWork Cloud的登录链接或一次性登录码。", "den.signin_link_label": "登录链接或一次性登录码", "den.signin_link_placeholder": "ipollowork://den-auth?...或粘贴登录码", @@ -891,12 +895,18 @@ export default { "session.outputs.empty": "暂无文件", "session.outputs.empty_hint": "本次对话生成的文件会显示在这里。", "session.outputs.action_browse_edit": "可浏览 / 编辑", + "session.outputs.action_design_studio": "在设计工作台编辑", "session.outputs.action_video_studio": "可在视频工作台编辑", "session.outputs.action_video_preview_edit": "预览 / 编辑", "session.outputs.open_video_studio": "打开视频工作台", + "session.outputs.open_design_studio": "打开设计工作台", "session.outputs.bundle_count": "包含 {count} 个文件", "session.outputs.expand_bundle": "展开包含文件", "session.outputs.collapse_bundle": "收起包含文件", + "design.export_actions": "导出选项", + "design.download_pptx": "下载 PPT", + "design.download_pdf": "下载 PDF", + "design.publish_web": "发布为网页", "session.new_task": "新建对话", "session.open": "打开", "session.palette_hint_navigate": "方向键导航", diff --git a/apps/app/src/index.react.tsx b/apps/app/src/index.react.tsx index 473bc0a98..73fda75fc 100644 --- a/apps/app/src/index.react.tsx +++ b/apps/app/src/index.react.tsx @@ -61,3 +61,10 @@ ReactDOM.createRoot(root).render( , ); + +window.requestAnimationFrame(() => { + const splash = document.getElementById("ipollowork-startup-splash"); + if (!splash) return; + splash.dataset.hiding = "true"; + window.setTimeout(() => splash.remove(), 180); +}); diff --git a/apps/app/src/lib/artifacts.ts b/apps/app/src/lib/artifacts.ts index 5dbf0285c..6c5fe72ed 100644 --- a/apps/app/src/lib/artifacts.ts +++ b/apps/app/src/lib/artifacts.ts @@ -166,9 +166,19 @@ export function isConversationOutputArtifact(artifact: ArtifactItem) { return true; } -/** HTML compositions under the video workspace open the session's Video Studio. */ +/** Design workspace outputs open the session's Design surface. */ +export function isDesignArtifact(artifact: ArtifactItem) { + return artifact.type !== "unknown" && /(?:^|\/)design(?:\/|$)/i.test(artifact.path); +} + +/** Video workspace outputs open the session's Video Studio. */ +export function isVideoStudioArtifact(artifact: ArtifactItem) { + return ["html", "video"].includes(artifact.type) && /(?:^|\/)video(?:\/|$)/i.test(artifact.path); +} + +/** HTML compositions under the video workspace can auto-open Video Studio. */ export function isVideoHtmlArtifact(artifact: ArtifactItem) { - return artifact.type === "html" && /(?:^|\/)video(?:\/|$)/i.test(artifact.path); + return artifact.type === "html" && isVideoStudioArtifact(artifact); } const BUNDLE_PRIMARY_TYPES = new Set(["website", "html", "video", "slides", "document", "pdf"]); diff --git a/apps/app/src/react-app/domains/cloud/cloud-signin-coming-soon-dialog.tsx b/apps/app/src/react-app/domains/cloud/cloud-signin-coming-soon-dialog.tsx new file mode 100644 index 000000000..4871a41e8 --- /dev/null +++ b/apps/app/src/react-app/domains/cloud/cloud-signin-coming-soon-dialog.tsx @@ -0,0 +1,56 @@ +/** @jsxImportSource react */ +import { Sparkles } from "lucide-react"; + +import { t } from "../../../i18n"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +type CloudSignInComingSoonDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export function CloudSignInComingSoonDialog({ open, onOpenChange }: CloudSignInComingSoonDialogProps) { + return ( + + +
+
+
+ +
+
+ + + + iPolloWork Cloud +
+ + + + {t("den.signin_coming_soon_title")} + + + {t("den.signin_coming_soon_description")} + + + + + }> + {t("den.signin_coming_soon_action")} + + +
+
+ +
+ ); +} diff --git a/apps/app/src/react-app/domains/onboarding/first-run-loader.tsx b/apps/app/src/react-app/domains/onboarding/first-run-loader.tsx index c0445a665..8d5b3648c 100644 --- a/apps/app/src/react-app/domains/onboarding/first-run-loader.tsx +++ b/apps/app/src/react-app/domains/onboarding/first-run-loader.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource react */ -import { OwDotTicker } from "../../shell/dot-ticker"; +import { WorkspaceLoadingState } from "../../shell/workspace-loading-state"; /** * First-run full-screen loader shown while the first session is created, @@ -15,10 +15,7 @@ export function FirstRunLoader() { role="status" >
- -
- Preparing workspace -
+
); diff --git a/apps/app/src/react-app/domains/session/chat/session-page.tsx b/apps/app/src/react-app/domains/session/chat/session-page.tsx index b8cc178e6..361975588 100644 --- a/apps/app/src/react-app/domains/session/chat/session-page.tsx +++ b/apps/app/src/react-app/domains/session/chat/session-page.tsx @@ -10,7 +10,6 @@ import { isPptxCompatibleTemplate, type TemplateCatalogItem, type TemplateManife import { currentLocale, t } from "../../../../i18n"; import { publicAssetUrl } from "../../../../app/lib/public-asset"; import { IPOLLOWORK_EXTENSION_CATALOG } from "../../../../app/constants"; -import { buildDenAuthUrl, readDenBootstrapConfig } from "../../../../app/lib/den"; import { type iPolloWorkServerClient, type iPolloWorkServerStatus } from "../../../../app/lib/ipollowork-server"; import { getDisplaySessionTitle } from "../../../../app/lib/session-title"; import type { BootPhase } from "../../../../app/lib/startup-boot"; @@ -42,8 +41,8 @@ import { import { Input } from "@/components/ui/input"; import { toast } from "@/components/ui/sonner"; import { ConfirmModal } from "../../../design-system/modals/confirm-modal"; -import { usePlatform } from "../../../kernel/platform"; -import { useDenAuth } from "../../cloud/den-auth-provider"; +import { useDenAuth } from "../../cloud/den-auth-provider"; +import { CloudSignInComingSoonDialog } from "../../cloud/cloud-signin-coming-soon-dialog"; import ProviderAuthModal, { type ProviderAuthModalProps } from "../../connections/provider-auth/provider-auth-modal"; import { RenameSessionModal } from "../modals/rename-session-modal"; import { AppSidebar } from "../sidebar/app-sidebar"; @@ -60,12 +59,13 @@ import { ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable"; -import { ShareWorkspaceModal } from "../../workspace/share-workspace-modal"; -import { OwDotTicker } from "../../../shell/dot-ticker"; -import { useReactRenderWatchdog } from "../../../shell/react-render-watchdog"; -import { useShellConfig } from "../../../shell/shell-config"; -import { type SidePanelItem, useUiStateStore } from "../../../shell/ui-state-store"; -import { workspaceSettingsRoute } from "../../../shell/workspace-routes"; +import { ShareWorkspaceModal } from "../../workspace/share-workspace-modal"; +import { OwDotTicker } from "../../../shell/dot-ticker"; +import { useReactRenderWatchdog } from "../../../shell/react-render-watchdog"; +import { useShellConfig } from "../../../shell/shell-config"; +import { type SidePanelItem, useUiStateStore } from "../../../shell/ui-state-store"; +import { workspaceSettingsRoute } from "../../../shell/workspace-routes"; +import { WorkspaceLoadingState } from "../../../shell/workspace-loading-state"; import { isElectronRuntime } from "../../../../app/utils"; import { isCollectibleArtifactTarget, isLocalhostBrowserTarget, isOpenableFileTarget, type OpenTarget } from "../artifacts/open-target"; @@ -89,10 +89,11 @@ const STARTUP_SKELETON_ROWS = [ { id: "final", titleWidth: "36%", bodyWidth: "74%" }, ]; const GLOBAL_VOICE_SIDE_PANEL_KEY = "__ipollowork_voice__"; -const EMPTY_TRANSCRIPT_TARGETS: OpenTarget[] = []; -const MAIN_WORKSPACE_MIN_WIDTH = 520; -const MAIN_WORKSPACE_FALLBACK_MIN_WIDTH = 480; -type SessionPanelView = SidePanelItem | "launcher"; +const EMPTY_TRANSCRIPT_TARGETS: OpenTarget[] = []; +const MAIN_WORKSPACE_MIN_WIDTH = 520; +const MAIN_WORKSPACE_FALLBACK_MIN_WIDTH = 480; +const AUTO_COLLAPSE_LEFT_SIDEBAR_WIDTH = 520; +type SessionPanelView = SidePanelItem | "launcher"; export type SessionPageHistoryControls = { canUndo: boolean; @@ -387,11 +388,10 @@ function TemplateBriefCard({ template, onSubmit, onClose }: { template: Template return

{template.title} · {config.label}

{config.heading}

{config.description}

{config.fields.map((field) => )} setCustomPalette((current) => ({ ...current, [key]: value }))} />
; } -export function SessionPage(props: SessionPageProps) { - const locale = currentLocale(); - const { config: shellConfig } = useShellConfig(); - const platform = usePlatform(); - const navigate = useNavigate(); +export function SessionPage(props: SessionPageProps) { + const locale = currentLocale(); + const { config: shellConfig } = useShellConfig(); + const navigate = useNavigate(); const denAuth = useDenAuth(); const sidebarOpen = useUiStateStore((state) => state.sidebarOpen); const setSidebarOpen = useUiStateStore((state) => state.setSidebarOpen); @@ -427,8 +427,9 @@ export function SessionPage(props: SessionPageProps) { const [templateCatalog, setTemplateCatalog] = useState([]); const [templateCatalogLoading, setTemplateCatalogLoading] = useState(false); const [templateCatalogError, setTemplateCatalogError] = useState(null); - const [templateBusyId, setTemplateBusyId] = useState(null); - const [templateMarketOpen, setTemplateMarketOpen] = useState(false); + const [templateBusyId, setTemplateBusyId] = useState(null); + const [templateMarketOpen, setTemplateMarketOpen] = useState(false); + const [cloudSignInComingSoonOpen, setCloudSignInComingSoonOpen] = useState(false); const [templateSessionData, setTemplateSessionData] = useState<{ state: TemplateSessionState; manifest: TemplateManifestV1; hasBrief: boolean } | null>(null); const [templateSessionLoading, setTemplateSessionLoading] = useState(false); const [sessionTypeRevision, setSessionTypeRevision] = useState(0); @@ -467,10 +468,18 @@ export function SessionPage(props: SessionPageProps) { setSessionType(sessionId, "video"); setSessionTypeRevision((value) => value + 1); }, []); - const openCurrentVideoStudio = useCallback(() => { - if (!props.selectedSessionId) return; - setSidePanelState(props.selectedSessionId, "video"); - }, [props.selectedSessionId, setSidePanelState]); + const openCurrentVideoStudio = useCallback(() => { + if (!props.selectedSessionId) return; + setSessionType(props.selectedSessionId, "video"); + setSessionTypeRevision((value) => value + 1); + setSidePanelState(props.selectedSessionId, "video"); + }, [props.selectedSessionId, setSidePanelState]); + const openCurrentDesignStudio = useCallback(() => { + if (!props.selectedSessionId) return; + setSessionType(props.selectedSessionId, "design"); + setSessionTypeRevision((value) => value + 1); + setSidePanelState(props.selectedSessionId, "design"); + }, [props.selectedSessionId, setSidePanelState]); const refreshTemplateCatalog = useCallback(async () => { if (!props.ipolloworkServerClient || !props.runtimeWorkspaceId) return; setTemplateCatalogLoading(true); @@ -649,10 +658,12 @@ export function SessionPage(props: SessionPageProps) { return next; }); }, [conversationMessages.length, props.onDeleteSession, props.selectedSessionId, templateSessionData?.hasBrief]); - const [sessionPanelView, setSessionPanelView] = useState(null); - const effectiveSidePanelView = activeSidePanel ?? sessionPanelView; - const sidePanelOpen = effectiveSidePanelView !== null; - const panelRailActive = activeSidePanel === "panel"; + const [sessionPanelView, setSessionPanelView] = useState(null); + const effectiveSidePanelView = activeSidePanel ?? sessionPanelView; + const sidePanelOpen = effectiveSidePanelView !== null; + const autoCollapsedSidebarRef = useRef(false); + const userOpenedSidebarWhileNarrowRef = useRef(false); + const panelRailActive = activeSidePanel === "panel"; const designRailActive = activeSidePanel === "design"; const videoRailActive = activeSidePanel === "video"; const extensionsRailActive = activeSidePanel === "extensions"; @@ -679,16 +690,14 @@ export function SessionPage(props: SessionPageProps) { useEffect(() => { autoOpenedVideoOutputRef.current = null; }, [props.selectedSessionId]); - const voiceExtension = useMemo( + const voiceExtension = useMemo( () => IPOLLOWORK_EXTENSION_CATALOG.find((entry) => getExtensionId(entry) === "ipollowork-voice") ?? null, [], - ); - const voiceExtensionEnabled = voiceExtension ? isiPolloWorkExtensionEnabled(voiceExtension) : false; - const openCloudSignIn = useCallback(() => { - const baseUrl = readDenBootstrapConfig().baseUrl; - // Label stays "Sign in"; opens the sign-up tab so new users aren't defaulted into sign-in. - platform.openLink(buildDenAuthUrl(baseUrl, "sign-up")); - }, [platform]); + ); + const voiceExtensionEnabled = voiceExtension ? isiPolloWorkExtensionEnabled(voiceExtension) : false; + const openCloudSignIn = useCallback(() => { + setCloudSignInComingSoonOpen(true); + }, []); const openCloudAccount = useCallback(() => { navigate(props.selectedWorkspaceId ? workspaceSettingsRoute(props.selectedWorkspaceId, "cloud-account") @@ -768,19 +777,52 @@ export function SessionPage(props: SessionPageProps) { const [viewportWidth, setViewportWidth] = useState(() => ( typeof window === "undefined" ? MAIN_WORKSPACE_MIN_WIDTH : window.innerWidth )); - const sidebarProviderStyle: CSSProperties & Record<"--sidebar-width", string> = { - "--sidebar-width": `${leftSidebarWidth}px`, - }; - const mainWorkspaceMinWidth = viewportWidth >= leftSidebarWidth + browserPanelDefaultWidth + MAIN_WORKSPACE_MIN_WIDTH + const sidebarProviderStyle: CSSProperties & Record<"--sidebar-width", string> = { + "--sidebar-width": `${leftSidebarWidth}px`, + }; + const availableMainWorkspaceWidth = viewportWidth + - (shellConfig.sidebar && sidebarOpen ? leftSidebarWidth : 0) + - (sidePanelOpen ? browserPanelDefaultWidth : 0); + const expandedMainWorkspaceWidth = viewportWidth - (shellConfig.sidebar ? leftSidebarWidth : 0); + const mainWorkspaceMinWidth = viewportWidth >= leftSidebarWidth + browserPanelDefaultWidth + MAIN_WORKSPACE_MIN_WIDTH ? MAIN_WORKSPACE_MIN_WIDTH : MAIN_WORKSPACE_FALLBACK_MIN_WIDTH; const sidebarVisuallyCollapsed = !sidebarOpen; - useEffect(() => { - const handleResize = () => setViewportWidth(window.innerWidth); + useEffect(() => { + const handleResize = () => setViewportWidth(window.innerWidth); handleResize(); window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); - }, []); + return () => window.removeEventListener("resize", handleResize); + }, []); + useEffect(() => { + if (!shellConfig.sidebar) return; + if ( + availableMainWorkspaceWidth < AUTO_COLLAPSE_LEFT_SIDEBAR_WIDTH && + sidebarOpen && + !sidePanelOpen && + !userOpenedSidebarWhileNarrowRef.current + ) { + autoCollapsedSidebarRef.current = true; + setSidebarOpen(false); + return; + } + if ( + autoCollapsedSidebarRef.current && + !sidebarOpen && + expandedMainWorkspaceWidth >= AUTO_COLLAPSE_LEFT_SIDEBAR_WIDTH + ) { + autoCollapsedSidebarRef.current = false; + userOpenedSidebarWhileNarrowRef.current = false; + setSidebarOpen(true); + } + }, [ + availableMainWorkspaceWidth, + expandedMainWorkspaceWidth, + setSidebarOpen, + shellConfig.sidebar, + sidebarOpen, + sidePanelOpen, + ]); useEffect(() => { if (sidePanelOpen) return; setBrowserPanelDefaultWidth(browserPanelWidth); @@ -1475,9 +1517,13 @@ export function SessionPage(props: SessionPageProps) { variant="ghost" size="icon-sm" className="absolute left-6 top-1/2 z-20 size-8 -translate-y-1/2 rounded-lg border-none text-muted-foreground hover:bg-muted hover:text-foreground mac:left-20 mac:titlebar-no-drag" - aria-label={t("sidebar.expand")} - title={t("sidebar.expand")} - onClick={() => setSidebarOpen(true)} + aria-label={t("sidebar.expand")} + title={t("sidebar.expand")} + onClick={() => { + userOpenedSidebarWhileNarrowRef.current = availableMainWorkspaceWidth < AUTO_COLLAPSE_LEFT_SIDEBAR_WIDTH; + autoCollapsedSidebarRef.current = false; + setSidebarOpen(true); + }} style={{ WebkitAppRegion: "no-drag", pointerEvents: "auto" } as CSSProperties} > @@ -1671,10 +1717,12 @@ export function SessionPage(props: SessionPageProps) { activeQuestion={props.activeQuestion} questionReplyBusy={props.questionReplyBusy} respondQuestion={props.respondQuestion} - safeStringify={props.safeStringify} - onOpenTarget={openTarget} - onConversationMessagesChange={handleConversationMessagesChange} - onCreateSession={(type, templateId) => props.sidebar.onCreateTaskInWorkspace(props.selectedWorkspaceId, type, templateId)} + safeStringify={props.safeStringify} + onOpenTarget={openTarget} + onConversationMessagesChange={handleConversationMessagesChange} + onOpenDesignStudio={openCurrentDesignStudio} + onOpenVideoStudio={openCurrentVideoStudio} + onCreateSession={(type, templateId) => props.sidebar.onCreateTaskInWorkspace(props.selectedWorkspaceId, type, templateId)} onActivateVideoStudio={activateVideoStudio} designTemplates={templateCatalog} designTemplatesLoading={templateCatalogLoading} @@ -1757,15 +1805,10 @@ export function SessionPage(props: SessionPageProps) {
) : (
-
- -
- {t("session.preparing_workspace")} -
-

- {t("session.loading_detail")} -

-
+
)}
@@ -1848,9 +1891,10 @@ export function SessionPage(props: SessionPageProps) { ) : activeSidePanel === "outputs" ? ( ) : activeSidePanel === "panel" && props.selectedSessionId ? ( ) : null} - { if (!open) setCreateGroupOpen(false); }}> + { if (!open) setCreateGroupOpen(false); }}> {t("session_management.new_group")} @@ -1963,9 +2007,14 @@ export function SessionPage(props: SessionPageProps) { - - - {props.shareWorkspaceModal ? : null} + + + + + {props.shareWorkspaceModal ? : null} {/* Cloud provider notifications are now handled globally by CloudProvidersToast in app-root.tsx */} diff --git a/apps/app/src/react-app/domains/session/design/design-panel.tsx b/apps/app/src/react-app/domains/session/design/design-panel.tsx index bb496ffd4..5b60b8e1a 100644 --- a/apps/app/src/react-app/domains/session/design/design-panel.tsx +++ b/apps/app/src/react-app/domains/session/design/design-panel.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource react */ import * as React from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { AlignCenter, AlignLeft, AlignRight, ArrowLeft, Check, ChevronLeft, ChevronRight, Code2, Download, ExternalLink, ImagePlus, Link2, Loader2, Minus, Monitor, MousePointer2, Move, Palette, Paintbrush, Plus, Presentation, Save, Share2, SlidersHorizontal, Smartphone, Sparkles, Square, Type, Undo2, Upload, X } from "lucide-react"; +import { AlignCenter, AlignLeft, AlignRight, ArrowLeft, Check, ChevronLeft, ChevronRight, Code2, Download, ExternalLink, ImagePlus, Link2, Loader2, Minus, Monitor, MousePointer2, Move, Palette, Paintbrush, Plus, Presentation, Save, Share2, SlidersHorizontal, Smartphone, Sparkles, Square, Type, Undo2, Upload, X } from "lucide-react"; import type { iPolloWorkServerClient } from "@/app/lib/ipollowork-server"; import { pickLocalImageFile, readLocalImageAsDataUrl } from "@/app/lib/desktop"; @@ -15,10 +15,12 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -import { toast } from "@/components/ui/sonner"; -import { cn } from "@/lib/utils"; +import { Switch } from "@/components/ui/switch"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { toast } from "@/components/ui/sonner"; +import { cn } from "@/lib/utils"; +import { t } from "@/i18n"; import { isPptxCompatibleTemplate } from "@ipollowork/types/templates"; import { ConfirmModal } from "@/react-app/design-system/modals/confirm-modal"; import { @@ -363,7 +365,7 @@ export function DesignPanel({ const observer = new ResizeObserver(sync); observer.observe(viewport); return () => observer.disconnect(); - }, [isPresentationTemplate]); + }, [isPresentationTemplate, sourceHydrated]); const templateTokenPath = React.useMemo(() => { const tokenPath = designTemplate?.designSystem.tokens || linkedDesignTokenPath(fileQuery.data?.content) || "design-tokens.css"; const briefPath = templateQuery.data?.state.briefPath; @@ -544,7 +546,10 @@ export function DesignPanel({ setExportingPdf(true); const frame = document.createElement("iframe"); frame.setAttribute("aria-hidden", "true"); - frame.style.cssText = `position:fixed;left:-100000px;top:0;width:${PDF_SLIDE_WIDTH}px;height:${PDF_SLIDE_HEIGHT}px;border:0;visibility:hidden;pointer-events:none`; + // Keep the export document laid out and paintable. `visibility:hidden` on + // the host iframe can make Chromium/html2canvas skip its rendering tree in + // packaged Electron builds, producing a valid but blank PPTX. + frame.style.cssText = `position:fixed;left:-100000px;top:0;width:${PDF_SLIDE_WIDTH}px;height:${PDF_SLIDE_HEIGHT}px;border:0;opacity:0;pointer-events:none`; document.body.append(frame); try { const exportLibraries = Promise.all([import("html2canvas-pro"), import("jspdf")]); @@ -705,10 +710,17 @@ export function DesignPanel({ await yieldForExportWork(); const pptxSlide = pptx.addSlide(); - if (usesNativeEditablePptx) { - pptxSlide.background = { color: pptxCompatibleSlideBackground(slide) }; - const objects = collectPptxCompatibleObjects(slide); - nativeObjectCount += objects.length; + if (usesNativeEditablePptx) { + pptxSlide.background = { color: pptxCompatibleSlideBackground(slide) }; + const objects = collectPptxCompatibleObjects(slide); + const objectCoverage = validatePptxElementPlanCoverage({ + hasVisibleContent: slideHasVisiblePptxContent(slide), + planCount: objects.length, + }); + if (!objectCoverage.valid) { + throw new Error("PPTX export stopped because visible slide content could not be collected. No blank presentation was created."); + } + nativeObjectCount += objects.length; for (const object of objects) { if (object.kind === "shape") { pptxSlide.addShape(object.value.type, { @@ -1107,23 +1119,17 @@ export function DesignPanel({ ) : ( <>
- {hasSiteVersioning ? ( -
-
-

{fileName(activePagePath)}

-

{viewedVersionPath === "current" ? "Current design" : "Version preview"}

-
- {versionTargets.length > 0 ? ( - - ) : null} -
- ) : null} + {hasSiteVersioning && versionTargets.length > 0 ? ( +
+ +
+ ) : null}
{fileQuery.isLoading || !sourceHydrated ? ( @@ -1256,8 +1261,8 @@ export function DesignPanel({ isPresentationTemplate ? "absolute left-1/2 top-1/2 h-[900px] w-[1600px] origin-center rounded-lg shadow-sm" : previewDevice === "desktop" - ? "w-full rounded-lg shadow-sm" - : "w-[390px] max-w-full shrink-0 rounded-[26px] shadow-xl shadow-black/15", + ? "h-full w-full rounded-lg shadow-sm" + : "h-full w-[390px] max-w-full shrink-0 rounded-[26px] shadow-xl shadow-black/15", )} style={isPresentationTemplate ? { transform: `translate(-50%, -50%) scale(${presentationScale})` } diff --git a/apps/app/src/react-app/domains/session/design/presentation-canvas.ts b/apps/app/src/react-app/domains/session/design/presentation-canvas.ts index 6d8a4094a..00ed84bc9 100644 --- a/apps/app/src/react-app/domains/session/design/presentation-canvas.ts +++ b/apps/app/src/react-app/domains/session/design/presentation-canvas.ts @@ -2,7 +2,13 @@ export const PRESENTATION_CANVAS_WIDTH = 1600; export const PRESENTATION_CANVAS_HEIGHT = 900; export function presentationCanvasScale(availableWidth: number, availableHeight: number) { - if (availableWidth <= 0 || availableHeight <= 0) return 0; + // The viewport is mounted after the async template file loads. In a + // packaged Electron window ResizeObserver can report the initial zero-size + // layout for one frame (or miss that first transition entirely). A zero + // transform makes the iframe itself 0x0, which looks like a blank slide. + // Keep the fixed canvas paintable until the real viewport measurement + // arrives; the next observer update will scale it to fit normally. + if (availableWidth <= 0 || availableHeight <= 0) return 1; return Math.min( availableWidth / PRESENTATION_CANVAS_WIDTH, availableHeight / PRESENTATION_CANVAS_HEIGHT, diff --git a/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx b/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx index 3a90e3203..a879002ba 100644 --- a/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx +++ b/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx @@ -904,7 +904,7 @@ export function AppSidebar(props: AppSidebarProps) { - props.onOpenSettings("/settings/general")}> + props.onOpenSettings("/settings/preferences")}> {t("status.settings")} diff --git a/apps/app/src/react-app/domains/session/surface/composer/composer.tsx b/apps/app/src/react-app/domains/session/surface/composer/composer.tsx index 0d2ecde10..55fe3c2d5 100644 --- a/apps/app/src/react-app/domains/session/surface/composer/composer.tsx +++ b/apps/app/src/react-app/domains/session/surface/composer/composer.tsx @@ -13,8 +13,9 @@ import { isiPolloWorkExtensionEnabled, isiPolloWorkExtensionHidden, IPOLLOWORK_E import { useDesktopRestriction } from "@/react-app/domains/cloud/desktop-config-provider"; import { resolveExtensionIconUrl } from "@/react-app/design-system/extension-icon-src"; import { ModelBehaviorSelect } from "@/components/model-behavior-select"; -import { ModelSelect } from "@/components/model-select"; -import { LexicalPromptEditor, type LexicalPromptEditorHandle } from "./editor"; +import { ModelSelect } from "@/components/model-select"; +import { MAX_MODEL_ATTACHMENT_BYTES } from "@/react-app/domains/session/sync/attachment-support"; +import { LexicalPromptEditor, type LexicalPromptEditorHandle } from "./editor"; import { listRunningAppsForMention } from "./app-mentions"; import type { ComposerMentionKind } from "./mention-encoding"; import { getSlashCommandQuery } from "./slash-command"; @@ -108,8 +109,7 @@ type ComposerProps = { const FLUSH_PROMPT_EVENT = "ipollowork:flushPromptDraft"; const FOCUS_PROMPT_EVENT = "ipollowork:focusPrompt"; -const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024; -const IMAGE_COMPRESS_MAX_PX = 2048; +const IMAGE_COMPRESS_MAX_PX = 2048; const IMAGE_COMPRESS_QUALITY = 0.82; const IMAGE_COMPRESS_TARGET_BYTES = 1_500_000; const FILE_URL_RE = /^file:\/\//i; @@ -1024,7 +1024,7 @@ export function ReactSessionComposer(props: ComposerProps) { for (const original of inputFiles) { const processed = original.type.startsWith("image/") ? await compressImageFile(original) : original; - if (processed.size > MAX_ATTACHMENT_BYTES) { + if (processed.size > MAX_MODEL_ATTACHMENT_BYTES) { oversize.push(processed.name || original.name); continue; } @@ -1039,7 +1039,7 @@ export function ReactSessionComposer(props: ComposerProps) { toast.warning( oversize.length === 1 ? t("composer.file_exceeds_limit", { name: oversize[0] }) - : `${oversize.length} files exceed the 8MB limit.`, + : t("composer.files_exceed_limit", { count: oversize.length }), ); } diff --git a/apps/app/src/react-app/domains/session/surface/session-surface.tsx b/apps/app/src/react-app/domains/session/surface/session-surface.tsx index 12f1f7387..b01abd37b 100644 --- a/apps/app/src/react-app/domains/session/surface/session-surface.tsx +++ b/apps/app/src/react-app/domains/session/surface/session-surface.tsx @@ -39,13 +39,17 @@ import { decodeComposerMentionValue, encodeComposerMentionValue, type ComposerMe import { desktopBridge } from "@/app/lib/desktop"; import { parseSlashCommandInvocation } from "./composer/slash-command"; import { DevProfiler } from "@/react-app/shell/dev-profiler"; -import { PaperGrainGradient } from "@ipollowork/ui/react"; +import { publicAssetUrl } from "@/app/lib/public-asset"; import { useShellConfig } from "@/react-app/shell/shell-config"; import { useReactRenderWatchdog } from "@/react-app/shell/react-render-watchdog"; import { SessionDebugPanel } from "./debug-panel"; import { deriveRenderedSessionMessages, resolveRenderedSessionSnapshot } from "./session-render-state"; import { useLocal } from "@/react-app/kernel/local-provider"; -import { isModelReadableAttachment } from "@/react-app/domains/session/sync/attachment-support"; +import { + isModelReadableAttachment, + MAX_MODEL_ATTACHMENT_BYTES, + resolveModelAttachmentMimeType, +} from "@/react-app/domains/session/sync/attachment-support"; import { deriveSessionRenderModel } from "@/react-app/domains/session/sync/transition-controller"; import { useSessionScrollController } from "./scroll-controller"; import { SessionScrollOverlay } from "./scroll-overlay"; @@ -143,8 +147,11 @@ export type SessionSurfaceProps = { onUploadInboxFiles?: ((files: File[], options?: { notify?: boolean }) => void | Promise) | null; providerConnectedCount?: number; onCreateSession?: (type: NewConversationMode, templateId?: string) => void; - /** Marks the first prompt as a video task before it reaches the agent. */ - onActivateVideoStudio?: (sessionId: string) => void; + /** Marks the first prompt as a video task before it reaches the agent. */ + onActivateVideoStudio?: (sessionId: string) => void; + /** Opens the current session's Video Studio from an inline video artifact. */ + onOpenDesignStudio?: () => void; + onOpenVideoStudio?: () => void; designTemplates?: TemplateCatalogItem[]; designTemplatesLoading?: boolean; designTemplateBusyId?: string | null; @@ -244,23 +251,17 @@ function messageHasVisibleAssistantOutput(message: UIMessage) { }); } -function AssistantWaitingCard({ label = t("session.assistant_thinking") }: { label?: string }) { - return ( -
-
-
- -
- {label} +function AssistantWaitingCard({ label = t("session.assistant_thinking") }: { label?: string }) { + return ( +
+
+ + {label}
); @@ -929,16 +930,16 @@ export function SessionSurface(props: SessionSurfaceProps) { toast.warning(props.attachmentsDisabledReason ?? "Attachments are unavailable."); return; } - const oversized = files.filter((file) => file.size > 25 * 1024 * 1024); - const sized = files.filter((file) => file.size <= 25 * 1024 * 1024); + const oversized = files.filter((file) => file.size > MAX_MODEL_ATTACHMENT_BYTES); + const sized = files.filter((file) => file.size <= MAX_MODEL_ATTACHMENT_BYTES); if (oversized.length) { toast.warning( oversized.length === 1 ? `${oversized[0]?.name ?? "File"} is too large` : `${oversized.length} files are too large`, - { description: "Files over 25 MB were skipped." }, - ); - } - const unreadable = sized.filter((file) => !isModelReadableAttachment(file.type)); - const accepted = sized.filter((file) => isModelReadableAttachment(file.type)); + { description: "Files over 25 MB were skipped." }, + ); + } + const unreadable = sized.filter((file) => !isModelReadableAttachment(file.type, file.name)); + const accepted = sized.filter((file) => isModelReadableAttachment(file.type, file.name)); if (unreadable.length) { toast.warning( unreadable.length === 1 @@ -948,15 +949,19 @@ export function SessionSurface(props: SessionSurfaceProps) { ); } if (!accepted.length) return; - const next = accepted.map((file) => ({ - id: `${file.name}-${file.lastModified}-${Math.random().toString(36).slice(2)}`, - name: file.name, - mimeType: file.type || "application/octet-stream", - size: file.size, - kind: file.type.startsWith("image/") ? "image" as const : "file" as const, - file, - previewUrl: file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined, - })); + const next = accepted.map((file) => { + const mimeType = resolveModelAttachmentMimeType(file.name, file.type); + const isImage = mimeType.startsWith("image/"); + return { + id: `${file.name}-${file.lastModified}-${Math.random().toString(36).slice(2)}`, + name: file.name, + mimeType, + size: file.size, + kind: isImage ? "image" as const : "file" as const, + file, + previewUrl: isImage ? URL.createObjectURL(file) : undefined, + }; + }); setComposerAttachments(props.sessionId, [...attachments, ...next]); }; @@ -1568,11 +1573,13 @@ export function SessionSurface(props: SessionSurfaceProps) { onForkAtMessage={handleForkAtMessage} onEditUserMessage={handleEditUserMessage} > - + diff --git a/apps/app/src/react-app/domains/session/sync/actions-store.ts b/apps/app/src/react-app/domains/session/sync/actions-store.ts index 285f3808c..37cd1f693 100644 --- a/apps/app/src/react-app/domains/session/sync/actions-store.ts +++ b/apps/app/src/react-app/domains/session/sync/actions-store.ts @@ -33,6 +33,7 @@ import { addOpencodeCacheHint, safeStringify } from "../../../../app/utils"; import { clearSessionDraft, saveSessionDraft } from "./draft-store"; import { firstLineLocalFileParts } from "./prompt-file-parts"; import { appMentionInstruction } from "../surface/composer/app-mentions"; +import { resolveModelAttachmentMimeType } from "./attachment-support"; type SessionModelConfig = { applyPendingSessionChoice: (sessionId: string) => void; @@ -63,12 +64,7 @@ const fileToDataUrl = (file: File, mimeType: string) => }); function attachmentMime(attachment: ComposerAttachment) { - if (attachment.kind === "image") return attachment.mimeType; - if (attachment.mimeType === "application/pdf") return attachment.mimeType; - // Everything else is sent as text. Unsupported binary mimes (e.g. Keynote) - // poison the server-side session history: every later prompt replays the - // provider's UnsupportedFunctionalityError and the session cannot recover. - return "text/plain"; + return resolveModelAttachmentMimeType(attachment.name, attachment.mimeType); } export function createSessionActionsStore(options: { diff --git a/apps/app/src/react-app/domains/session/sync/attachment-support.ts b/apps/app/src/react-app/domains/session/sync/attachment-support.ts index a823b8237..867aa5f08 100644 --- a/apps/app/src/react-app/domains/session/sync/attachment-support.ts +++ b/apps/app/src/react-app/domains/session/sync/attachment-support.ts @@ -1,20 +1,79 @@ +/** Keep the browser -> provider attachment contract in one place. */ +export const MAX_MODEL_ATTACHMENT_BYTES = 25 * 1024 * 1024; + +const OFFICE_MIME_TYPES = new Set([ + "application/msword", + "application/vnd.ms-word", + "application/vnd.ms-excel", + "application/vnd.ms-excel.sheet.binary.macroenabled.12", + "application/vnd.ms-excel.sheet.macroenabled.12", + "application/vnd.ms-excel.template.macroenabled.12", + "application/vnd.ms-powerpoint", + "application/vnd.ms-powerpoint.presentation.macroenabled.12", + "application/vnd.oasis.opendocument.presentation", + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.oasis.opendocument.text", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.openxmlformats-officedocument.presentationml.template", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.spreadsheetml.template", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.wordprocessingml.template", + "application/vnd.ms-word.document.macroenabled.12", + "application/vnd.ms-word.template.macroenabled.12", + "application/rtf", +]); + +const EXTENSION_MIME_TYPES: Record = { + ".doc": "application/msword", + ".docm": "application/vnd.ms-word.document.macroenabled.12", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".dotm": "application/vnd.ms-word.template.macroenabled.12", + ".dotx": "application/vnd.openxmlformats-officedocument.wordprocessingml.template", + ".odp": "application/vnd.oasis.opendocument.presentation", + ".ods": "application/vnd.oasis.opendocument.spreadsheet", + ".odt": "application/vnd.oasis.opendocument.text", + ".pdf": "application/pdf", + ".pot": "application/vnd.ms-powerpoint", + ".potm": "application/vnd.ms-powerpoint.presentation.macroenabled.12", + ".potx": "application/vnd.openxmlformats-officedocument.presentationml.template", + ".ppt": "application/vnd.ms-powerpoint", + ".pptm": "application/vnd.ms-powerpoint.presentation.macroenabled.12", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".xls": "application/vnd.ms-excel", + ".xlsb": "application/vnd.ms-excel.sheet.binary.macroenabled.12", + ".xlsm": "application/vnd.ms-excel.sheet.macroenabled.12", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".xltm": "application/vnd.ms-excel.template.macroenabled.12", + ".xltx": "application/vnd.openxmlformats-officedocument.spreadsheetml.template", +}; + +function extensionMimeType(fileName: string): string | null { + const normalized = fileName.trim().toLowerCase(); + const extension = normalized.slice(normalized.lastIndexOf(".")); + return extension.length > 1 ? EXTENSION_MIME_TYPES[extension] ?? null : null; +} + +/** Resolve missing Finder/browser MIME types before validating or sending. */ +export function resolveModelAttachmentMimeType(fileName: string, mimeType: string): string { + const normalized = mimeType.trim().toLowerCase(); + if (normalized && normalized !== "application/octet-stream") return normalized; + return extensionMimeType(fileName) ?? "text/plain"; +} + /** * Which attachment media types can be sent to the model as file parts. * - * Providers (via opencode + the AI SDK) accept images, PDFs, and text. - * Anything else (e.g. Keynote `application/x-iwork-keynote-sffkey`, Office - * binaries) is rejected by the provider with an UnsupportedFunctionalityError - * — and because the file part lives in server-side session history, every - * later message in the session replays the failure. Blocking these at attach - * time prevents poisoning the session. - * - * Empty / `application/octet-stream` types are allowed: browsers report them - * for plain source/code files, which are sent as `text/plain`. + * This mirrors the practical file inputs supported by Codex-style providers: + * images, PDFs, common Office/ODF documents, and text/code formats. Unknown + * empty MIME types remain text/plain for source files whose browser type is + * unavailable; arbitrary known binary formats are rejected before sending so + * they cannot poison the server-side session history. */ -export function isModelReadableAttachment(mimeType: string) { - const mime = mimeType.toLowerCase(); - if (mime === "" || mime === "application/octet-stream") return true; +export function isModelReadableAttachment(mimeType: string, fileName = "") { + const mime = resolveModelAttachmentMimeType(fileName, mimeType); if (mime.startsWith("image/") || mime.startsWith("text/")) return true; if (mime === "application/pdf" || mime === "application/json") return true; + if (OFFICE_MIME_TYPES.has(mime)) return true; return mime.endsWith("+json") || mime.endsWith("+xml") || mime === "application/xml" || mime === "application/javascript"; } diff --git a/apps/app/src/react-app/domains/settings/ipollowork-voice-config.tsx b/apps/app/src/react-app/domains/settings/ipollowork-voice-config.tsx index c3f12d2c7..3061bdeb6 100644 --- a/apps/app/src/react-app/domains/settings/ipollowork-voice-config.tsx +++ b/apps/app/src/react-app/domains/settings/ipollowork-voice-config.tsx @@ -1,5 +1,6 @@ /** @jsxImportSource react */ -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { CheckCircle2, Loader2, Mic2, XCircle } from "lucide-react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; @@ -19,10 +20,14 @@ import { FieldLabel, } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import type { iPolloWorkServerClient } from "@/app/lib/ipollowork-server"; import { t } from "@/i18n"; import { registerExtensionConfig, type ExtensionConfigContext } from "./extension-registry"; +import { getSpeechInputProvider, setSpeechInputProvider, type SpeechInputProvider } from "./state/speech-input-preferences"; export type IPolloWorkVoiceConfigProps = { + authorizationClient: iPolloWorkServerClient | null; busy: boolean; status: string | null; error: string | null; @@ -33,6 +38,7 @@ export type IPolloWorkVoiceConfigProps = { const IPolloWorkVoiceConfigFactory = (ctx: ExtensionConfigContext) => ( (() => getSpeechInputProvider()); + const [speechInputSaved, setSpeechInputSaved] = useState(false); const canSave = Boolean(apiKey.trim()); + const authorizationQuery = useQuery({ + queryKey: ["settings", "speech-input", "authorization"], + queryFn: async () => props.authorizationClient?.listAuthorizationServices() ?? { items: [] }, + enabled: props.authorizationClient !== null, + refetchOnWindowFocus: false, + }); + const configuredProviderIds = useMemo( + () => new Set((authorizationQuery.data?.items ?? []).filter((item) => item.configured).map((item) => item.id)), + [authorizationQuery.data?.items], + ); + const providerOptions = useMemo(() => [ + { value: "auto" as const, label: t("settings.integration.speech_input.auto") }, + ...(configuredProviderIds.has("openai-images") ? [{ value: "openai" as const, label: t("settings.authorization.option.openai") }] : []), + ...(configuredProviderIds.has("aliyun-bailian") ? [{ value: "aliyun-bailian" as const, label: t("settings.authorization.option.aliyun_bailian") }] : []), + ...(configuredProviderIds.has("baidu-speech") ? [{ value: "baidu-speech" as const, label: t("settings.authorization.option.baidu_speech") }] : []), + ], [configuredProviderIds]); + const effectiveSpeechInputProvider = providerOptions.some((option) => option.value === speechInputProvider) + ? speechInputProvider + : "auto"; + + useEffect(() => { + if (effectiveSpeechInputProvider !== speechInputProvider) { + setSpeechInputProviderState(effectiveSpeechInputProvider); + setSpeechInputProvider(effectiveSpeechInputProvider); + } + }, [effectiveSpeechInputProvider, speechInputProvider]); + + const handleSpeechInputProviderChange = (value: string | null) => { + if (!value) return; + if (!providerOptions.some((option) => option.value === value)) return; + const nextProvider = value as SpeechInputProvider; + setSpeechInputProviderState(nextProvider); + setSpeechInputProvider(nextProvider); + setSpeechInputSaved(true); + }; return ( - - - {t("settings.integration.voice.title")} - - {t("settings.integration.voice.description")} - - - - {props.envKeyDetected ? ( - - - {t("settings.integration.voice.key_detected_title")} - - {t("settings.integration.voice.key_detected_description")} - - - ) : null} +
+ + + {t("settings.integration.voice.title")} + + {t("settings.integration.voice.description")} + + + + {props.envKeyDetected ? ( + + + {t("settings.integration.voice.key_detected_title")} + + {t("settings.integration.voice.key_detected_description")} + + + ) : null} - + + + {t("settings.integration.openai_api_key")} + setApiKey(event.currentTarget.value)} + placeholder="sk-..." + /> + + {t("settings.integration.voice.key_description")} + + + + + {props.status ? ( + + + {props.status} + + ) : null} + {props.error ? ( + + + {props.error} + + ) : null} + + + + + + + + + + {t("settings.integration.speech_input.title")} + {t("settings.integration.speech_input.description")} + + - {t("settings.integration.openai_api_key")} - setApiKey(event.currentTarget.value)} - placeholder="sk-..." - /> - - {t("settings.integration.voice.key_description")} - + {t("settings.integration.speech_input.provider_label")} + + {providerOptions.length === 1 ? {t("settings.integration.speech_input.none")} : null} - - - {props.status ? ( - - - {props.status} - - ) : null} - {props.error ? ( - - - {props.error} - - ) : null} - - - - - - + {speechInputSaved ? ( + + + {t("settings.integration.speech_input.saved")} + + ) : null} + + +
); } diff --git a/apps/app/src/react-app/domains/settings/pages/authorization-center-view.tsx b/apps/app/src/react-app/domains/settings/pages/authorization-center-view.tsx index d8791238c..8757b1ea9 100644 --- a/apps/app/src/react-app/domains/settings/pages/authorization-center-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/authorization-center-view.tsx @@ -10,6 +10,7 @@ import { Image, KeyRound, Loader2, + Mic2, PlugZap, RefreshCw, XCircle, @@ -95,6 +96,15 @@ const SERVICES: Record = descriptionKey: "settings.authorization.service.aliyun_bailian.description", fields: [{ key: "DASHSCOPE_API_KEY", label: "DashScope API key", placeholder: "sk-..." }], }, + "baidu-speech": { + icon: Mic2, + titleKey: "settings.authorization.service.baidu_speech.title", + descriptionKey: "settings.authorization.service.baidu_speech.description", + fields: [ + { key: "BAIDU_SPEECH_API_KEY", label: "Baidu API key", placeholder: "API key" }, + { key: "BAIDU_SPEECH_SECRET_KEY", label: "Baidu Secret key", placeholder: "Secret key" }, + ], + }, "volcengine-video": { icon: Clapperboard, titleKey: "settings.authorization.service.volcengine_video.title", diff --git a/apps/app/src/react-app/domains/settings/shell/settings-page.tsx b/apps/app/src/react-app/domains/settings/shell/settings-page.tsx index ca5ea8da3..000775265 100644 --- a/apps/app/src/react-app/domains/settings/shell/settings-page.tsx +++ b/apps/app/src/react-app/domains/settings/shell/settings-page.tsx @@ -194,7 +194,7 @@ export function getSettingsTabDescription(tab: SettingsTab) { } export function getWorkspaceSettingsTabs(): SettingsTab[] { - return ["preferences", "permissions", "extensions", "advanced"]; + return ["preferences", "permissions", "extensions"]; } export function getGlobalSettingsTabs(developerMode: boolean): SettingsTab[] { @@ -313,24 +313,6 @@ export function SettingsSidebar(props: SettingsSidebarProps) { - {/* Top-level hub entry */} - - - - - props.onSelectTab("general")} - > - - {getSettingsTabLabel("general")} - - - - - - {t("settings.group_workspace")} diff --git a/apps/app/src/react-app/domains/settings/shell/settings-shell.tsx b/apps/app/src/react-app/domains/settings/shell/settings-shell.tsx index 378fc79e8..491ee16dc 100644 --- a/apps/app/src/react-app/domains/settings/shell/settings-shell.tsx +++ b/apps/app/src/react-app/domains/settings/shell/settings-shell.tsx @@ -166,7 +166,6 @@ export function SettingsShell(props: SettingsShellProps) { function SettingsSectionMenu(props: Pick) { const { memoryEnabled } = useFeatureFlagsPreferences(); const sections: Array<{ label: string | null; tabs: SettingsTab[] }> = [ - { label: null, tabs: ["general"] }, { label: t("settings.group_workspace"), tabs: getWorkspaceSettingsTabs() }, { label: t("settings.group_global"), tabs: getGlobalSettingsTabs(props.developerMode) }, { label: t("settings.group_cloud"), tabs: getCloudSettingsTabs(memoryEnabled) }, diff --git a/apps/app/src/react-app/domains/settings/state/speech-input-preferences.ts b/apps/app/src/react-app/domains/settings/state/speech-input-preferences.ts new file mode 100644 index 000000000..e5064987e --- /dev/null +++ b/apps/app/src/react-app/domains/settings/state/speech-input-preferences.ts @@ -0,0 +1,20 @@ +export const SPEECH_INPUT_PROVIDERS = ["auto", "openai", "aliyun-bailian", "baidu-speech"] as const; + +export type SpeechInputProvider = (typeof SPEECH_INPUT_PROVIDERS)[number]; + +const SPEECH_INPUT_PROVIDER_STORAGE_KEY = "ipollowork.speechInputProvider"; + +function isSpeechInputProvider(value: string): value is SpeechInputProvider { + return SPEECH_INPUT_PROVIDERS.includes(value as SpeechInputProvider); +} + +export function getSpeechInputProvider(): SpeechInputProvider { + if (typeof window === "undefined") return "auto"; + const stored = window.localStorage.getItem(SPEECH_INPUT_PROVIDER_STORAGE_KEY)?.trim() ?? ""; + return isSpeechInputProvider(stored) ? stored : "auto"; +} + +export function setSpeechInputProvider(provider: SpeechInputProvider) { + if (typeof window === "undefined") return; + window.localStorage.setItem(SPEECH_INPUT_PROVIDER_STORAGE_KEY, provider); +} diff --git a/apps/app/src/react-app/kernel/local-provider.tsx b/apps/app/src/react-app/kernel/local-provider.tsx index 53f5195e4..d44499a0e 100644 --- a/apps/app/src/react-app/kernel/local-provider.tsx +++ b/apps/app/src/react-app/kernel/local-provider.tsx @@ -80,7 +80,7 @@ const LocalContext = createContext(undefined); const UI_STORAGE_KEY = "ipollowork.ui"; export const DEFAULT_SHOW_THINKING = true; -const INITIAL_UI: LocalUIState = { view: "settings", tab: "general" }; +const INITIAL_UI: LocalUIState = { view: "settings", tab: "preferences" }; const INITIAL_PREFS: LocalPreferences = { showThinking: DEFAULT_SHOW_THINKING, modelVariant: null, diff --git a/apps/app/src/react-app/shell/app-menu.tsx b/apps/app/src/react-app/shell/app-menu.tsx index 429ea74d3..923b9716e 100644 --- a/apps/app/src/react-app/shell/app-menu.tsx +++ b/apps/app/src/react-app/shell/app-menu.tsx @@ -14,7 +14,7 @@ export function AppMenuProvider({ children }: { children: ReactNode }) { const toggleSidebar = useUiStateStore((state) => state.toggleSidebar); useEffect(() => { - const openSettings = () => navigate("/settings/general"); + const openSettings = () => navigate("/settings/preferences"); const checkUpdates = () => { useUpdateCheckRequestStore.getState().requestUpdateCheck(); navigate("/settings/updates"); diff --git a/apps/app/src/react-app/shell/command-palette.tsx b/apps/app/src/react-app/shell/command-palette.tsx index c98c533b2..4a1a65c19 100644 --- a/apps/app/src/react-app/shell/command-palette.tsx +++ b/apps/app/src/react-app/shell/command-palette.tsx @@ -235,8 +235,8 @@ export function CommandPalette(props: CommandPaletteProps) { ...(props.extraItems ?? []), { id: "open-settings", - title: t("settings.tab_general"), - detail: t("settings.tab_description_general"), + title: t("settings.tab_preferences"), + detail: t("settings.tab_description_preferences"), meta: t("session.cmd_settings_meta"), action: () => { props.onClose(); diff --git a/apps/app/src/react-app/shell/control/control-provider.tsx b/apps/app/src/react-app/shell/control/control-provider.tsx index 7c029604a..835d81d85 100644 --- a/apps/app/src/react-app/shell/control/control-provider.tsx +++ b/apps/app/src/react-app/shell/control/control-provider.tsx @@ -462,10 +462,10 @@ export function IPolloWorkRouteControlActions() { }, { id: "route.settings.general", - label: "Open general settings", - description: "Navigate to general settings.", + label: "Open preferences settings", + description: "Navigate to preferences settings.", sideEffect: "navigation", - execute: () => navigate("/settings/general"), + execute: () => navigate("/settings/preferences"), }, { id: "route.settings.skills", diff --git a/apps/app/src/react-app/shell/loading-overlay.tsx b/apps/app/src/react-app/shell/loading-overlay.tsx index 513b69e00..688f950d0 100644 --- a/apps/app/src/react-app/shell/loading-overlay.tsx +++ b/apps/app/src/react-app/shell/loading-overlay.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource react */ import { useBootState, useBootOverlayVisible } from "./boot-state"; -import { OwDotTicker } from "./dot-ticker"; +import { WorkspaceLoadingState } from "./workspace-loading-state"; const RELEASES_URL = "https://github.com/Devin-AXIS/iPolloWork/releases"; @@ -27,10 +27,7 @@ export function LoadingOverlay() { role="status" >
- -
- {message || "Preparing workspace"} -
+ {error ? (
{error}
diff --git a/apps/app/src/react-app/shell/session-route.tsx b/apps/app/src/react-app/shell/session-route.tsx index 06dae0d43..ac59ee3c1 100644 --- a/apps/app/src/react-app/shell/session-route.tsx +++ b/apps/app/src/react-app/shell/session-route.tsx @@ -92,6 +92,7 @@ import { import { useLocal } from "@/react-app/kernel/local-provider"; import { usePlatform } from "@/react-app/kernel/platform"; import { SessionPage } from "@/react-app/domains/session/chat/session-page"; +import { resolveModelAttachmentMimeType } from "@/react-app/domains/session/sync/attachment-support"; import { isDesktopProviderBlocked, DESKTOP_RESTRICTION_OPENCODE_PROVIDER_ID } from "@/app/cloud/desktop-app-restrictions"; import { useCheckDesktopRestriction } from "@/react-app/domains/cloud/desktop-config-provider"; import { useRestrictionNotice } from "@/react-app/domains/cloud/restriction-notice-provider"; @@ -246,11 +247,7 @@ async function fileToDataUrl(file: File, mimeType: string) { } function attachmentMime(attachment: ComposerAttachment) { - if (attachment.kind === "image") return attachment.mimeType; - if (attachment.mimeType === "application/pdf") return attachment.mimeType; - // Everything else is sent as text; unsupported binary mimes poison - // server-side session history (see sync/attachment-support.ts). - return "text/plain"; + return resolveModelAttachmentMimeType(attachment.name, attachment.mimeType); } async function draftToParts(draft: ComposerDraft, workspaceRoot: string) { @@ -796,9 +793,9 @@ export function SessionRoute() { return list.filter((agent) => !agent.hidden && agent.mode !== "subagent"); }, [engineReloadVersion, opencodeClient]); - const handleOpenSettings = useCallback((route = "/settings/general", workspaceId = sidebarActiveWorkspaceId) => { + const handleOpenSettings = useCallback((route = "/settings/preferences", workspaceId = sidebarActiveWorkspaceId) => { const sessionId = workspaceId === sidebarActiveWorkspaceId ? selectedSessionId : null; - const tab = route.replace(/^\/settings\/?/, "").replace(/^\/+|\/+$/g, "") || "general"; + const tab = route.replace(/^\/settings\/?/, "").replace(/^\/+|\/+$/g, "") || "preferences"; const target = workspaceId ? workspaceSettingsRoute(workspaceId, tab) : route; writeActiveWorkspaceId(workspaceId || null); navigate(target, { state: { workspaceId, sessionId } }); @@ -861,7 +858,7 @@ export function SessionRoute() { }, providerConnectedCount: hasUsableModel ? 1 : providerConnectedIds.length, onOpenSettingsSection: (section: "commands" | "skills" | "mcps" | "plugins" | "providers") => { - handleOpenSettings(section === "skills" ? "/settings/skills" : section === "mcps" ? "/settings/extensions/mcp" : section === "plugins" ? "/settings/extensions/plugins" : section === "providers" ? "/settings/ai" : "/settings/general"); + handleOpenSettings(section === "skills" ? "/settings/skills" : section === "mcps" ? "/settings/extensions/mcp" : section === "plugins" ? "/settings/extensions/plugins" : section === "providers" ? "/settings/ai" : "/settings/preferences"); }, onSendDraft: async (draft: ComposerDraft, sessionId: string) => { const targetSessionId = sessionId.trim() || selectedSessionId; @@ -1970,7 +1967,7 @@ export function SessionRoute() { hasUsableModel={hasUsableModel} providers={providers} mcpConnectedCount={mcpConnectedCount} - onOpenSettings={() => handleOpenSettings("/settings/general")} + onOpenSettings={() => handleOpenSettings("/settings/preferences")} onOpenProviderAuth={() => sessionProviderAuthStore.openProviderAuthModal({ returnFocusTarget: "composer" })} providerAuthModal={sessionProviderAuthSnapshot.providerAuthModalOpen ? { open: true, @@ -2198,8 +2195,18 @@ export function SessionRoute() { if (!endpoint) return; await endpoint.client.deleteSession(endpoint.workspaceId, sessionId); if (selectedSessionId === sessionId) { - writeLastSessionFor(selectedWorkspaceId, null); - navigateToWorkspaceSession(selectedWorkspaceId); + const nextSession = (sessionsByWorkspaceId[selectedWorkspaceId] ?? []) + .filter((session) => { + if (session.id === sessionId) return false; + return !(typeof session.time?.archived === "number" && session.time.archived > 0); + }) + .sort( + (left, right) => + (right.time?.updated ?? right.time?.created ?? 0) - + (left.time?.updated ?? left.time?.created ?? 0), + )[0]; + writeLastSessionFor(selectedWorkspaceId, nextSession?.id ?? null); + navigateToWorkspaceSession(selectedWorkspaceId, nextSession?.id ?? null); } await refreshRouteState(); } @@ -2271,7 +2278,7 @@ export function SessionRoute() { } }} onOpenSession={(workspaceId, sessionId) => navigateToWorkspaceSession(workspaceId, sessionId)} - onOpenSettings={(route) => handleOpenSettings(route ?? "/settings/general")} + onOpenSettings={(route) => handleOpenSettings(route ?? "/settings/preferences")} onOpenModelPicker={() => { modelPicker.setQuery(""); modelPicker.setRecentProviderIds(new Set()); @@ -2345,7 +2352,7 @@ export function SessionRoute() { }} onOpenSettings={() => { modelPicker.setOpen(false); - handleOpenSettings("/settings/general"); + handleOpenSettings("/settings/preferences"); }} onClose={() => { modelPicker.setOpen(false); modelPicker.setRecentProviderIds(new Set()); }} /> diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx index a6d1b043e..4c6af998e 100644 --- a/apps/app/src/react-app/shell/settings-route.tsx +++ b/apps/app/src/react-app/shell/settings-route.tsx @@ -253,7 +253,7 @@ export function parseSettingsPath(pathname: string): { .replace(/^\/settings\/?/, "") .replace(/^\/+|\/+$/g, ""); if (!trimmed) { - return { tab: "general", redirectPath: "general" }; + return { tab: "preferences", redirectPath: "preferences" }; } const [head, tail] = trimmed.split("/"); @@ -286,7 +286,7 @@ export function parseSettingsPath(pathname: string): { if (tail === "plugins") return { tab: "extensions", redirectPath: null, extensionsSection: "plugins" }; return { tab: "extensions", redirectPath: null, extensionsSection: "all" }; default: - return { tab: "general", redirectPath: "general" }; + return { tab: "preferences", redirectPath: "preferences" }; } } @@ -357,7 +357,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { const restrictionNotice = useRestrictionNotice(); const desktopConfig = useDesktopConfig(); const reloadCoordinator = useReloadCoordinator(); - const [embeddedPath, setEmbeddedPath] = useState(props.initialPath ?? "general"); + const [embeddedPath, setEmbeddedPath] = useState(props.initialPath ?? "preferences"); const route = props.embedded ? parseSettingsPath(`/settings/${embeddedPath}`) : parseSettingsPath(location.pathname); const navigationWorkspaceId = readNavigationWorkspaceId(location.state); const navigationSessionId = readNavigationSessionId(location.state); diff --git a/apps/app/src/react-app/shell/welcome-route.tsx b/apps/app/src/react-app/shell/welcome-route.tsx index 789cf8842..6f95d9fc6 100644 --- a/apps/app/src/react-app/shell/welcome-route.tsx +++ b/apps/app/src/react-app/shell/welcome-route.tsx @@ -16,6 +16,7 @@ import { isDesktopRuntime } from "../../app/utils"; import { createClient, unwrap } from "../../app/lib/opencode"; import { useLocal } from "../kernel/local-provider"; import { usePlatform } from "../kernel/platform"; +import { CloudSignInComingSoonDialog } from "../domains/cloud/cloud-signin-coming-soon-dialog"; import { WelcomePage } from "../domains/onboarding/welcome-page"; import { ProviderSelectionStep } from "../domains/onboarding/provider-selection-step"; import { AttributionStep, type AttributionSource } from "../domains/onboarding/attribution-step"; @@ -30,7 +31,7 @@ import { useDenAuth } from "../domains/cloud/den-auth-provider"; import { resolveiPolloWorkConnection } from "./ipollowork-connection"; import { captureAnalyticsEvent } from "../../app/lib/analytics"; import { buildiPolloWorkWorkspaceBaseUrl, createiPolloWorkServerClient } from "../../app/lib/ipollowork-server"; -import { buildDenAuthUrl, clearDenSession, DEFAULT_DEN_BASE_URL, readDenSettings } from "../../app/lib/den"; +import { clearDenSession, readDenSettings } from "../../app/lib/den"; import { denSettingsChangedEvent, dispatchDenSessionUpdated, @@ -131,6 +132,7 @@ export function WelcomeRoute() { const [state, dispatch] = useReducer(welcomeReducer, initialWelcomeState); const [manualFolder, setManualFolder] = useState(""); const [organizationServerUrl, setOrganizationServerUrl] = useState(() => readDenSettings().baseUrl); + const [cloudSignInComingSoonOpen, setCloudSignInComingSoonOpen] = useState(false); const [organizationServerBusy, setOrganizationServerBusy] = useState(false); const [organizationServerError, setOrganizationServerError] = useState(null); @@ -352,9 +354,8 @@ export function WelcomeRoute() { }, [handleCreateWorkspace, manualFolder]); const handleTeamSignIn = useCallback(() => { - const settings = readDenSettings(); - platform.openLink(buildDenAuthUrl(settings.baseUrl || DEFAULT_DEN_BASE_URL, "sign-in")); - }, [platform]); + setCloudSignInComingSoonOpen(true); + }, []); const finishOnboarding = useCallback(() => { navigate(state.pendingRoute ?? "/session", { replace: true }); @@ -418,6 +419,10 @@ export function WelcomeRoute() { : t("app.local_disabled_reason") } /> + {state.providerStep ? ( { diff --git a/apps/app/src/react-app/shell/workspace-loading-state.tsx b/apps/app/src/react-app/shell/workspace-loading-state.tsx new file mode 100644 index 000000000..27d35c19d --- /dev/null +++ b/apps/app/src/react-app/shell/workspace-loading-state.tsx @@ -0,0 +1,26 @@ +/** @jsxImportSource react */ +import { publicAssetUrl } from "@/app/lib/public-asset"; + +type WorkspaceLoadingStateProps = { + message: string; + detail?: string | null; +}; + +export function WorkspaceLoadingState({ message, detail }: WorkspaceLoadingStateProps) { + return ( +
+ +
+ {message} +
+ {detail ?

{detail}

: null} +
+ ); +} diff --git a/apps/app/src/react-app/shell/workspace-routes.ts b/apps/app/src/react-app/shell/workspace-routes.ts index a44462feb..5b8be2cb2 100644 --- a/apps/app/src/react-app/shell/workspace-routes.ts +++ b/apps/app/src/react-app/shell/workspace-routes.ts @@ -10,7 +10,7 @@ export function workspaceSessionRoute(workspaceId: string, sessionId?: string | export function workspaceSettingsRoute( workspaceId: string, - tab: SettingsTab | "extensions/mcp" | "extensions/plugins" | string = "general", + tab: SettingsTab | "extensions/mcp" | "extensions/plugins" | string = "preferences", ) { return `/workspace/${encodeURIComponent(workspaceId.trim())}/settings/${tab}`; } diff --git a/apps/app/tests/attachment-support.test.ts b/apps/app/tests/attachment-support.test.ts new file mode 100644 index 000000000..9f49fedbc --- /dev/null +++ b/apps/app/tests/attachment-support.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; + +import { + isModelReadableAttachment, + MAX_MODEL_ATTACHMENT_BYTES, + resolveModelAttachmentMimeType, +} from "../src/react-app/domains/session/sync/attachment-support"; + +describe("model attachment support", () => { + test("uses a 25 MB per-file limit", () => { + expect(MAX_MODEL_ATTACHMENT_BYTES).toBe(25 * 1024 * 1024); + }); + + test("accepts common document, spreadsheet, and presentation formats", () => { + expect(isModelReadableAttachment("application/pdf", "brief.pdf")).toBe(true); + expect(isModelReadableAttachment("", "brief.docx")).toBe(true); + expect(isModelReadableAttachment("", "budget.xlsx")).toBe(true); + expect(isModelReadableAttachment("application/vnd.ms-powerpoint", "deck.ppt")).toBe(true); + expect(isModelReadableAttachment("", "deck.pptx")).toBe(true); + }); + + test("keeps unknown binary formats blocked", () => { + expect(isModelReadableAttachment("application/zip", "archive.zip")).toBe(false); + }); + + test("infers a useful MIME type when Finder does not provide one", () => { + expect(resolveModelAttachmentMimeType("deck.pptx", "application/octet-stream")).toBe( + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ); + expect(resolveModelAttachmentMimeType("notes.md", "")).toBe("text/plain"); + }); +}); diff --git a/apps/app/tests/cloud-signin-coming-soon-dialog.test.ts b/apps/app/tests/cloud-signin-coming-soon-dialog.test.ts new file mode 100644 index 000000000..ad91ac72f --- /dev/null +++ b/apps/app/tests/cloud-signin-coming-soon-dialog.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const appRoot = resolve(import.meta.dir, ".."); +const sessionPage = readFileSync( + resolve(appRoot, "src/react-app/domains/session/chat/session-page.tsx"), + "utf8", +); +const welcomeRoute = readFileSync( + resolve(appRoot, "src/react-app/shell/welcome-route.tsx"), + "utf8", +); +const dialog = readFileSync( + resolve(appRoot, "src/react-app/domains/cloud/cloud-signin-coming-soon-dialog.tsx"), + "utf8", +); + +test("each optional Cloud sign-in entry opens the local coming-soon dialog", () => { + expect(sessionPage).toContain('setCloudSignInComingSoonOpen(true)'); + expect(sessionPage).toContain(" { + test("routes Design HTML and native slide outputs to Design", () => { + expect(isDesignArtifact(artifact("design/session-1/entry.html", "html"))).toBe(true); + expect(isDesignArtifact(artifact("design/session-1/export.pptx", "slides"))).toBe(true); + expect(isDesignArtifact(artifact("design/session-1/design-tokens.css", "text"))).toBe(true); + }); + + test("routes video compositions and rendered videos to Video Studio", () => { + expect(isVideoStudioArtifact(artifact("video/session-1/entry.html", "html"))).toBe(true); + expect(isVideoStudioArtifact(artifact("video/session-1/render.mp4", "video"))).toBe(true); + expect(isVideoStudioArtifact(artifact("video/session-1/design-tokens.css", "text"))).toBe(false); + }); + + test("does not steal ordinary workspace files", () => { + expect(isDesignArtifact(artifact("src/design-notes.html", "html"))).toBe(false); + expect(isVideoStudioArtifact(artifact("exports/render.mp4", "video"))).toBe(false); + }); +}); diff --git a/apps/app/tests/design-deck-navigation.test.ts b/apps/app/tests/design-deck-navigation.test.ts index 12f61f88e..c17ce5c97 100644 --- a/apps/app/tests/design-deck-navigation.test.ts +++ b/apps/app/tests/design-deck-navigation.test.ts @@ -16,4 +16,18 @@ describe("Design deck navigation", () => { expect(source).toContain("presentationCanvasScale(previewViewport.width, previewViewport.height)"); expect(source).toContain("!isPresentationTemplate ? ("); }); -}); + + test("measures the viewport after the async preview has mounted", async () => { + const source = await Bun.file(panelUrl).text(); + + expect(source).toContain("}, [isPresentationTemplate, sourceHydrated]);"); + }); + + test("does not show a redundant current design subtitle", async () => { + const source = await Bun.file(panelUrl).text(); + + expect(source).not.toContain('"Current design"'); + expect(source).not.toContain('"Version preview"'); + expect(source).not.toContain('

{fileName(activePagePath)}

'); + }); +}); diff --git a/apps/app/tests/design-export-menu.test.ts b/apps/app/tests/design-export-menu.test.ts new file mode 100644 index 000000000..b2077b960 --- /dev/null +++ b/apps/app/tests/design-export-menu.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test"; + +const panelUrl = new URL("../src/react-app/domains/session/design/design-panel.tsx", import.meta.url); + +describe("Design export menu", () => { + test("keeps deck downloads and webpage publishing behind one compact menu", async () => { + const source = await Bun.file(panelUrl).text(); + + expect(source).toContain(""); + expect(source).toContain('t("design.download_pptx")'); + expect(source).toContain('t("design.download_pdf")'); + expect(source).toContain('t("design.publish_web")'); + expect(source).not.toContain('title="Export PPTX"'); + expect(source).not.toContain('title="Export PDF"'); + }); +}); diff --git a/apps/app/tests/design-pptx-export.test.ts b/apps/app/tests/design-pptx-export.test.ts index 568830d60..1d04ce0cc 100644 --- a/apps/app/tests/design-pptx-export.test.ts +++ b/apps/app/tests/design-pptx-export.test.ts @@ -103,6 +103,14 @@ describe("PPTX deck export", () => { expect(pptxExport).toContain("validatePptxElementPlanCoverage"); expect(pptxExport).toContain("No blank presentation was created."); }); + + test("stops a native-editable deck export instead of writing blank slides", async () => { + const source = await Bun.file(panelUrl).text(); + const pptxExport = source.slice(source.indexOf("const exportDeckToPptx"), source.indexOf("const saveMutation")); + + expect(pptxExport).toContain("const objectCoverage = validatePptxElementPlanCoverage"); + expect(pptxExport).toContain("planCount: objects.length"); + }); test("keeps native-editable exports separate from PDF color conversion", async () => { const source = await Bun.file(panelUrl).text(); diff --git a/apps/app/tests/design-preview-height.test.ts b/apps/app/tests/design-preview-height.test.ts new file mode 100644 index 000000000..acbec5b14 --- /dev/null +++ b/apps/app/tests/design-preview-height.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const panelSource = readFileSync( + path.resolve(import.meta.dir, "../src/react-app/domains/session/design/design-panel.tsx"), + "utf8", +); + +test("standard Design previews fill the available right-panel height", () => { + expect(panelSource).toContain('? "h-full w-full rounded-lg shadow-sm"'); + expect(panelSource).toContain('"h-full w-[390px] max-w-full shrink-0 rounded-[26px] shadow-xl shadow-black/15"'); + expect(panelSource).toContain('"absolute left-1/2 top-1/2 h-[900px] w-[1600px] origin-center rounded-lg shadow-sm"'); +}); diff --git a/apps/app/tests/presentation-canvas.test.ts b/apps/app/tests/presentation-canvas.test.ts index ed3f02cba..9905613b8 100644 --- a/apps/app/tests/presentation-canvas.test.ts +++ b/apps/app/tests/presentation-canvas.test.ts @@ -14,8 +14,9 @@ describe("Presentation canvas", () => { expect(presentationCanvasScale(2400, 900)).toBe(1); }); - test("waits for a real preview viewport before exposing the canvas", () => { - expect(presentationCanvasScale(0, 900)).toBe(0); - expect(presentationCanvasScale(1600, 0)).toBe(0); + test("keeps the canvas visible while the first viewport measurement is pending", () => { + expect(presentationCanvasScale(0, 900)).toBe(1); + expect(presentationCanvasScale(1600, 0)).toBe(1); + expect(presentationCanvasScale(0, 0)).toBe(1); }); }); diff --git a/apps/app/tests/settings-route.test.ts b/apps/app/tests/settings-route.test.ts index 07d43a7c4..50cbfa287 100644 --- a/apps/app/tests/settings-route.test.ts +++ b/apps/app/tests/settings-route.test.ts @@ -3,6 +3,11 @@ import { describe, expect, test } from "bun:test"; import { parseSettingsPath } from "../src/react-app/shell/settings-route"; describe("settings route parsing", () => { + test("redirects the settings root to preferences while keeping the overview route available", () => { + expect(parseSettingsPath("/settings")).toEqual({ tab: "preferences", redirectPath: "preferences" }); + expect(parseSettingsPath("/settings/general")).toEqual({ tab: "general", redirectPath: null }); + }); + test("recognizes the Connect settings tab", () => { expect(parseSettingsPath("/settings/connect")).toEqual({ tab: "connect", redirectPath: null }); expect(parseSettingsPath("/workspace/workspace_1/settings/connect")).toEqual({ diff --git a/apps/app/tests/video-artifact-routing.test.ts b/apps/app/tests/video-artifact-routing.test.ts new file mode 100644 index 000000000..a4fbc4b10 --- /dev/null +++ b/apps/app/tests/video-artifact-routing.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; + +const messageListUrl = new URL("../src/components/chat/message-list.tsx", import.meta.url); +const sessionSurfaceUrl = new URL("../src/react-app/domains/session/surface/session-surface.tsx", import.meta.url); +const sessionPageUrl = new URL("../src/react-app/domains/session/chat/session-page.tsx", import.meta.url); + +describe("Video artifact routing", () => { + test("keeps inline video artifacts on the Video Studio path", async () => { + const messageListSource = await Bun.file(messageListUrl).text(); + const sessionSurfaceSource = await Bun.file(sessionSurfaceUrl).text(); + const sessionPageSource = await Bun.file(sessionPageUrl).text(); + + expect(messageListSource).toContain(""); + expect(messageListSource).toContain("onOpenDesignStudio={onOpenDesignStudio}"); + expect(messageListSource).toContain("onOpenVideoStudio={onOpenVideoStudio}"); + expect(sessionSurfaceSource).toContain("onOpenDesignStudio?: () => void;"); + expect(sessionSurfaceSource).toContain("onOpenDesignStudio={props.onOpenDesignStudio}"); + expect(sessionSurfaceSource).toContain("onOpenVideoStudio?: () => void;"); + expect(sessionSurfaceSource).toContain("onOpenVideoStudio={props.onOpenVideoStudio}"); + expect(sessionPageSource).toContain("onOpenVideoStudio={openCurrentVideoStudio}"); + }); +}); diff --git a/apps/desktop/electron/main.mjs b/apps/desktop/electron/main.mjs index 9404342df..c8cb80ec3 100644 --- a/apps/desktop/electron/main.mjs +++ b/apps/desktop/electron/main.mjs @@ -2289,8 +2289,8 @@ async function createMainWindow() { } mainWindow = new BrowserWindow({ - width: 1180, - height: 820, + width: 1440, + height: 900, minWidth: MAIN_WINDOW_MIN_WIDTH, minHeight: MAIN_WINDOW_MIN_HEIGHT, title: currentDisplayAppName, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7d1aa7321..3ab25bc24 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@ipollowork/desktop", "private": true, - "version": "0.17.26", + "version": "0.17.30", "description": "iPolloWork desktop shell", "author": { "name": "iPolloWork", diff --git a/apps/orchestrator/package.json b/apps/orchestrator/package.json index 7c772211a..e5591ffc3 100644 --- a/apps/orchestrator/package.json +++ b/apps/orchestrator/package.json @@ -1,6 +1,6 @@ { "name": "ipollowork-orchestrator", - "version": "0.17.26", + "version": "0.17.30", "description": "iPolloWork host orchestrator for opencode + iPolloWork server", "private": true, "type": "module", @@ -46,7 +46,7 @@ "@opencode-ai/sdk": "^1.18.3", "@opentui/core": "0.1.77", "@opentui/solid": "0.1.77", - "ipollowork-server": "0.17.26", + "ipollowork-server": "0.17.30", "solid-js": "1.9.9" }, "devDependencies": { diff --git a/apps/server/package.json b/apps/server/package.json index 7d167ee74..91f184e8b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "ipollowork-server", - "version": "0.17.26", + "version": "0.17.30", "description": "Filesystem-backed API for iPolloWork remote clients", "type": "module", "bin": { diff --git a/apps/server/src/authorization-center.ts b/apps/server/src/authorization-center.ts index c285d6653..53677d76e 100644 --- a/apps/server/src/authorization-center.ts +++ b/apps/server/src/authorization-center.ts @@ -4,6 +4,7 @@ import { createAliyunOssV4Request, createS3V4Request } from "./object-storage-si export const AUTHORIZATION_SERVICE_IDS = [ "openai-images", "aliyun-bailian", + "baidu-speech", "volcengine-video", "aliyun-oss", "wasabi", @@ -47,10 +48,10 @@ const AUTHORIZATION_SERVICES: readonly AuthorizationServiceDefinition[] = [ keys: ["OPENAI_API_KEY"], category: "media", agent: { - capability: "OpenAI image generation", - useWhen: "Use when the user asks to create an image asset.", + capability: "OpenAI media and realtime voice", + useWhen: "Use when the user asks to create an image asset or use OpenAI realtime voice.", instruction: - "Prefer the iPolloWork openai-image-generation/image_generate extension when it is available so the PNG is saved as a workspace artifact. Otherwise use OPENAI_API_KEY only from trusted runtime code.", + "Prefer the iPolloWork openai-image-generation/image_generate extension for image assets. Use OPENAI_API_KEY only from trusted runtime code for image generation or realtime voice; never expose it to the renderer.", }, }, { @@ -64,6 +65,17 @@ const AUTHORIZATION_SERVICES: readonly AuthorizationServiceDefinition[] = [ "Use the iPolloWork media extension actions from trusted runtime code. They keep DASHSCOPE_API_KEY on this device and provide the supported media operations without modifying OpenCode.", }, }, + { + id: "baidu-speech", + keys: ["BAIDU_SPEECH_API_KEY", "BAIDU_SPEECH_SECRET_KEY"], + category: "media", + agent: { + capability: "Baidu Cloud speech media", + useWhen: "Use when the user asks for speech recognition or configures Baidu Cloud.", + instruction: + "Use BAIDU_SPEECH_API_KEY and BAIDU_SPEECH_SECRET_KEY only from trusted runtime code. Keep both credentials on this device and send audio through the server-side speech adapter.", + }, + }, { id: "volcengine-video", keys: ["ARK_API_KEY"], @@ -218,6 +230,11 @@ export async function testAuthorizationService( return fetchAuthorizationTest("https://dashscope.aliyuncs.com/compatible-mode/v1/models", { headers: { Authorization: `Bearer ${resolved.values.DASHSCOPE_API_KEY}` }, }); + case "baidu-speech": + return testBaiduSpeechAuthorization( + resolved.values.BAIDU_SPEECH_API_KEY, + resolved.values.BAIDU_SPEECH_SECRET_KEY, + ); case "volcengine-video": return fetchAuthorizationTest( "https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks?page_num=1&page_size=1", @@ -256,4 +273,35 @@ export async function testAuthorizationService( } } +async function testBaiduSpeechAuthorization(apiKey: string, secretKey: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + try { + const response = await fetch("https://aip.baidubce.com/oauth/2.0/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "client_credentials", + client_id: apiKey, + client_secret: secretKey, + }), + signal: controller.signal, + redirect: "error", + }); + const payload = await response.json().catch(() => null) as { access_token?: unknown; error_description?: unknown } | null; + if (response.ok && typeof payload?.access_token === "string" && payload.access_token.length > 0) { + return { ok: true, detail: "Connection verified." }; + } + const detail = typeof payload?.error_description === "string" ? payload.error_description : `The service rejected this authorization (HTTP ${response.status}).`; + return { ok: false, detail }; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + return { ok: false, detail: "The connection test timed out." }; + } + return { ok: false, detail: "Could not reach the service. Check your network and try again." }; + } finally { + clearTimeout(timeout); + } +} + export const __test__ = { createAliyunOssV4Request, createS3V4Request }; diff --git a/apps/server/src/env-routes.e2e.test.ts b/apps/server/src/env-routes.e2e.test.ts index 2066de57f..80f1d937d 100644 --- a/apps/server/src/env-routes.e2e.test.ts +++ b/apps/server/src/env-routes.e2e.test.ts @@ -264,9 +264,16 @@ describe("env routes", () => { configured: true, fields: [{ key: "OPENAI_API_KEY", configured: true }], agent: { - capability: "OpenAI image generation", + capability: "OpenAI media and realtime voice", }, }); + expect(catalog.items.find((item) => item.id === "baidu-speech")).toMatchObject({ + configured: false, + fields: [ + { key: "BAIDU_SPEECH_API_KEY", configured: false }, + { key: "BAIDU_SPEECH_SECRET_KEY", configured: false }, + ], + }); expect(catalog.items.find((item) => item.id === "aliyun-oss")?.configured).toBe(false); }); @@ -295,6 +302,36 @@ describe("env routes", () => { expect(await response.json()).toEqual({ ok: true, detail: "Connection verified." }); }); + test("speech input uses the existing Alibaba authorization", async () => { + const { base } = await boot(); + await fetch(`${base}/env`, { + method: "PUT", + headers: hostAuth(), + body: JSON.stringify({ + entries: [ + { key: "DASHSCOPE_API_KEY", value: "dashscope-secret" }, + ], + }), + }); + + globalThis.fetch = ((input, init) => { + if (String(input) === "https://dashscope.aliyuncs.com/compatible-mode/v1/models") { + expect(init?.headers).toMatchObject({ Authorization: "Bearer dashscope-secret" }); + return Promise.resolve(new Response(JSON.stringify({ object: "list", data: [] }), { status: 200 })); + } + return nativeFetch(input, init); + }) as typeof fetch; + + const mediaTest = await fetch(`${base}/authorization-services/aliyun-bailian/test`, { + method: "POST", + headers: hostAuth(), + body: "{}", + }); + expect(mediaTest.status).toBe(200); + expect(await mediaTest.json()).toEqual({ ok: true, detail: "Connection verified." }); + + }); + test("OSS authorization test signs a read-only bucket listing with V4", async () => { const { base } = await boot(); await fetch(`${base}/env`, { diff --git a/docs/assets/ipollowork-demo-poster.jpg b/docs/assets/ipollowork-demo-poster.jpg new file mode 100644 index 000000000..8e031995f Binary files /dev/null and b/docs/assets/ipollowork-demo-poster.jpg differ diff --git a/docs/assets/ipollowork-demo.mp4 b/docs/assets/ipollowork-demo.mp4 new file mode 100644 index 000000000..7a2f1a4b5 Binary files /dev/null and b/docs/assets/ipollowork-demo.mp4 differ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e50c6298..7264f992e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -287,7 +287,7 @@ importers: specifier: 0.1.77 version: 0.1.77(solid-js@1.9.9)(stage-js@1.0.0-alpha.17)(typescript@5.9.3)(web-tree-sitter@0.25.10) ipollowork-server: - specifier: 0.17.26 + specifier: 0.17.30 version: link:../server solid-js: specifier: 1.9.9 diff --git a/translated_readmes/README_JA.md b/translated_readmes/README_JA.md index 6911308b2..a448f3e35 100644 --- a/translated_readmes/README_JA.md +++ b/translated_readmes/README_JA.md @@ -1,3 +1,35 @@ # iPolloWork -**iPolloWork は、Codex と Claude Code に代わる次世代のソースアベイラブルなワークスペースです。** コーディングだけでなく、オフィス作業、Web サイト、プレゼンテーション、デザイン、動画まで一つのローカルファースト環境で扱えます。AI が生成した後も、PowerPoint のようにテキスト、画像、色、書体、レイアウト、動画シーンを直接編集できます。個人、非商用、社内利用は無料で、外部向け商用利用には書面による許諾が必要です。詳細は[英語のメインドキュメント](../README.md)を参照してください。 +

+ English · 简体中文 · 繁體中文 · 日本語 +

+ +

+ + iPolloWork のデモを見る + +

+ +

+ ▶ iPolloWork のデモを見る +

+ +> **AI がつくり、動かし、成果物を編集可能なままにする。** + +**ターミナルの外にある仕事のための、ソースアベイラブルでローカルファーストな Codex 代替です。** + +iPolloWork は、リポジトリ、ローカルファイル、ブラウザ操作、ドキュメント、プレゼンテーション、Web サイト、デザイン、動画を一つのワークスペースで扱える AI エージェント環境です。目的を伝えると、エージェントが計画して実行します。ユーザーは手順を確認し、操作を承認し、同じ場所で成果物を編集し続けられます。 + +Codex のようなコーディングは出発点にすぎません。成果物がスライド、Web ページ、ビジュアルデザイン、動画になっても、完成ファイルやチャットの回答で終わらず、そのまま編集できます。 + +## iPolloWork の違い + +- **エージェント中心の実行** — 作業を計画し、ツールを使い、ファイルを読み書きし、コマンドを実行し、現在の状態から作業を続けます。 +- **編集可能な成果物** — コードからドキュメント、Web サイト、スライド、デザイン、動画まで、生成後もテキスト、画像、レイアウト、シーンを変更できます。 +- **ローカルで管理** — 自分の端末で実行し、任意のモデルやプロバイダーを接続し、権限を承認し、Skills、プラグイン、MCP サーバー、ブラウザ自動化で拡張できます。 + +## 簡単に言うと + +iPolloWork は、Codex のようなエージェントに、可視化されたローカルファーストの作業環境を組み合わせたものです。チャットだけのラッパーではなく、ローカル利用に iPolloCloud アカウントは必要ありません。 + +このリポジトリには Work クライアントとローカル実行環境が含まれます。詳しいインストール方法、開発コマンド、アーキテクチャ、ライセンスについては[英語のメインドキュメント](../README.md)を参照してください。個人利用や小規模な社内利用の条件、商用利用の許諾条件も同ドキュメントに記載しています。 diff --git a/translated_readmes/README_ZH.md b/translated_readmes/README_ZH.md index 6a6d4271e..218cb32da 100644 --- a/translated_readmes/README_ZH.md +++ b/translated_readmes/README_ZH.md @@ -4,22 +4,33 @@ English · 简体中文 · 繁體中文 · 日本語

-> **让智能体创造,让每一个结果都能继续编辑。** +

+ + 观看 iPolloWork 演示 + +

+ +

+ ▶ 观看 iPolloWork 演示 +

+ +> **让 AI 执行,让结果始终可编辑。** + +**面向终端之外真实工作的源码可用、本地优先 Codex 平替。** + +iPolloWork 让 AI 智能体在一个工作空间里处理代码仓库、本地文件、浏览器任务、文档、演示稿、网站、设计和视频。你描述目标,智能体负责规划和执行;你可以检查过程、批准操作,并在同一个地方继续编辑结果。 -**Codex 与 Claude Code 的下一代源码开放平替——不只帮你写代码,更直接完成真正的工作。** +Codex 式编码只是起点。当结果变成演示稿、网页、视觉设计或视频时,iPolloWork 仍然让它保持可编辑,而不是只交付一个成品文件或一段聊天记录。 -iPolloWork 把 AI 智能体变成一个完整的可视化工作区:代码、办公、网站、幻灯片、设计和视频,都能在同一个任务里完成。你只需要描述目标,让智能体先生成,再亲手继续修改——原位改文字、换图片、调颜色和字体、拖动和缩放元素、切换电脑与手机预览,或者在时间线上调整视频画面,像修改 PowerPoint 一样直观。 +## 它真正解决的三件事 -它不是又一个套着对话框的 AI 工具。iPolloWork 把对话、文件、浏览器、可编辑画布、设计工具、视频工作室、任务记录、权限、Skills、插件和 MCP 真正整合进一个本地优先的桌面工作空间。 +- **智能体执行** — 规划工作、调用工具、读写文件、运行命令,并从当前状态继续推进。 +- **结果可编辑** — 从代码延伸到文档、网站、演示稿、设计和视频;生成之后,文字、图片、布局和画面仍能继续修改。 +- **本地可控** — 在自己的设备上运行,接入自己的模型或服务商,逐项批准权限,并通过 Skills、插件、MCP 服务和浏览器自动化扩展能力。 -## 一个智能体工作区,完成所有类型的工作 +## 一句话理解 -- **Code** — 理解代码仓库、规划修改、编写代码、调用工具并检查结果,拥有完整的智能体开发流程。 -- **办公** — 调研、撰写文档、处理表格并制作精美幻灯片,不再停留在一段文字回答。 -- **设计** — 生成网站、幻灯片和视觉内容,再在画布上直接修改文字、图片、颜色、字体、布局和响应式效果。 -- **视频** — 在内置 Studio 中生成和调整视觉画面,直接编辑内容并使用时间线完成剪辑。 -- **自由扩展** — 接入不同模型,通过 Skills、插件、MCP 和浏览器自动化扩展智能体能力。 -- **本地优先** — 支持 macOS、Windows 和 Linux,直接处理本地文件;可使用桌面端、浏览器 UI 或无界面 Server,需要团队与商业能力时再连接 iPolloCloud。 +iPolloWork 是一个围绕 Codex 式智能体构建的可视化、本地优先工作空间。它不是只会聊天的包装层,本地使用也不要求 iPolloCloud 账号。 源码可用仓库只包含 Work 客户端及本地运行能力。账号、组织管理、托管 Worker、支付、管理后台和移动 App 属于独立的 iPolloCloud,不影响 Work 单独使用。 diff --git a/translated_readmes/README_ZH_hk.md b/translated_readmes/README_ZH_hk.md index 8ccdd46f6..7f1fdd091 100644 --- a/translated_readmes/README_ZH_hk.md +++ b/translated_readmes/README_ZH_hk.md @@ -6,22 +6,33 @@ English · 简体中文 · 繁體中文 · 日本語

-> **讓智能體創造,讓每一個結果都能繼續編輯。** +

+ + 觀看 iPolloWork 演示 + +

+ +

+ ▶ 觀看 iPolloWork 演示 +

+ +> **讓 AI 執行,讓結果始終可編輯。** + +**面向終端之外真實工作的源碼可用、本地優先 Codex 平替。** + +iPolloWork 讓 AI 智能體在一個工作空間裏處理代碼倉庫、本地文件、瀏覽器任務、文檔、演示稿、網站、設計和視頻。你描述目標,智能體負責規劃和執行;你可以檢查過程、批准操作,並在同一個地方繼續編輯結果。 -**Codex 與 Claude Code 的下一代源碼開放平替——不只幫你寫代碼,更直接完成真正的工作。** +Codex 式編碼只是起點。當結果變成演示稿、網頁、視覺設計或視頻時,iPolloWork 仍然讓它保持可編輯,而不是隻交付一個成品文件或一段聊天記錄。 -iPolloWork 把 AI 智能體變成一個完整的可視化工作區:代碼、辦公、網站、幻燈片、設計和視頻,都能在同一個任務裏完成。你只需要描述目標,讓智能體先生成,再親手繼續修改——原位改文字、換圖片、調顏色和字體、拖動和縮放元素、切換電腦與手機預覽,或者在時間線上調整視頻畫面,像修改 PowerPoint 一樣直觀。 +## 它真正解決的三件事 -它不是又一個套着對話框的 AI 工具。iPolloWork 把對話、文件、瀏覽器、可編輯畫布、設計工具、視頻工作室、任務記錄、權限、Skills、插件和 MCP 真正整合進一個本地優先的桌面工作空間。 +- **智能體執行** — 規劃工作、調用工具、讀寫文件、運行命令,並從當前狀態繼續推進。 +- **結果可編輯** — 從代碼延伸到文檔、網站、演示稿、設計和視頻;生成之後,文字、圖片、佈局和畫面仍能繼續修改。 +- **本地可控** — 在自己的設備上運行,接入自己的模型或服務商,逐項批准權限,並通過 Skills、插件、MCP 服務和瀏覽器自動化擴展能力。 -## 一個智能體工作區,完成所有類型的工作 +## 一句話理解 -- **Code** — 理解代碼倉庫、規劃修改、編寫代碼、調用工具並檢查結果,擁有完整的智能體開發流程。 -- **辦公** — 調研、撰寫文檔、處理表格並製作精美幻燈片,不再停留在一段文字回答。 -- **設計** — 生成網站、幻燈片和視覺內容,再在畫布上直接修改文字、圖片、顏色、字體、佈局和響應式效果。 -- **視頻** — 在內置 Studio 中生成和調整視覺畫面,直接編輯內容並使用時間線完成剪輯。 -- **自由擴展** — 接入不同模型,通過 Skills、插件、MCP 和瀏覽器自動化擴展智能體能力。 -- **本地優先** — 支持 macOS、Windows 和 Linux,直接處理本地文件;可使用桌面端、瀏覽器 UI 或無界面 Server,需要團隊與商業能力時再連接 iPolloCloud。 +iPolloWork 是一個圍繞 Codex 式智能體構建的可視化、本地優先工作空間。它不是隻會聊天的包裝層,本地使用也不要求 iPolloCloud 賬號。 源碼可用倉庫只包含 Work 客户端及本地運行能力。賬號、組織管理、託管 Worker、支付、管理後台和移動 App 屬於獨立的 iPolloCloud,不影響 Work 單獨使用。