@steerable/agent-shell-web 0.6.53 → 0.6.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -4
- package/package.json +4 -4
- package/src/AppShell.tsx +3 -3
- package/src/brand.ts +1 -1
- package/src/components/AgentSidebar.tsx +64 -56
- package/src/components/ChatHeader.tsx +41 -25
- package/src/components/ConfirmDialog.tsx +4 -3
- package/src/components/CreateProjectModal.tsx +31 -21
- package/src/components/LocalLlmSettingsModal.tsx +4 -3
- package/src/components/SidebarRelease.tsx +6 -5
- package/src/components/TerminalPanel.tsx +6 -5
- package/src/components/TerminalView.tsx +15 -10
- package/src/components/chat/ApprovalModal.tsx +40 -26
- package/src/components/chat/ApprovalPromptProvider.tsx +5 -5
- package/src/components/chat/AskUserPromptProvider.tsx +4 -4
- package/src/components/chat/AskUserQuestionMenu.tsx +19 -14
- package/src/components/chat/AssistantMessage.tsx +18 -13
- package/src/components/chat/ChatInput.tsx +56 -40
- package/src/components/chat/ChatProjectBadge.tsx +27 -22
- package/src/components/chat/EmptyChatHero.tsx +2 -1
- package/src/components/chat/ExecPolicyPicker.tsx +12 -11
- package/src/components/chat/ExecutedActionsCard.tsx +45 -33
- package/src/components/chat/FilePathCode.tsx +8 -3
- package/src/components/chat/InterruptedTurnCard.tsx +4 -3
- package/src/components/chat/LocalChatPanel.tsx +2 -1
- package/src/components/chat/Markdown.tsx +2 -1
- package/src/components/chat/MessageList.tsx +3 -2
- package/src/components/chat/ModelPicker.tsx +25 -16
- package/src/components/chat/OrchestrationChildrenCard.tsx +2 -1
- package/src/components/chat/OrchestrationFlowCard.tsx +23 -22
- package/src/components/chat/PlanTodoList.tsx +2 -1
- package/src/components/chat/ProjectTrustBanner.tsx +19 -5
- package/src/components/chat/SessionOrchestrationFlow.tsx +27 -26
- package/src/components/chat/SessionTodoList.tsx +8 -7
- package/src/components/chat/SessionTreeModal.tsx +10 -9
- package/src/components/chat/StreamingStatus.tsx +4 -3
- package/src/components/chat/SuggestedReplies.tsx +3 -2
- package/src/components/chat/TaskOutcomeCards.tsx +8 -5
- package/src/components/chat/TaskPanelModal.tsx +33 -23
- package/src/components/chat/TaskProcessPanel.tsx +16 -15
- package/src/components/chat/TurnFilesCard.tsx +14 -13
- package/src/components/chat/UserMessage.tsx +12 -11
- package/src/components/chat/executed-actions-model.ts +47 -35
- package/src/components/chat/orchestration-flow-model.ts +16 -5
- package/src/components/chat/process-status.ts +8 -7
- package/src/components/chat/todo-list-model.ts +5 -4
- package/src/components/chat/turn-files.ts +8 -8
- package/src/components/chat/useChatTasks.ts +3 -3
- package/src/components/settings/AgentCapabilityFields.tsx +26 -23
- package/src/components/settings/AgentsSettingsPanel.tsx +33 -28
- package/src/components/settings/AppUpdateSettingsPanel.tsx +3 -2
- package/src/components/settings/AppearanceSettingsPanel.tsx +81 -37
- package/src/components/settings/DiagnoseSettingsPanel.tsx +11 -8
- package/src/components/settings/InsightsSettingsPanel.tsx +58 -43
- package/src/components/settings/LlmSettingsPanel.tsx +107 -86
- package/src/components/settings/McpSettingsPanel.tsx +72 -46
- package/src/components/settings/ModelIdCombobox.tsx +2 -1
- package/src/components/settings/OrchestrationSettingsPanel.tsx +8 -5
- package/src/components/settings/PortableSettingsPanel.tsx +83 -55
- package/src/components/settings/PythonRunnerSettingsPanel.tsx +26 -23
- package/src/components/settings/SecuritySettingsPanel.tsx +58 -34
- package/src/components/settings/SettingsNavMenu.tsx +3 -2
- package/src/components/settings/SettingsSaveButton.tsx +2 -1
- package/src/components/settings/SkillsSettingsPanel.tsx +43 -38
- package/src/components/settings/TelemetrySettingsPanel.tsx +20 -17
- package/src/components/settings/UsagePanel.tsx +22 -23
- package/src/components/settings/WebSearchSettingsPanel.tsx +28 -19
- package/src/components/settings/llm-vendors.ts +12 -7
- package/src/components/settings/model-capabilities.ts +16 -15
- package/src/components/settings/preset-choice-model.ts +6 -3
- package/src/fixtures/browser-dev-data.json +24 -24
- package/src/hooks/useChatsAndAgents.ts +9 -9
- package/src/hooks/useProjects.ts +3 -3
- package/src/i18n/agent-label.ts +15 -0
- package/src/i18n/index.tsx +68 -0
- package/src/i18n/runtime.ts +117 -0
- package/src/i18n/translate.ts +65 -0
- package/src/layouts/AgentLayout.tsx +13 -12
- package/src/lib/app-release.ts +16 -11
- package/src/lib/attachments.ts +18 -9
- package/src/lib/{browser-dev-electron-mock.ts → browser-dev-host-mock.ts} +42 -42
- package/src/lib/chat-title.ts +3 -2
- package/src/lib/chat-transport.ts +16 -12
- package/src/lib/date-groups.ts +23 -15
- package/src/lib/host-bridge.ts +371 -4
- package/src/lib/host-paste.ts +1 -1
- package/src/lib/http-bridge.ts +12 -9
- package/src/lib/insights.ts +2 -2
- package/src/lib/local-api.ts +5 -5
- package/src/lib/portable.ts +11 -10
- package/src/lib/show-thinking-content.ts +15 -3
- package/src/lib/slash-sources.ts +3 -3
- package/src/lib/tauri-bridge.ts +1 -1
- package/src/main.tsx +27 -4
- package/src/pages/AgentPage.tsx +39 -34
- package/src/pages/ChatPanelPreviewPage.tsx +46 -44
- package/src/pages/SettingsPage.tsx +51 -45
- package/src/vite-env.d.ts +3 -0
- package/vite.base.js +6 -2
- package/src/lib/electron-bridge.ts +0 -390
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* adapter:
|
|
9
9
|
* - It wires the framework hook to the local-backend transport
|
|
10
10
|
* (`@/lib/local-api`).
|
|
11
|
-
* - It gates everything behind `
|
|
11
|
+
* - It gates everything behind `hasHostBridge()` so a renderer running in a
|
|
12
12
|
* plain browser doesn't fire requests.
|
|
13
13
|
* - It keeps the legacy `UseChatsAndAgentsResult` shape so callers
|
|
14
14
|
* (`AgentLayout`, `useChatsAndAgents()` consumers) don't change.
|
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
type LocalChat,
|
|
36
36
|
type LocalChatAgent,
|
|
37
37
|
} from '@/lib/local-api';
|
|
38
|
-
import {
|
|
38
|
+
import { hasHostBridge } from '@/lib/host-bridge';
|
|
39
39
|
import { pickDefaultAgentId } from '@/brand';
|
|
40
40
|
|
|
41
41
|
const CHAT_PAGE_SIZE = 50;
|
|
@@ -66,12 +66,12 @@ export interface UseChatsAndAgentsResult {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
export function useChatsAndAgents(): UseChatsAndAgentsResult {
|
|
69
|
-
const
|
|
69
|
+
const hasHostBridgeRef = useRef(hasHostBridge());
|
|
70
70
|
|
|
71
71
|
const transport = useMemo<ChatListTransport<LocalChat, LocalChatAgent>>(
|
|
72
72
|
() => ({
|
|
73
73
|
listChats: async ({ page, pageSize }) => {
|
|
74
|
-
if (!
|
|
74
|
+
if (!hasHostBridgeRef.current) return { chats: [], hasMore: false };
|
|
75
75
|
const data = await listChats({ page, limit: pageSize });
|
|
76
76
|
return {
|
|
77
77
|
chats: data.chats,
|
|
@@ -79,13 +79,13 @@ export function useChatsAndAgents(): UseChatsAndAgentsResult {
|
|
|
79
79
|
};
|
|
80
80
|
},
|
|
81
81
|
listAgents: async () => {
|
|
82
|
-
if (!
|
|
82
|
+
if (!hasHostBridgeRef.current) return [];
|
|
83
83
|
const data = await listChatAgents(false);
|
|
84
84
|
return data.agents;
|
|
85
85
|
},
|
|
86
86
|
createChat: async ({ agentId, projectId }) => {
|
|
87
|
-
if (!
|
|
88
|
-
throw new Error('not
|
|
87
|
+
if (!hasHostBridgeRef.current) {
|
|
88
|
+
throw new Error('not connected to the host');
|
|
89
89
|
}
|
|
90
90
|
const res = await apiCreateChat({
|
|
91
91
|
...(agentId ? { agentId } : {}),
|
|
@@ -94,7 +94,7 @@ export function useChatsAndAgents(): UseChatsAndAgentsResult {
|
|
|
94
94
|
return res.chatId;
|
|
95
95
|
},
|
|
96
96
|
deleteChat: async (chatId) => {
|
|
97
|
-
if (!
|
|
97
|
+
if (!hasHostBridgeRef.current) return false;
|
|
98
98
|
await apiDeleteChat(chatId);
|
|
99
99
|
return true;
|
|
100
100
|
},
|
|
@@ -110,7 +110,7 @@ export function useChatsAndAgents(): UseChatsAndAgentsResult {
|
|
|
110
110
|
pageSize: CHAT_PAGE_SIZE,
|
|
111
111
|
// The renderer mounts before electron decides whether the bridge is live;
|
|
112
112
|
// skip the auto-fetch and trigger it manually after the SSR no-op check.
|
|
113
|
-
skipInitialLoad: !
|
|
113
|
+
skipInitialLoad: !hasHostBridgeRef.current,
|
|
114
114
|
});
|
|
115
115
|
|
|
116
116
|
// Default-select this flavor's builtin expert once the catalog loads.
|
package/src/hooks/useProjects.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* 任一侧新建、改名或改目录后调用 refresh,其它订阅者一起更新。
|
|
4
4
|
*/
|
|
5
5
|
import { useCallback, useEffect, useSyncExternalStore } from 'react';
|
|
6
|
-
import {
|
|
6
|
+
import { hasHostBridge } from '@/lib/host-bridge';
|
|
7
7
|
import { hostToolChrome } from '@/lib/host-tools';
|
|
8
8
|
import { listProjects, type LocalProject } from '@/lib/local-api';
|
|
9
9
|
|
|
@@ -36,7 +36,7 @@ export function getProjectsSnapshot(): ProjectsSnapshot {
|
|
|
36
36
|
/** 重新拉取项目列表并通知所有订阅者。非 Electron 或关掉项目入口时清空。 */
|
|
37
37
|
export async function refreshProjects(): Promise<void> {
|
|
38
38
|
const seq = ++requestSeq;
|
|
39
|
-
if (!
|
|
39
|
+
if (!hasHostBridge() || !hostToolChrome('projects')) {
|
|
40
40
|
if (snapshot.projects.length > 0 || snapshot.error) {
|
|
41
41
|
snapshot = EMPTY_SNAPSHOT;
|
|
42
42
|
emit();
|
|
@@ -51,7 +51,7 @@ export async function refreshProjects(): Promise<void> {
|
|
|
51
51
|
} catch (err) {
|
|
52
52
|
if (seq !== requestSeq) return;
|
|
53
53
|
const message = err instanceof Error ? err.message : String(err);
|
|
54
|
-
console.error('
|
|
54
|
+
console.error('Failed to load projects:', err);
|
|
55
55
|
snapshot = { projects: snapshot.projects, error: message };
|
|
56
56
|
emit();
|
|
57
57
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { t } from './runtime';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 内置智能体的界面名按 id 翻译。库里的 name 仍是种子原文,切换语言不改历史行。
|
|
5
|
+
* 用户自建智能体用库里的名字。
|
|
6
|
+
*/
|
|
7
|
+
const BUILTIN_AGENT_LABELS: Record<string, string> = {
|
|
8
|
+
'local-assistant': 'Computer operator',
|
|
9
|
+
'all-round-assistant': 'Assistant',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function agentLabel(agent: { id: string; name: string }): string {
|
|
13
|
+
const source = BUILTIN_AGENT_LABELS[agent.id];
|
|
14
|
+
return source ? t(source) : agent.name;
|
|
15
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
configureI18n,
|
|
5
|
+
getLocale,
|
|
6
|
+
registerLocale,
|
|
7
|
+
setLocale,
|
|
8
|
+
subscribeI18n,
|
|
9
|
+
supportedLocales,
|
|
10
|
+
t,
|
|
11
|
+
} from './runtime';
|
|
12
|
+
|
|
13
|
+
export { configureI18n, getLocale, registerLocale, setLocale, supportedLocales, t };
|
|
14
|
+
export { LOCALE_STORAGE_KEY, SOURCE_LOCALE, normalizeLocale, resolveLocale } from './translate';
|
|
15
|
+
|
|
16
|
+
interface I18nContextValue {
|
|
17
|
+
locale: string;
|
|
18
|
+
locales: readonly string[];
|
|
19
|
+
t: typeof t;
|
|
20
|
+
setLocale: (locale: string) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const I18nContext = createContext<I18nContextValue | null>(null);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 包住应用树。语言变化时用 key 重挂子树,这样直接调用 `t()` 的组件也会换文案。
|
|
27
|
+
*/
|
|
28
|
+
export function I18nProvider({ children }: { children: ReactNode }) {
|
|
29
|
+
const [locale, setSnapshot] = useState(getLocale);
|
|
30
|
+
useEffect(() => subscribeI18n(() => setSnapshot(getLocale())), []);
|
|
31
|
+
const value = useMemo<I18nContextValue>(
|
|
32
|
+
() => ({
|
|
33
|
+
locale,
|
|
34
|
+
locales: supportedLocales(),
|
|
35
|
+
t,
|
|
36
|
+
setLocale: (next) => {
|
|
37
|
+
if (!setLocale(next)) return;
|
|
38
|
+
window.steerableHost?.setLocale?.(next);
|
|
39
|
+
},
|
|
40
|
+
}),
|
|
41
|
+
[locale],
|
|
42
|
+
);
|
|
43
|
+
return (
|
|
44
|
+
<I18nContext.Provider value={value}>
|
|
45
|
+
<div key={locale} className="contents">
|
|
46
|
+
{children}
|
|
47
|
+
</div>
|
|
48
|
+
</I18nContext.Provider>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function useI18n(): I18nContextValue {
|
|
53
|
+
const value = useContext(I18nContext);
|
|
54
|
+
if (value) return value;
|
|
55
|
+
return {
|
|
56
|
+
locale: getLocale(),
|
|
57
|
+
locales: supportedLocales(),
|
|
58
|
+
t,
|
|
59
|
+
setLocale,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 语言名用该语言自己的写法,不跟着界面语言翻。 */
|
|
64
|
+
export function localeEndonym(locale: string): string {
|
|
65
|
+
if (locale === 'zh') return '中文'; // i18n:allow 语言名用该语言自己的写法
|
|
66
|
+
if (locale === 'en') return 'English';
|
|
67
|
+
return locale;
|
|
68
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 渲染进程的当前语言。产品入口在 bootstrap 前 registerLocale,
|
|
3
|
+
* bootstrap 再 configureI18n。未配置时只有英语。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
LOCALE_STORAGE_KEY,
|
|
8
|
+
SOURCE_LOCALE,
|
|
9
|
+
resolveLocale,
|
|
10
|
+
translate,
|
|
11
|
+
} from './translate';
|
|
12
|
+
|
|
13
|
+
export interface I18nStorage {
|
|
14
|
+
getItem(key: string): string | null;
|
|
15
|
+
setItem(key: string, value: string): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface I18nState {
|
|
19
|
+
locales: string[];
|
|
20
|
+
defaultLocale: string;
|
|
21
|
+
locale: string;
|
|
22
|
+
catalogs: Record<string, Record<string, string>>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const state: I18nState = {
|
|
26
|
+
locales: [SOURCE_LOCALE],
|
|
27
|
+
defaultLocale: SOURCE_LOCALE,
|
|
28
|
+
locale: SOURCE_LOCALE,
|
|
29
|
+
catalogs: {},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const listeners = new Set<() => void>();
|
|
33
|
+
|
|
34
|
+
function emit(): void {
|
|
35
|
+
for (const listener of listeners) listener();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function subscribeI18n(listener: () => void): () => void {
|
|
39
|
+
listeners.add(listener);
|
|
40
|
+
return () => {
|
|
41
|
+
listeners.delete(listener);
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function registerLocale(locale: string, messages: Record<string, string>): void {
|
|
46
|
+
const normalized = locale.trim();
|
|
47
|
+
if (!normalized || normalized === SOURCE_LOCALE) return;
|
|
48
|
+
state.catalogs[normalized] = { ...state.catalogs[normalized], ...messages };
|
|
49
|
+
emit();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readStorage(): I18nStorage | null {
|
|
53
|
+
if (typeof localStorage === 'undefined') return null;
|
|
54
|
+
return localStorage;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function systemLocale(): string | null {
|
|
58
|
+
if (typeof navigator === 'undefined') return null;
|
|
59
|
+
return navigator.language || null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function configureI18n(options: {
|
|
63
|
+
locales: readonly string[];
|
|
64
|
+
defaultLocale: string;
|
|
65
|
+
systemLocale?: string | null;
|
|
66
|
+
storage?: I18nStorage | null;
|
|
67
|
+
}): void {
|
|
68
|
+
const locales = [...new Set(options.locales.filter((locale) => locale.length > 0))];
|
|
69
|
+
state.locales = locales.length > 0 ? locales : [SOURCE_LOCALE];
|
|
70
|
+
state.defaultLocale = options.defaultLocale || SOURCE_LOCALE;
|
|
71
|
+
const storage = options.storage === undefined ? readStorage() : options.storage;
|
|
72
|
+
state.locale = resolveLocale({
|
|
73
|
+
supported: state.locales,
|
|
74
|
+
defaultLocale: state.defaultLocale,
|
|
75
|
+
saved: storage?.getItem(LOCALE_STORAGE_KEY) ?? null,
|
|
76
|
+
system: options.systemLocale === undefined ? systemLocale() : options.systemLocale,
|
|
77
|
+
});
|
|
78
|
+
applyDocumentLang(state.locale);
|
|
79
|
+
emit();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function getLocale(): string {
|
|
83
|
+
return state.locale;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function supportedLocales(): readonly string[] {
|
|
87
|
+
return state.locales;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function setLocale(locale: string): boolean {
|
|
91
|
+
if (!state.locales.includes(locale)) return false;
|
|
92
|
+
if (state.locale === locale) return true;
|
|
93
|
+
state.locale = locale;
|
|
94
|
+
const storage = readStorage();
|
|
95
|
+
storage?.setItem(LOCALE_STORAGE_KEY, locale);
|
|
96
|
+
applyDocumentLang(locale);
|
|
97
|
+
emit();
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function t(message: string, params?: Record<string, string | number>): string {
|
|
102
|
+
return translate(message, state.locale, state.catalogs, params);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function applyDocumentLang(locale: string): void {
|
|
106
|
+
if (typeof document === 'undefined') return;
|
|
107
|
+
document.documentElement.lang = locale === 'zh' ? 'zh-CN' : locale;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 测试隔离。生产代码不调用。 */
|
|
111
|
+
export function resetI18nForTests(): void {
|
|
112
|
+
state.locales = [SOURCE_LOCALE];
|
|
113
|
+
state.defaultLocale = SOURCE_LOCALE;
|
|
114
|
+
state.locale = SOURCE_LOCALE;
|
|
115
|
+
state.catalogs = {};
|
|
116
|
+
listeners.clear();
|
|
117
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 界面文案查找。源语言是英语:调用处写英文句子,应用层语言包把英文映射到译文。
|
|
3
|
+
* 缺译文时原样返回英文,漏翻在界面上看得见。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const SOURCE_LOCALE = 'en';
|
|
7
|
+
export const LOCALE_STORAGE_KEY = 'agent-shell.locale';
|
|
8
|
+
|
|
9
|
+
/** `zh-CN` / `en-US` 收成语言包用的主标签。不认识的标签保留主段。 */
|
|
10
|
+
export function normalizeLocale(raw: string | null | undefined): string | null {
|
|
11
|
+
if (!raw) return null;
|
|
12
|
+
const lower = raw.trim().toLowerCase().replace(/_/g, '-');
|
|
13
|
+
if (!lower) return null;
|
|
14
|
+
if (lower.startsWith('zh')) return 'zh';
|
|
15
|
+
if (lower.startsWith('en')) return 'en';
|
|
16
|
+
const primary = lower.split('-')[0];
|
|
17
|
+
return primary || null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveLocale(options: {
|
|
21
|
+
supported: readonly string[];
|
|
22
|
+
defaultLocale: string;
|
|
23
|
+
saved: string | null;
|
|
24
|
+
system: string | null;
|
|
25
|
+
}): string {
|
|
26
|
+
const supported = options.supported.filter((locale) => locale.length > 0);
|
|
27
|
+
const list = supported.length > 0 ? supported : [SOURCE_LOCALE];
|
|
28
|
+
const pick = (value: string | null): string | null => {
|
|
29
|
+
if (!value) return null;
|
|
30
|
+
if (list.includes(value)) return value;
|
|
31
|
+
const normalized = normalizeLocale(value);
|
|
32
|
+
if (normalized && list.includes(normalized)) return normalized;
|
|
33
|
+
return null;
|
|
34
|
+
};
|
|
35
|
+
return (
|
|
36
|
+
pick(options.saved) ??
|
|
37
|
+
pick(options.system) ??
|
|
38
|
+
pick(options.defaultLocale) ??
|
|
39
|
+
list[0] ??
|
|
40
|
+
SOURCE_LOCALE
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatMessage(
|
|
45
|
+
template: string,
|
|
46
|
+
params?: Record<string, string | number>,
|
|
47
|
+
): string {
|
|
48
|
+
if (!params) return template;
|
|
49
|
+
return template.replace(/\{(\w+)\}/g, (whole, name: string) => {
|
|
50
|
+
const value = params[name];
|
|
51
|
+
return value === undefined ? whole : String(value);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 语言包的键是英文源句。源语言不查表。 */
|
|
56
|
+
export function translate(
|
|
57
|
+
message: string,
|
|
58
|
+
locale: string,
|
|
59
|
+
catalogs: Readonly<Record<string, Readonly<Record<string, string>>>>,
|
|
60
|
+
params?: Record<string, string | number>,
|
|
61
|
+
): string {
|
|
62
|
+
const table = locale === SOURCE_LOCALE ? undefined : catalogs[locale];
|
|
63
|
+
const template = table?.[message] ?? message;
|
|
64
|
+
return formatMessage(template, params);
|
|
65
|
+
}
|
|
@@ -8,7 +8,7 @@ import { AskUserPromptProvider } from '@/components/chat/AskUserPromptProvider';
|
|
|
8
8
|
import { ApprovalPromptProvider } from '@/components/chat/ApprovalPromptProvider';
|
|
9
9
|
import { InsightsConsentBanner } from '@/components/settings/InsightsSettingsPanel';
|
|
10
10
|
import { trackBehavior } from '@/lib/insights';
|
|
11
|
-
import {
|
|
11
|
+
import { getHostBridge, hasHostBridge } from '@/lib/host-bridge';
|
|
12
12
|
import { deleteChatIfEmpty, pruneEmptyChats } from '@/lib/local-api';
|
|
13
13
|
import { PORTABLE_CHATS_CHANGED_EVENT } from '@/lib/portable';
|
|
14
14
|
import { getPackChatSlots, type PackChatSlotContribution } from '@/packs/registry';
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type UseChatsAndAgentsResult,
|
|
18
18
|
} from '@/hooks/useChatsAndAgents';
|
|
19
19
|
import { hostToolChrome, sanitizeRightPanelKind, settingsChrome } from '@/lib/host-tools';
|
|
20
|
+
import { t } from '@/i18n';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* AgentLayout wraps `/agent`, `/agent/:chatId`, and `/` (default) with a
|
|
@@ -169,7 +170,7 @@ function AgentLayoutContent() {
|
|
|
169
170
|
refreshChatsRef.current = data.refreshChats;
|
|
170
171
|
}, [data.refreshChats]);
|
|
171
172
|
useEffect(() => {
|
|
172
|
-
const bridge =
|
|
173
|
+
const bridge = getHostBridge();
|
|
173
174
|
if (!bridge?.onChatTitleUpdated) return;
|
|
174
175
|
const unsubscribe = bridge.onChatTitleUpdated((payload) => {
|
|
175
176
|
patchChatTitleRef.current(payload.chatId, payload.title);
|
|
@@ -180,7 +181,7 @@ function AgentLayoutContent() {
|
|
|
180
181
|
// 现场补建(见 router.handleStream),随后广播 `chat-created`。这里刷一次
|
|
181
182
|
// 侧栏把这条会话拉出来——否则会出现"URL 能聊、列表里却查无此会话"。
|
|
182
183
|
useEffect(() => {
|
|
183
|
-
const bridge =
|
|
184
|
+
const bridge = getHostBridge();
|
|
184
185
|
if (!bridge?.onChatCreated) return;
|
|
185
186
|
const unsubscribe = bridge.onChatCreated(() => {
|
|
186
187
|
void refreshChatsRef.current();
|
|
@@ -198,7 +199,7 @@ function AgentLayoutContent() {
|
|
|
198
199
|
// 空会话不进侧栏:启动时清掉从未发过消息的残骸;离开一段空对话时丢掉它。
|
|
199
200
|
// except / 离开检测都避开当前打开的 chatId,避免和首页首条发送抢跑。
|
|
200
201
|
useEffect(() => {
|
|
201
|
-
if (!
|
|
202
|
+
if (!hasHostBridge()) return;
|
|
202
203
|
let cancelled = false;
|
|
203
204
|
void pruneEmptyChats(chatId)
|
|
204
205
|
.then((res) => {
|
|
@@ -216,7 +217,7 @@ function AgentLayoutContent() {
|
|
|
216
217
|
useEffect(() => {
|
|
217
218
|
const prev = prevChatIdRef.current;
|
|
218
219
|
prevChatIdRef.current = chatId;
|
|
219
|
-
if (!
|
|
220
|
+
if (!hasHostBridge() || !prev || prev === chatId) return;
|
|
220
221
|
void deleteChatIfEmpty(prev)
|
|
221
222
|
.then((res) => {
|
|
222
223
|
if (res.deleted) return refreshChatsRef.current();
|
|
@@ -415,7 +416,7 @@ function AgentLayoutContent() {
|
|
|
415
416
|
// so this flips the layout state owned by AgentLayout.
|
|
416
417
|
useEffect(() => {
|
|
417
418
|
if (!hostToolChrome('terminal')) return;
|
|
418
|
-
const bridge =
|
|
419
|
+
const bridge = getHostBridge();
|
|
419
420
|
if (!bridge?.onMenuOpenTerminal) return;
|
|
420
421
|
bridge.onMenuOpenTerminal(() => {
|
|
421
422
|
toggleRightPanel('terminal');
|
|
@@ -560,8 +561,8 @@ function AgentLayoutContent() {
|
|
|
560
561
|
type="button"
|
|
561
562
|
onClick={toggleSidebarCollapsed}
|
|
562
563
|
className="flex h-7 w-7 items-center justify-center rounded-full text-agent-muted-foreground transition-colors duration-200 hover:bg-agent-foreground/5 hover:text-agent-foreground"
|
|
563
|
-
title=
|
|
564
|
-
aria-label=
|
|
564
|
+
title={t('Expand sidebar')}
|
|
565
|
+
aria-label={t('Expand sidebar')}
|
|
565
566
|
>
|
|
566
567
|
<LuPanelLeftOpen className="h-3.5 w-3.5" />
|
|
567
568
|
</button>
|
|
@@ -581,11 +582,11 @@ function AgentLayoutContent() {
|
|
|
581
582
|
<div
|
|
582
583
|
role="separator"
|
|
583
584
|
aria-orientation="vertical"
|
|
584
|
-
aria-label=
|
|
585
|
+
aria-label={t('Resize sidebar')}
|
|
585
586
|
onMouseDown={() => setIsResizing(true)}
|
|
586
587
|
onDoubleClick={resetWidth}
|
|
587
588
|
className="w-1 flex-shrink-0 cursor-col-resize bg-transparent transition-colors hover:bg-agent-border/60"
|
|
588
|
-
title=
|
|
589
|
+
title={t('Drag to resize the sidebar (double-click to reset)')}
|
|
589
590
|
/>
|
|
590
591
|
</>
|
|
591
592
|
)}
|
|
@@ -612,11 +613,11 @@ function AgentLayoutContent() {
|
|
|
612
613
|
<div
|
|
613
614
|
role="separator"
|
|
614
615
|
aria-orientation="vertical"
|
|
615
|
-
aria-label=
|
|
616
|
+
aria-label={t('Resize right panel')}
|
|
616
617
|
onMouseDown={() => setIsTerminalResizing(true)}
|
|
617
618
|
onDoubleClick={resetTerminalWidth}
|
|
618
619
|
className="w-1 flex-shrink-0 cursor-col-resize bg-transparent transition-colors hover:bg-agent-border/60"
|
|
619
|
-
title=
|
|
620
|
+
title={t('Drag to resize the right panel (double-click to reset)')}
|
|
620
621
|
/>
|
|
621
622
|
<div
|
|
622
623
|
className={`h-full flex-shrink-0 overflow-hidden rounded-agent-lg shadow-sm ${
|
package/src/lib/app-release.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { t } from '@/i18n';
|
|
2
|
+
import type { AppReleasePhase, AppReleaseSnapshot } from './host-bridge';
|
|
2
3
|
|
|
3
4
|
export interface AppReleaseView {
|
|
4
5
|
version: string | null;
|
|
@@ -20,21 +21,25 @@ export function sidebarUpdateLabel(
|
|
|
20
21
|
if (!snap.enabled || snap.phase === 'disabled') return null;
|
|
21
22
|
switch (snap.phase) {
|
|
22
23
|
case 'checking':
|
|
23
|
-
return '
|
|
24
|
+
return t('Checking');
|
|
24
25
|
case 'downloading':
|
|
25
|
-
return typeof snap.percent === 'number'
|
|
26
|
+
return typeof snap.percent === 'number'
|
|
27
|
+
? t('Downloading {percent}%', { percent: Math.round(snap.percent) })
|
|
28
|
+
: t('Downloading…');
|
|
26
29
|
case 'ready':
|
|
27
|
-
return snap.availableVersion
|
|
30
|
+
return snap.availableVersion
|
|
31
|
+
? t('Update to {version}', { version: snap.availableVersion })
|
|
32
|
+
: t('Update');
|
|
28
33
|
case 'installing':
|
|
29
|
-
return '
|
|
34
|
+
return t('Installing');
|
|
30
35
|
case 'error':
|
|
31
|
-
return '
|
|
36
|
+
return t('Retry');
|
|
32
37
|
case 'idle':
|
|
33
|
-
if (snap.availableVersion) return
|
|
38
|
+
if (snap.availableVersion) return t('{version} available', { version: snap.availableVersion });
|
|
34
39
|
if (snap.message) return snap.message;
|
|
35
|
-
return confirmedCurrent ? '
|
|
40
|
+
return confirmedCurrent ? t('Up to date') : t('Check for updates');
|
|
36
41
|
default:
|
|
37
|
-
return '
|
|
42
|
+
return t('Check for updates');
|
|
38
43
|
}
|
|
39
44
|
}
|
|
40
45
|
|
|
@@ -43,9 +48,9 @@ export function sidebarUpdateTitle(snap: AppReleaseSnapshot): string | undefined
|
|
|
43
48
|
return snap.message;
|
|
44
49
|
}
|
|
45
50
|
if (snap.phase === 'ready' && snap.availableVersion) {
|
|
46
|
-
return
|
|
51
|
+
return t('Restart and install {version}', { version: snap.availableVersion });
|
|
47
52
|
}
|
|
48
|
-
if (snap.phase === 'downloading') return '
|
|
53
|
+
if (snap.phase === 'downloading') return t('Downloading update');
|
|
49
54
|
return undefined;
|
|
50
55
|
}
|
|
51
56
|
|
package/src/lib/attachments.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* - BS/browser:File 没有路径,这里把字节读成 base64 走 HTTP 端点落盘。
|
|
10
10
|
* 返回的落盘路径会重写消息正文里的文件引用与图像附件元数据。
|
|
11
11
|
*/
|
|
12
|
-
import {
|
|
12
|
+
import { t } from '@/i18n';
|
|
13
|
+
import { getHostBridge } from './host-bridge';
|
|
13
14
|
|
|
14
15
|
export interface AttachmentFile {
|
|
15
16
|
name: string;
|
|
@@ -82,7 +83,7 @@ export async function saveChatAttachments(
|
|
|
82
83
|
files: AttachmentFile[],
|
|
83
84
|
): Promise<SaveChatAttachmentsResult> {
|
|
84
85
|
if (!chatId || files.length === 0) return { files, failures: [] };
|
|
85
|
-
const bridge =
|
|
86
|
+
const bridge = getHostBridge();
|
|
86
87
|
if (!bridge?.attachments?.save) return { files, failures: [] };
|
|
87
88
|
try {
|
|
88
89
|
// 有真实路径走 path 拷贝(Electron);没有则读字节走 data 上传(BS/browser)。
|
|
@@ -109,7 +110,7 @@ export async function saveChatAttachments(
|
|
|
109
110
|
kept.push(file);
|
|
110
111
|
return;
|
|
111
112
|
}
|
|
112
|
-
failures.push({ name: file.name, error: entry?.error || '
|
|
113
|
+
failures.push({ name: file.name, error: entry?.error || t('Upload failed'), file });
|
|
113
114
|
});
|
|
114
115
|
return { files: kept, failures };
|
|
115
116
|
} catch (err) {
|
|
@@ -123,13 +124,21 @@ export async function saveChatAttachments(
|
|
|
123
124
|
}
|
|
124
125
|
}
|
|
125
126
|
|
|
126
|
-
/**
|
|
127
|
+
/** 把失败项拼成一条给用户看的提示(供各提交入口复用)。 */
|
|
127
128
|
export function formatAttachmentFailures(failures: AttachmentSaveFailure[]): string {
|
|
128
129
|
if (failures.length === 0) return '';
|
|
129
|
-
const detail = failures
|
|
130
|
+
const detail = failures
|
|
131
|
+
.map((f) => t('{name} ({error})', { name: f.name, error: f.error }))
|
|
132
|
+
.join(t('; '));
|
|
130
133
|
return failures.length === 1
|
|
131
|
-
?
|
|
132
|
-
|
|
134
|
+
? t('File "{name}" failed to upload and is not included in this message: {error}', {
|
|
135
|
+
name: failures[0].name,
|
|
136
|
+
error: failures[0].error,
|
|
137
|
+
})
|
|
138
|
+
: t('{count} files failed to upload and are not included in this message: {detail}', {
|
|
139
|
+
count: failures.length,
|
|
140
|
+
detail,
|
|
141
|
+
});
|
|
133
142
|
}
|
|
134
143
|
|
|
135
144
|
/**
|
|
@@ -142,8 +151,8 @@ export function appendAttachmentRefs(rawText: string, files: AttachmentFile[]):
|
|
|
142
151
|
if (files.length === 0) return rawText;
|
|
143
152
|
const refs = files.map((f) => `- \`${f.path}\``).join('\n');
|
|
144
153
|
return rawText
|
|
145
|
-
? `${rawText}\n\n---\n
|
|
146
|
-
:
|
|
154
|
+
? `${rawText}\n\n---\n${t('Related files:')}\n${refs}`
|
|
155
|
+
: `${t('Related files:')}\n${refs}`;
|
|
147
156
|
}
|
|
148
157
|
|
|
149
158
|
/**
|