Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions apps/desktop/src/main/__tests__/attachment-input-notice.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { LlmConnection } from '@maka/core/llm-connections';
import { shouldShowImageAttachmentNotDirectNotice } from '../../renderer/attachment-input-notice.js';

function connection(vision: boolean | undefined): LlmConnection {
return {
slug: 'test',
name: 'Test',
providerType: 'openai-compatible',
defaultModel: 'model',
enabled: true,
createdAt: 0,
updatedAt: 0,
models: [{ id: 'model', capabilities: vision === undefined ? undefined : { vision } }],
};
}

test('shows an advisory notice only for images on a non-vision model', () => {
const target = { connectionSlug: 'test', model: 'model' };
assert.equal(
shouldShowImageAttachmentNotDirectNotice({
attachments: [{ kind: 'image' }],
target,
connections: [connection(false)],
}),
true,
);
assert.equal(
shouldShowImageAttachmentNotDirectNotice({
attachments: [{ kind: 'image' }],
target,
connections: [connection(true)],
}),
false,
);
assert.equal(
shouldShowImageAttachmentNotDirectNotice({
attachments: [{ kind: 'other' }],
target,
connections: [connection(false)],
}),
false,
);
});
22 changes: 22 additions & 0 deletions apps/desktop/src/main/__tests__/new-task-staged-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,28 @@ test('a completing send clears the attachments it submitted', async () => {
assert.equal(probe.latest().pendingAttachments.length, 0);
});

test('reports newly staged image attachments without changing their staging behavior', async () => {
const stagedKinds: string[][] = [];
const probe = await mountProbe((options) =>
useComposerAttachments({
...options,
toastApi: { error() {} },
service: idleAttachmentService,
onAttachmentsStaged: (attachments) => stagedKinds.push(attachments.map((attachment) => attachment.kind)),
}),
);

await probe.render(NEW_TASK_PENDING_KEY);
await act(() =>
probe.latest().attachFilePaths([
{ name: 'chart.png', type: 'image/png', size: 12 } as unknown as File,
]),
);

assert.deepEqual(stagedKinds, [['image']]);
assert.equal(probe.latest().pendingAttachments[0]?.kind, 'image');
});

test('retracted queue attachments can be restored and submitted without re-ingest', async () => {
const probe = await mountProbe((options) =>
useComposerAttachments({
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ import { useShellConnections } from './use-shell-connections';
import { useShellChatModel } from './use-shell-chat-model';
import { useShellLiveTurn } from './use-shell-live-turn';
import { useShellResume } from './use-shell-resume';
import { shouldShowImageAttachmentNotDirectNotice } from './attachment-input-notice';

function rebaseWorkspaceFileReferences(
sourceText: string,
Expand Down Expand Up @@ -403,6 +404,16 @@ function AppShellContent({
draftKey: attachmentDraftKey,
toastApi,
service: window.maka.attachments,
onAttachmentsStaged(staged) {
const target = activeSession
? { connectionSlug: activeSession.llmConnectionSlug, model: activeSession.model }
: newChatModel
? { connectionSlug: newChatModel.llmConnectionSlug, model: newChatModel.model }
: undefined;
if (!shouldShowImageAttachmentNotDirectNotice({ attachments: staged, target, connections })) return;
const copy = getDesktopConversationCopy(uiLocale).actions;
toastApi.info(copy.imageAttachmentNotDirectTitle, copy.imageAttachmentNotDirectDescription);
},
});
const {
pendingQuotes,
Expand Down
46 changes: 46 additions & 0 deletions apps/desktop/src/renderer/attachment-input-notice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { resolveModelVisionSupport } from '@maka/core/model-metadata';
import { relayModelProfile } from '@maka/core/model-thinking';
import type { LlmConnection } from '@maka/core/llm-connections';

type AttachmentCandidate = { kind: string };

/**
* Whether Desktop should explain that a newly staged image will not be
* delivered as a native provider image part. This is advisory only: the
* attachment remains staged and model-facing Read references remain intact.
*/
export function shouldShowImageAttachmentNotDirectNotice(input: {
attachments: readonly AttachmentCandidate[];
target: { connectionSlug: string; model: string } | undefined;
connections: readonly LlmConnection[];
}): boolean {
if (!input.attachments.some((attachment) => attachment.kind === 'image')) return false;
if (!input.target) return false;
const connection = input.connections.find((entry) => entry.slug === input.target!.connectionSlug);
if (!connection) return false;
return !resolveModelVisionSupport(
connection.providerType,
connection.models,
input.target.model,
relayModelProfile(connection, input.target.model)?.vision,
);
}
6 changes: 4 additions & 2 deletions apps/desktop/src/renderer/locales/conversation-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export interface DesktopConversationCopy {
operationFailedTitle: string;
operationFailedFallback: string;
attachmentFailedTitle: string;
imageAttachmentNotDirectTitle: string;
imageAttachmentNotDirectDescription: string;
tryAgain: string;
modelReboundTitle: string;
modelReboundDescription: (modelId?: string) => string;
Expand Down Expand Up @@ -416,7 +418,7 @@ function enDetail(parts: readonly string[]): string {

const COPY = {
zh: {
actions: { stopFailedTitle: '停止失败', stopFailedFallback: '任务操作失败,请稍后重试。', refreshSessionsFailedTitle: '刷新任务列表失败', refreshSessionsFailedFallback: '刷新任务列表失败,请稍后重试。', conversationErrorTitle: '任务出错', conversationErrorFallback: '任务运行失败,请稍后重试。', regenerateStartedTitle: '已发起重新生成', regenerateStartedDescription: '正在生成新的一轮回答', branchCreatedTitle: '已创建分支', branchCreatedDescription: (name) => `新任务 ${name}`, revisionStartedTitle: '已创建修改版草稿', revisionStartedDescription: '原任务仍会保留;修改后发送将在新版本中继续', revisionReadyTitle: '可以修改并重发了', revisionReadyDescription: '已回到该消息之前;编辑后发送即可', revisionUnavailableTitle: '暂时无法编辑这条消息', revisionAttachmentsUnsupported: '包含附件的历史消息暂不支持编辑并重发,请复制文字后新建消息。', revisionTransformedTextUnsupported: '通过显式技能发送的历史消息暂不支持编辑并重发,请复制文字后重新选择技能。', revisionDraftAttachmentConflict: 'Composer 中已有待发送附件,请先发送或移除附件,再编辑历史消息。', revisionCommandUnsupported: '修改消息时不能执行 /compact、/side 或编排命令,请取消修改后再试。', revisionAlreadyActive: '已有一条消息正在修改,请先发送或取消当前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已发送消息', revisionBannerDetail: '· 发送后创建新版本', revisionUnchanged: '内容没有变化。如需重新回答,请使用“重新生成”。', operationFailedTitle: '操作失败', operationFailedFallback: '任务操作失败,请稍后重试。', attachmentFailedTitle: '添加附件失败', tryAgain: '请稍后重试。', modelReboundTitle: '已切换到可用模型', modelReboundDescription: (modelId) => `原任务使用的连接已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '读取任务失败', returnLatest: '返回最新消息', scrollMainToBottom: '滚动主对话到底部' },
actions: { stopFailedTitle: '停止失败', stopFailedFallback: '任务操作失败,请稍后重试。', refreshSessionsFailedTitle: '刷新任务列表失败', refreshSessionsFailedFallback: '刷新任务列表失败,请稍后重试。', conversationErrorTitle: '任务出错', conversationErrorFallback: '任务运行失败,请稍后重试。', regenerateStartedTitle: '已发起重新生成', regenerateStartedDescription: '正在生成新的一轮回答', branchCreatedTitle: '已创建分支', branchCreatedDescription: (name) => `新任务 ${name}`, revisionStartedTitle: '已创建修改版草稿', revisionStartedDescription: '原任务仍会保留;修改后发送将在新版本中继续', revisionReadyTitle: '可以修改并重发了', revisionReadyDescription: '已回到该消息之前;编辑后发送即可', revisionUnavailableTitle: '暂时无法编辑这条消息', revisionAttachmentsUnsupported: '包含附件的历史消息暂不支持编辑并重发,请复制文字后新建消息。', revisionTransformedTextUnsupported: '通过显式技能发送的历史消息暂不支持编辑并重发,请复制文字后重新选择技能。', revisionDraftAttachmentConflict: 'Composer 中已有待发送附件,请先发送或移除附件,再编辑历史消息。', revisionCommandUnsupported: '修改消息时不能执行 /compact、/side 或编排命令,请取消修改后再试。', revisionAlreadyActive: '已有一条消息正在修改,请先发送或取消当前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已发送消息', revisionBannerDetail: '· 发送后创建新版本', revisionUnchanged: '内容没有变化。如需重新回答,请使用“重新生成”。', operationFailedTitle: '操作失败', operationFailedFallback: '任务操作失败,请稍后重试。', attachmentFailedTitle: '添加附件失败', imageAttachmentNotDirectTitle: '图片已作为附件添加', imageAttachmentNotDirectDescription: '当前模型不会直接接收图片。图片已作为附件提供给模型。', tryAgain: '请稍后重试。', modelReboundTitle: '已切换到可用模型', modelReboundDescription: (modelId) => `原任务使用的连接已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '读取任务失败', returnLatest: '返回最新消息', scrollMainToBottom: '滚动主对话到底部' },
attachments: { tooMany: '附件数量超过 8 个', tooLarge: '附件大小超过 50MB', duplicate: '附件来源重复,请勿重复添加同一文件。' },
model: {
fakeBackendLabel: '本地模拟连接',
Expand Down Expand Up @@ -641,7 +643,7 @@ const COPY = {
turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', contextBudgetExhausted: '上下文已达到上限,当前任务无法继续', malformedSummary: '上下文压缩未能生成有效摘要。请检查模型的上下文窗口设置、切换模型,或开启新任务。', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', providerCapacity: '模型服务暂时满载,请稍后重试或切换模型', rateLimit: '触发模型速率限制', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', partial: '已保留部分输出,可从这里继续', toolRecord: '工具记录已保留,重试前先看结果', retry: '没有执行工具,可直接重试', capacity: '模型服务暂时满载,请等待几分钟或切换模型后重试', contextOverflow: '上下文仍超出限制,请减少附件或开启新任务', contextBudgetExhausted: '检查模型的上下文窗口设置、切换模型,或开启新任务', sandboxBoundaryClosed: '访问范围没有放开,重试本轮后可重新决定' } },
},
en: {
actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The task action failed. Try again later.', refreshSessionsFailedTitle: 'Failed to refresh tasks', refreshSessionsFailedFallback: 'The task list could not be refreshed. Try again later.', conversationErrorTitle: 'Task error', conversationErrorFallback: 'The task run failed. Try again later.', regenerateStartedTitle: 'Regeneration started', regenerateStartedDescription: 'Generating a new response', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New task: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original task is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; edit and send when ready', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: 'Edit & resend does not yet support historical attachments. Copy the text into a new message instead.', revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', revisionUnchanged: 'Nothing changed. Use Regenerate if you only want a new answer.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', returnLatest: 'Return to latest', scrollMainToBottom: 'Scroll main conversation to bottom' },
actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The task action failed. Try again later.', refreshSessionsFailedTitle: 'Failed to refresh tasks', refreshSessionsFailedFallback: 'The task list could not be refreshed. Try again later.', conversationErrorTitle: 'Task error', conversationErrorFallback: 'The task run failed. Try again later.', regenerateStartedTitle: 'Regeneration started', regenerateStartedDescription: 'Generating a new response', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New task: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original task is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; edit and send when ready', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: 'Edit & resend does not yet support historical attachments. Copy the text into a new message instead.', revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', revisionUnchanged: 'Nothing changed. Use Regenerate if you only want a new answer.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', imageAttachmentNotDirectTitle: 'Image added as an attachment', imageAttachmentNotDirectDescription: 'The current model does not receive images directly. The image has been provided as an attachment.', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', returnLatest: 'Return to latest', scrollMainToBottom: 'Scroll main conversation to bottom' },
attachments: { tooMany: 'You can attach at most 8 files', tooLarge: 'Attachments must be 50 MB or smaller', duplicate: 'This attachment was already added.' },
model: {
fakeBackendLabel: 'Local simulation',
Expand Down
10 changes: 9 additions & 1 deletion apps/desktop/src/renderer/use-composer-attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ export function useComposerAttachments(options: {
draftKey: string;
toastApi: ToastApi;
service: ComposerAttachmentService;
/** Called after new items enter the visible composer draft. */
onAttachmentsStaged?: (attachments: readonly PendingAttachment[]) => void;
}) {
const uiLocale = useUiLocale();
const copy = getDesktopConversationCopy(uiLocale).actions;
Expand All @@ -136,9 +138,13 @@ export function useComposerAttachments(options: {
// The live staging key, for the one import that resolves long after it was
// started: the native file dialog. See pickAttachments.
const draftKeyRef = useRef(options.draftKey);
// The model target can change while the native dialog is open. Keep this
// notification callback live for the same reason the draft owner is live.
const onAttachmentsStagedRef = useRef(options.onAttachmentsStaged);
useEffect(() => {
draftKeyRef.current = options.draftKey;
}, [options.draftKey]);
onAttachmentsStagedRef.current = options.onAttachmentsStaged;
}, [options.draftKey, options.onAttachmentsStaged]);
const stagedAttachments = selectPending(pendingByKey, options.draftKey);
const pendingAttachments = useMemo(
() =>
Expand Down Expand Up @@ -219,6 +225,7 @@ export function useComposerAttachments(options: {
const staged = result.files.map(approvalToPending);
setPendingByKey((map) => appendPending(map, ownerKey, staged));
for (const item of staged) stagedKeysRef.current.add(item.stagingKey);
onAttachmentsStagedRef.current?.(staged);
void loadPreviewsSequentially(staged);
} catch (error) {
options.toastApi.error(
Expand All @@ -234,6 +241,7 @@ export function useComposerAttachments(options: {
const staged = files.map(fileToPending);
setPendingByKey((map) => appendPending(map, ownerKey, staged));
for (const item of staged) stagedKeysRef.current.add(item.stagingKey);
onAttachmentsStagedRef.current?.(staged);
void loadPreviewsSequentially(staged);
}

Expand Down
Loading
Loading