mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-13 12:29:14 +00:00
Compare commits
5 Commits
feature/re
...
feat/add-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da4da1230e | ||
|
|
cda794639c | ||
|
|
a1b03f02df | ||
|
|
6dc272c1fa | ||
|
|
9877fe790c |
@@ -23,8 +23,6 @@
|
||||
"build-and-start": "npm run build && npm run start",
|
||||
"build:vscode": "node scripts/build_vscode_companion.js",
|
||||
"build:all": "npm run build && npm run build:sandbox && npm run build:vscode",
|
||||
"build:native": "node scripts/build_native.js",
|
||||
"build:native:all": "node scripts/build_native.js --all",
|
||||
"build:packages": "npm run build --workspaces",
|
||||
"build:sandbox": "node scripts/build_sandbox.js",
|
||||
"bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js",
|
||||
|
||||
@@ -434,6 +434,16 @@ const SETTINGS_SCHEMA = {
|
||||
'Show welcome back dialog when returning to a project with conversation history.',
|
||||
showInDialog: true,
|
||||
},
|
||||
enableUserFeedback: {
|
||||
type: 'boolean',
|
||||
label: 'Enable User Feedback',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description:
|
||||
'Show optional feedback dialog after conversations to help improve Qwen performance.',
|
||||
showInDialog: true,
|
||||
},
|
||||
accessibility: {
|
||||
type: 'object',
|
||||
label: 'Accessibility',
|
||||
|
||||
@@ -289,6 +289,12 @@ export default {
|
||||
'Show Citations': 'Quellenangaben anzeigen',
|
||||
'Custom Witty Phrases': 'Benutzerdefinierte Witzige Sprüche',
|
||||
'Enable Welcome Back': 'Willkommen-zurück aktivieren',
|
||||
'Enable User Feedback': 'Benutzerfeedback aktivieren',
|
||||
'How is Qwen doing this session? (optional)':
|
||||
'Wie macht sich Qwen in dieser Sitzung? (optional)',
|
||||
Bad: 'Schlecht',
|
||||
Good: 'Gut',
|
||||
'Not Sure Yet': 'Noch nicht sicher',
|
||||
'Disable Loading Phrases': 'Ladesprüche deaktivieren',
|
||||
'Screen Reader Mode': 'Bildschirmleser-Modus',
|
||||
'IDE Mode': 'IDE-Modus',
|
||||
|
||||
@@ -286,6 +286,12 @@ export default {
|
||||
'Show Citations': 'Show Citations',
|
||||
'Custom Witty Phrases': 'Custom Witty Phrases',
|
||||
'Enable Welcome Back': 'Enable Welcome Back',
|
||||
'Enable User Feedback': 'Enable User Feedback',
|
||||
'How is Qwen doing this session? (optional)':
|
||||
'How is Qwen doing this session? (optional)',
|
||||
Bad: 'Bad',
|
||||
Good: 'Good',
|
||||
'Not Sure Yet': 'Not Sure Yet',
|
||||
'Disable Loading Phrases': 'Disable Loading Phrases',
|
||||
'Screen Reader Mode': 'Screen Reader Mode',
|
||||
'IDE Mode': 'IDE Mode',
|
||||
|
||||
@@ -289,6 +289,12 @@ export default {
|
||||
'Show Citations': 'Показывать цитаты',
|
||||
'Custom Witty Phrases': 'Пользовательские остроумные фразы',
|
||||
'Enable Welcome Back': 'Включить приветствие при возврате',
|
||||
'Enable User Feedback': 'Включить отзывы пользователей',
|
||||
'How is Qwen doing this session? (optional)':
|
||||
'Как дела у Qwen в этой сессии? (необязательно)',
|
||||
Bad: 'Плохо',
|
||||
Good: 'Хорошо',
|
||||
'Not Sure Yet': 'Пока не уверен',
|
||||
'Disable Loading Phrases': 'Отключить фразы при загрузке',
|
||||
'Screen Reader Mode': 'Режим программы чтения с экрана',
|
||||
'IDE Mode': 'Режим IDE',
|
||||
|
||||
@@ -277,6 +277,11 @@ export default {
|
||||
'Show Citations': '显示引用',
|
||||
'Custom Witty Phrases': '自定义诙谐短语',
|
||||
'Enable Welcome Back': '启用欢迎回来',
|
||||
'Enable User Feedback': '启用用户反馈',
|
||||
'How is Qwen doing this session? (optional)': 'Qwen 这次表现如何?(可选)',
|
||||
Bad: '不满意',
|
||||
Good: '满意',
|
||||
'Not Sure Yet': '暂不评价',
|
||||
'Disable Loading Phrases': '禁用加载短语',
|
||||
'Screen Reader Mode': '屏幕阅读器模式',
|
||||
'IDE Mode': 'IDE 模式',
|
||||
|
||||
@@ -45,6 +45,7 @@ import process from 'node:process';
|
||||
import { useHistory } from './hooks/useHistoryManager.js';
|
||||
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
|
||||
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
||||
import { useFeedbackDialog } from './hooks/useFeedbackDialog.js';
|
||||
import { useAuthCommand } from './auth/useAuth.js';
|
||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||
@@ -1173,6 +1174,19 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
const nightly = props.version.includes('nightly');
|
||||
|
||||
const {
|
||||
isFeedbackDialogOpen,
|
||||
openFeedbackDialog,
|
||||
closeFeedbackDialog,
|
||||
submitFeedback,
|
||||
} = useFeedbackDialog({
|
||||
config,
|
||||
settings,
|
||||
streamingState,
|
||||
history: historyManager.history,
|
||||
sessionStats,
|
||||
});
|
||||
|
||||
const dialogsVisible =
|
||||
showWelcomeBackDialog ||
|
||||
showWorkspaceMigrationDialog ||
|
||||
@@ -1194,7 +1208,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isSubagentCreateDialogOpen ||
|
||||
isAgentsManagerDialogOpen ||
|
||||
isApprovalModeDialogOpen ||
|
||||
isResumeDialogOpen;
|
||||
isResumeDialogOpen ||
|
||||
isFeedbackDialogOpen;
|
||||
|
||||
const pendingHistoryItems = useMemo(
|
||||
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
||||
@@ -1292,6 +1307,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// Subagent dialogs
|
||||
isSubagentCreateDialogOpen,
|
||||
isAgentsManagerDialogOpen,
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen,
|
||||
}),
|
||||
[
|
||||
isThemeDialogOpen,
|
||||
@@ -1382,6 +1399,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// Subagent dialogs
|
||||
isSubagentCreateDialogOpen,
|
||||
isAgentsManagerDialogOpen,
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1422,6 +1441,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
handleResume,
|
||||
// Feedback dialog
|
||||
openFeedbackDialog,
|
||||
closeFeedbackDialog,
|
||||
submitFeedback,
|
||||
}),
|
||||
[
|
||||
handleThemeSelect,
|
||||
@@ -1457,6 +1480,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
handleResume,
|
||||
// Feedback dialog
|
||||
openFeedbackDialog,
|
||||
closeFeedbackDialog,
|
||||
submitFeedback,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
51
packages/cli/src/ui/FeedbackDialog.tsx
Normal file
51
packages/cli/src/ui/FeedbackDialog.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Box, Text } from 'ink';
|
||||
import type React from 'react';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { useUIActions } from './contexts/UIActionsContext.js';
|
||||
import { useUIState } from './contexts/UIStateContext.js';
|
||||
import { useKeypress } from './hooks/useKeypress.js';
|
||||
|
||||
export const FeedbackDialog: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
const uiActions = useUIActions();
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
uiActions.closeFeedbackDialog();
|
||||
} else if (key.name === '1') {
|
||||
uiActions.submitFeedback(1);
|
||||
} else if (key.name === '2') {
|
||||
uiActions.submitFeedback(2);
|
||||
} else if (key.name === '3') {
|
||||
uiActions.submitFeedback(3);
|
||||
} else if (key.name === '0') {
|
||||
uiActions.closeFeedbackDialog();
|
||||
}
|
||||
},
|
||||
{ isActive: uiState.isFeedbackDialogOpen },
|
||||
);
|
||||
|
||||
if (!uiState.isFeedbackDialogOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<Box>
|
||||
<Text color="cyan">● </Text>
|
||||
<Text bold>{t('How is Qwen doing this session? (optional)')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="cyan">1: </Text>
|
||||
<Text>{t('Good')}</Text>
|
||||
<Text> </Text>
|
||||
<Text color="cyan">2: </Text>
|
||||
<Text>{t('Bad')}</Text>
|
||||
<Text> </Text>
|
||||
<Text color="cyan">3: </Text>
|
||||
<Text>{t('Not Sure Yet')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -35,6 +35,7 @@ import { ModelSwitchDialog } from './ModelSwitchDialog.js';
|
||||
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
|
||||
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
|
||||
import { SessionPicker } from './SessionPicker.js';
|
||||
import { FeedbackDialog } from '../FeedbackDialog.js';
|
||||
|
||||
interface DialogManagerProps {
|
||||
addItem: UseHistoryManagerReturn['addItem'];
|
||||
@@ -291,5 +292,9 @@ export const DialogManager = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.isFeedbackDialogOpen) {
|
||||
return <FeedbackDialog />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -66,6 +66,10 @@ export interface UIActions {
|
||||
openResumeDialog: () => void;
|
||||
closeResumeDialog: () => void;
|
||||
handleResume: (sessionId: string) => void;
|
||||
// Feedback dialog
|
||||
openFeedbackDialog: () => void;
|
||||
closeFeedbackDialog: () => void;
|
||||
submitFeedback: (rating: number) => void;
|
||||
}
|
||||
|
||||
export const UIActionsContext = createContext<UIActions | null>(null);
|
||||
|
||||
@@ -126,6 +126,8 @@ export interface UIState {
|
||||
// Subagent dialogs
|
||||
isSubagentCreateDialogOpen: boolean;
|
||||
isAgentsManagerDialogOpen: boolean;
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen: boolean;
|
||||
}
|
||||
|
||||
export const UIStateContext = createContext<UIState | null>(null);
|
||||
|
||||
173
packages/cli/src/ui/hooks/useFeedbackDialog.ts
Normal file
173
packages/cli/src/ui/hooks/useFeedbackDialog.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
type Config,
|
||||
logUserFeedback,
|
||||
UserFeedbackEvent,
|
||||
type UserFeedbackRating,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { StreamingState, MessageType, type HistoryItem } from '../types.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import type { SessionStatsState } from '../contexts/SessionContext.js';
|
||||
|
||||
const FEEDBACK_SHOW_PROBABILITY = 0.25; // 25% probability of showing feedback dialog
|
||||
const MIN_TOOL_CALLS = 10; // Minimum tool calls to show feedback dialog
|
||||
const MIN_USER_MESSAGES = 5; // Minimum user messages to show feedback dialog
|
||||
|
||||
/**
|
||||
* Check if there's an AI response after the last user message in the conversation history
|
||||
*/
|
||||
const hasAIResponseAfterLastUserMessage = (history: HistoryItem[]): boolean => {
|
||||
// Find the last user message
|
||||
let lastUserMessageIndex = -1;
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
if (history[i].type === MessageType.USER) {
|
||||
lastUserMessageIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there's any AI response (GEMINI message) after the last user message
|
||||
if (lastUserMessageIndex !== -1) {
|
||||
for (let i = lastUserMessageIndex + 1; i < history.length; i++) {
|
||||
if (history[i].type === MessageType.GEMINI) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Count the number of user messages in the conversation history
|
||||
*/
|
||||
const countUserMessages = (history: HistoryItem[]): number =>
|
||||
history.filter((item) => item.type === MessageType.USER).length;
|
||||
|
||||
export interface UseFeedbackDialogProps {
|
||||
config: Config;
|
||||
settings: LoadedSettings;
|
||||
streamingState: StreamingState;
|
||||
history: HistoryItem[];
|
||||
sessionStats: SessionStatsState;
|
||||
}
|
||||
|
||||
export const useFeedbackDialog = ({
|
||||
config,
|
||||
settings,
|
||||
streamingState,
|
||||
history,
|
||||
sessionStats,
|
||||
}: UseFeedbackDialogProps) => {
|
||||
// Feedback dialog state
|
||||
const [isFeedbackDialogOpen, setIsFeedbackDialogOpen] = useState(false);
|
||||
const [feedbackShownForSession, setFeedbackShownForSession] = useState(false);
|
||||
|
||||
const openFeedbackDialog = useCallback(() => {
|
||||
setIsFeedbackDialogOpen(true);
|
||||
setFeedbackShownForSession(true);
|
||||
}, []);
|
||||
|
||||
const closeFeedbackDialog = useCallback(
|
||||
() => setIsFeedbackDialogOpen(false),
|
||||
[],
|
||||
);
|
||||
|
||||
const submitFeedback = useCallback(
|
||||
(rating: number) => {
|
||||
// Calculate session duration and turn count
|
||||
const sessionDurationMs =
|
||||
Date.now() - sessionStats.sessionStartTime.getTime();
|
||||
let lastUserMessageIndex = -1;
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
if (history[i].type === MessageType.USER) {
|
||||
lastUserMessageIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const turnCount =
|
||||
lastUserMessageIndex === -1 ? 0 : history.length - lastUserMessageIndex;
|
||||
|
||||
// Create and log the feedback event
|
||||
const feedbackEvent = new UserFeedbackEvent(
|
||||
sessionStats.sessionId,
|
||||
rating as UserFeedbackRating,
|
||||
sessionDurationMs,
|
||||
turnCount,
|
||||
config.getModel(),
|
||||
config.getApprovalMode(),
|
||||
);
|
||||
|
||||
logUserFeedback(config, feedbackEvent);
|
||||
closeFeedbackDialog();
|
||||
},
|
||||
[config, sessionStats, history, closeFeedbackDialog],
|
||||
);
|
||||
|
||||
// Track when to show feedback dialog
|
||||
useEffect(() => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
if (streamingState === StreamingState.Idle && history.length > 0) {
|
||||
const hasAIResponseAfterLastUser =
|
||||
hasAIResponseAfterLastUserMessage(history);
|
||||
|
||||
const sessionDurationMs =
|
||||
Date.now() - sessionStats.sessionStartTime.getTime();
|
||||
|
||||
// Get tool calls count and user messages count
|
||||
const toolCallsCount = sessionStats.metrics.tools.totalCalls;
|
||||
const userMessagesCount = countUserMessages(history);
|
||||
|
||||
// Check if the session meets the minimum requirements:
|
||||
// Either tool calls > 10 OR user messages > 5
|
||||
const meetsMinimumRequirements =
|
||||
toolCallsCount > MIN_TOOL_CALLS ||
|
||||
userMessagesCount > MIN_USER_MESSAGES;
|
||||
|
||||
// Show feedback dialog if:
|
||||
// 1. Telemetry is enabled (required for feedback submission)
|
||||
// 2. User feedback is enabled in settings
|
||||
// 3. There's an AI response after the last user message (real AI conversation)
|
||||
// 4. Session duration > 10 seconds (meaningful interaction)
|
||||
// 5. Not already shown for this session
|
||||
// 6. Random chance (25% probability)
|
||||
// 7. Meets minimum requirements (tool calls > 10 OR user messages > 5)
|
||||
if (
|
||||
config.getUsageStatisticsEnabled() && // Only show if telemetry is enabled
|
||||
settings.merged.ui?.enableUserFeedback !== false && // Default to true if not set
|
||||
hasAIResponseAfterLastUser &&
|
||||
sessionDurationMs > 10000 && // 10 seconds minimum for meaningful interaction
|
||||
!feedbackShownForSession &&
|
||||
Math.random() < FEEDBACK_SHOW_PROBABILITY &&
|
||||
meetsMinimumRequirements
|
||||
) {
|
||||
timeoutId = setTimeout(() => {
|
||||
openFeedbackDialog();
|
||||
}, 1000); // Delay to ensure user has time to see the completion
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
streamingState,
|
||||
history,
|
||||
sessionStats,
|
||||
isFeedbackDialogOpen,
|
||||
feedbackShownForSession,
|
||||
openFeedbackDialog,
|
||||
settings.merged.ui?.enableUserFeedback,
|
||||
config,
|
||||
]);
|
||||
|
||||
return {
|
||||
isFeedbackDialogOpen,
|
||||
openFeedbackDialog,
|
||||
closeFeedbackDialog,
|
||||
submitFeedback,
|
||||
};
|
||||
};
|
||||
@@ -35,6 +35,7 @@ export const EVENT_MODEL_SLASH_COMMAND = 'qwen-code.slash_command.model';
|
||||
export const EVENT_SUBAGENT_EXECUTION = 'qwen-code.subagent_execution';
|
||||
export const EVENT_SKILL_LAUNCH = 'qwen-code.skill_launch';
|
||||
export const EVENT_AUTH = 'qwen-code.auth';
|
||||
export const EVENT_USER_FEEDBACK = 'qwen-code.user_feedback';
|
||||
|
||||
// Performance Events
|
||||
export const EVENT_STARTUP_PERFORMANCE = 'qwen-code.startup.performance';
|
||||
|
||||
@@ -45,6 +45,7 @@ export {
|
||||
logNextSpeakerCheck,
|
||||
logAuth,
|
||||
logSkillLaunch,
|
||||
logUserFeedback,
|
||||
} from './loggers.js';
|
||||
export type { SlashCommandEvent, ChatCompressionEvent } from './types.js';
|
||||
export {
|
||||
@@ -65,6 +66,8 @@ export {
|
||||
NextSpeakerCheckEvent,
|
||||
AuthEvent,
|
||||
SkillLaunchEvent,
|
||||
UserFeedbackEvent,
|
||||
UserFeedbackRating,
|
||||
} from './types.js';
|
||||
export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js';
|
||||
export type { TelemetryEvent } from './types.js';
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
EVENT_INVALID_CHUNK,
|
||||
EVENT_AUTH,
|
||||
EVENT_SKILL_LAUNCH,
|
||||
EVENT_USER_FEEDBACK,
|
||||
} from './constants.js';
|
||||
import {
|
||||
recordApiErrorMetrics,
|
||||
@@ -86,6 +87,7 @@ import type {
|
||||
InvalidChunkEvent,
|
||||
AuthEvent,
|
||||
SkillLaunchEvent,
|
||||
UserFeedbackEvent,
|
||||
} from './types.js';
|
||||
import type { UiEvent } from './uiTelemetry.js';
|
||||
import { uiTelemetryService } from './uiTelemetry.js';
|
||||
@@ -887,3 +889,32 @@ export function logSkillLaunch(config: Config, event: SkillLaunchEvent): void {
|
||||
};
|
||||
logger.emit(logRecord);
|
||||
}
|
||||
|
||||
export function logUserFeedback(
|
||||
config: Config,
|
||||
event: UserFeedbackEvent,
|
||||
): void {
|
||||
const uiEvent = {
|
||||
...event,
|
||||
'event.name': EVENT_USER_FEEDBACK,
|
||||
'event.timestamp': new Date().toISOString(),
|
||||
} as UiEvent;
|
||||
uiTelemetryService.addEvent(uiEvent);
|
||||
config.getChatRecordingService()?.recordUiTelemetryEvent(uiEvent);
|
||||
QwenLogger.getInstance(config)?.logUserFeedbackEvent(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
...getCommonAttributes(config),
|
||||
...event,
|
||||
'event.name': EVENT_USER_FEEDBACK,
|
||||
'event.timestamp': new Date().toISOString(),
|
||||
};
|
||||
|
||||
const logger = logs.getLogger(SERVICE_NAME);
|
||||
const logRecord: LogRecord = {
|
||||
body: `User feedback: Rating ${event.rating} for session ${event.session_id}. Turn count: ${event.turn_count}. Duration: ${event.session_duration_ms}ms.`,
|
||||
attributes,
|
||||
};
|
||||
logger.emit(logRecord);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
ExtensionDisableEvent,
|
||||
AuthEvent,
|
||||
SkillLaunchEvent,
|
||||
UserFeedbackEvent,
|
||||
RipgrepFallbackEvent,
|
||||
EndSessionEvent,
|
||||
} from '../types.js';
|
||||
@@ -842,6 +843,23 @@ export class QwenLogger {
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logUserFeedbackEvent(event: UserFeedbackEvent): void {
|
||||
const rumEvent = this.createActionEvent('user', 'user_feedback', {
|
||||
properties: {
|
||||
session_id: event.session_id,
|
||||
rating: event.rating,
|
||||
session_duration_ms: event.session_duration_ms,
|
||||
turn_count: event.turn_count,
|
||||
model: event.model,
|
||||
approval_mode: event.approval_mode,
|
||||
prompt_id: event.prompt_id || '',
|
||||
},
|
||||
});
|
||||
|
||||
this.enqueueLogEvent(rumEvent);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logChatCompressionEvent(event: ChatCompressionEvent): void {
|
||||
const rumEvent = this.createActionEvent('misc', 'chat_compression', {
|
||||
properties: {
|
||||
|
||||
@@ -757,6 +757,44 @@ export class SkillLaunchEvent implements BaseTelemetryEvent {
|
||||
}
|
||||
}
|
||||
|
||||
export enum UserFeedbackRating {
|
||||
BAD = 1,
|
||||
FINE = 2,
|
||||
GOOD = 3,
|
||||
}
|
||||
|
||||
export class UserFeedbackEvent implements BaseTelemetryEvent {
|
||||
'event.name': 'user_feedback';
|
||||
'event.timestamp': string;
|
||||
session_id: string;
|
||||
rating: UserFeedbackRating;
|
||||
session_duration_ms: number;
|
||||
turn_count: number;
|
||||
model: string;
|
||||
approval_mode: string;
|
||||
prompt_id?: string;
|
||||
|
||||
constructor(
|
||||
session_id: string,
|
||||
rating: UserFeedbackRating,
|
||||
session_duration_ms: number,
|
||||
turn_count: number,
|
||||
model: string,
|
||||
approval_mode: string,
|
||||
prompt_id?: string,
|
||||
) {
|
||||
this['event.name'] = 'user_feedback';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
this.session_id = session_id;
|
||||
this.rating = rating;
|
||||
this.session_duration_ms = session_duration_ms;
|
||||
this.turn_count = turn_count;
|
||||
this.model = model;
|
||||
this.approval_mode = approval_mode;
|
||||
this.prompt_id = prompt_id;
|
||||
}
|
||||
}
|
||||
|
||||
export type TelemetryEvent =
|
||||
| StartSessionEvent
|
||||
| EndSessionEvent
|
||||
@@ -786,7 +824,8 @@ export type TelemetryEvent =
|
||||
| ToolOutputTruncatedEvent
|
||||
| ModelSlashCommandEvent
|
||||
| AuthEvent
|
||||
| SkillLaunchEvent;
|
||||
| SkillLaunchEvent
|
||||
| UserFeedbackEvent;
|
||||
|
||||
export class ExtensionDisableEvent implements BaseTelemetryEvent {
|
||||
'event.name': 'extension_disable';
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
|
||||
const distRoot = path.join(rootDir, 'dist', 'native');
|
||||
const entryPoint = path.join(rootDir, 'packages', 'cli', 'index.ts');
|
||||
const localesDir = path.join(
|
||||
rootDir,
|
||||
'packages',
|
||||
'cli',
|
||||
'src',
|
||||
'i18n',
|
||||
'locales',
|
||||
);
|
||||
const vendorDir = path.join(rootDir, 'packages', 'core', 'vendor');
|
||||
|
||||
const rootPackageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, 'package.json'), 'utf-8'),
|
||||
);
|
||||
const cliName = Object.keys(rootPackageJson.bin || {})[0] || 'qwen';
|
||||
const version = rootPackageJson.version;
|
||||
|
||||
const TARGETS = [
|
||||
{
|
||||
id: 'darwin-arm64',
|
||||
os: 'darwin',
|
||||
arch: 'arm64',
|
||||
bunTarget: 'bun-darwin-arm64',
|
||||
},
|
||||
{
|
||||
id: 'darwin-x64',
|
||||
os: 'darwin',
|
||||
arch: 'x64',
|
||||
bunTarget: 'bun-darwin-x64',
|
||||
},
|
||||
{
|
||||
id: 'linux-arm64',
|
||||
os: 'linux',
|
||||
arch: 'arm64',
|
||||
bunTarget: 'bun-linux-arm64',
|
||||
},
|
||||
{
|
||||
id: 'linux-x64',
|
||||
os: 'linux',
|
||||
arch: 'x64',
|
||||
bunTarget: 'bun-linux-x64',
|
||||
},
|
||||
{
|
||||
id: 'linux-arm64-musl',
|
||||
os: 'linux',
|
||||
arch: 'arm64',
|
||||
libc: 'musl',
|
||||
bunTarget: 'bun-linux-arm64-musl',
|
||||
},
|
||||
{
|
||||
id: 'linux-x64-musl',
|
||||
os: 'linux',
|
||||
arch: 'x64',
|
||||
libc: 'musl',
|
||||
bunTarget: 'bun-linux-x64-musl',
|
||||
},
|
||||
{
|
||||
id: 'windows-x64',
|
||||
os: 'windows',
|
||||
arch: 'x64',
|
||||
bunTarget: 'bun-windows-x64',
|
||||
},
|
||||
];
|
||||
|
||||
function getHostTargetId() {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
if (platform === 'darwin' && arch === 'arm64') return 'darwin-arm64';
|
||||
if (platform === 'darwin' && arch === 'x64') return 'darwin-x64';
|
||||
if (platform === 'win32' && arch === 'x64') return 'windows-x64';
|
||||
if (platform === 'linux' && arch === 'x64') {
|
||||
return isMusl() ? 'linux-x64-musl' : 'linux-x64';
|
||||
}
|
||||
if (platform === 'linux' && arch === 'arm64') {
|
||||
return isMusl() ? 'linux-arm64-musl' : 'linux-arm64';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isMusl() {
|
||||
if (process.platform !== 'linux') return false;
|
||||
const report = process.report?.getReport?.();
|
||||
return !report?.header?.glibcVersionRuntime;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
all: false,
|
||||
list: false,
|
||||
targets: [],
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--all') {
|
||||
args.all = true;
|
||||
} else if (arg === '--list-targets') {
|
||||
args.list = true;
|
||||
} else if (arg === '--target' && argv[i + 1]) {
|
||||
args.targets.push(argv[i + 1]);
|
||||
i += 1;
|
||||
} else if (arg?.startsWith('--targets=')) {
|
||||
const raw = arg.split('=')[1] || '';
|
||||
args.targets.push(
|
||||
...raw
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function ensureBunAvailable() {
|
||||
const result = spawnSync('bun', ['--version'], { stdio: 'pipe' });
|
||||
if (result.error) {
|
||||
console.error('Error: Bun is required to build native binaries.');
|
||||
console.error('Install Bun from https://bun.sh and retry.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function cleanNativeDist() {
|
||||
fs.rmSync(distRoot, { recursive: true, force: true });
|
||||
fs.mkdirSync(distRoot, { recursive: true });
|
||||
}
|
||||
|
||||
function copyRecursiveSync(src, dest) {
|
||||
if (!fs.existsSync(src)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = fs.statSync(src);
|
||||
if (stats.isDirectory()) {
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
}
|
||||
for (const entry of fs.readdirSync(src)) {
|
||||
if (entry === '.DS_Store') continue;
|
||||
copyRecursiveSync(path.join(src, entry), path.join(dest, entry));
|
||||
}
|
||||
} else {
|
||||
fs.copyFileSync(src, dest);
|
||||
if (stats.mode & 0o111) {
|
||||
fs.chmodSync(dest, stats.mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyNativeAssets(targetDir, target) {
|
||||
if (target.os === 'darwin') {
|
||||
const sbFiles = findSandboxProfiles();
|
||||
for (const file of sbFiles) {
|
||||
fs.copyFileSync(file, path.join(targetDir, path.basename(file)));
|
||||
}
|
||||
}
|
||||
|
||||
copyVendorRipgrep(targetDir, target);
|
||||
copyRecursiveSync(localesDir, path.join(targetDir, 'locales'));
|
||||
}
|
||||
|
||||
function findSandboxProfiles() {
|
||||
const matches = [];
|
||||
const packagesDir = path.join(rootDir, 'packages');
|
||||
const stack = [packagesDir];
|
||||
|
||||
while (stack.length) {
|
||||
const current = stack.pop();
|
||||
if (!current) break;
|
||||
const entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(entryPath);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.sb')) {
|
||||
matches.push(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function copyVendorRipgrep(targetDir, target) {
|
||||
if (!fs.existsSync(vendorDir)) {
|
||||
console.warn(`Warning: Vendor directory not found at ${vendorDir}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const vendorRipgrepDir = path.join(vendorDir, 'ripgrep');
|
||||
if (!fs.existsSync(vendorRipgrepDir)) {
|
||||
console.warn(`Warning: ripgrep directory not found at ${vendorRipgrepDir}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const platform = target.os === 'windows' ? 'win32' : target.os;
|
||||
const ripgrepTargetDir = path.join(
|
||||
vendorRipgrepDir,
|
||||
`${target.arch}-${platform}`,
|
||||
);
|
||||
if (!fs.existsSync(ripgrepTargetDir)) {
|
||||
console.warn(`Warning: ripgrep binaries not found at ${ripgrepTargetDir}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const destVendorRoot = path.join(targetDir, 'vendor');
|
||||
const destRipgrepDir = path.join(destVendorRoot, 'ripgrep');
|
||||
fs.mkdirSync(destRipgrepDir, { recursive: true });
|
||||
|
||||
const copyingFile = path.join(vendorRipgrepDir, 'COPYING');
|
||||
if (fs.existsSync(copyingFile)) {
|
||||
fs.copyFileSync(copyingFile, path.join(destRipgrepDir, 'COPYING'));
|
||||
}
|
||||
|
||||
copyRecursiveSync(
|
||||
ripgrepTargetDir,
|
||||
path.join(destRipgrepDir, path.basename(ripgrepTargetDir)),
|
||||
);
|
||||
}
|
||||
|
||||
function buildTarget(target) {
|
||||
const outputName = `${cliName}-${target.id}`;
|
||||
const targetDir = path.join(distRoot, outputName);
|
||||
const binDir = path.join(targetDir, 'bin');
|
||||
const binaryName = target.os === 'windows' ? `${cliName}.exe` : cliName;
|
||||
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
const buildArgs = [
|
||||
'build',
|
||||
'--compile',
|
||||
'--target',
|
||||
target.bunTarget,
|
||||
entryPoint,
|
||||
'--outfile',
|
||||
path.join(binDir, binaryName),
|
||||
];
|
||||
|
||||
const result = spawnSync('bun', buildArgs, { stdio: 'inherit' });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Bun build failed for ${target.id}`);
|
||||
}
|
||||
|
||||
const packageJson = {
|
||||
name: outputName,
|
||||
version,
|
||||
os: [target.os === 'windows' ? 'win32' : target.os],
|
||||
cpu: [target.arch],
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(targetDir, 'package.json'),
|
||||
JSON.stringify(packageJson, null, 2) + '\n',
|
||||
);
|
||||
|
||||
copyNativeAssets(targetDir, target);
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(entryPoint)) {
|
||||
console.error(`Entry point not found at ${entryPoint}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.list) {
|
||||
console.log(TARGETS.map((target) => target.id).join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
ensureBunAvailable();
|
||||
cleanNativeDist();
|
||||
|
||||
let selectedTargets = [];
|
||||
if (args.all) {
|
||||
selectedTargets = TARGETS;
|
||||
} else if (args.targets.length > 0) {
|
||||
selectedTargets = TARGETS.filter((target) =>
|
||||
args.targets.includes(target.id),
|
||||
);
|
||||
} else {
|
||||
const hostTargetId = getHostTargetId();
|
||||
if (!hostTargetId) {
|
||||
console.error(
|
||||
`Unsupported host platform/arch: ${process.platform}/${process.arch}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
selectedTargets = TARGETS.filter((target) => target.id === hostTargetId);
|
||||
}
|
||||
|
||||
if (selectedTargets.length === 0) {
|
||||
console.error('No matching targets selected.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const target of selectedTargets) {
|
||||
console.log(`\nBuilding native binary for ${target.id}...`);
|
||||
buildTarget(target);
|
||||
}
|
||||
|
||||
console.log('\n✅ Native build complete.');
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,251 +0,0 @@
|
||||
# Standalone Release Spec (Bun Native + npm Fallback)
|
||||
|
||||
This document describes the target release design for shipping Qwen Code as native
|
||||
binaries built with Bun, while retaining the existing npm JS bundle as a fallback
|
||||
distribution. It is written as a migration-ready spec that bridges the current
|
||||
release pipeline to the future dual-release system.
|
||||
|
||||
## Goal
|
||||
|
||||
Provide a CLI that:
|
||||
|
||||
- Runs as a standalone binary on Linux/macOS/Windows without requiring Node or Bun.
|
||||
- Retains npm installation (global/local) as a JS-only fallback.
|
||||
- Supports a curl installer that pulls the correct binary from GitHub Releases.
|
||||
- Ships multiple variants (x64/arm64, musl/glibc where needed).
|
||||
- Uses one release flow to produce all artifacts with a single tag/version.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Replacing npm as a dev-time dependency manager.
|
||||
- Shipping a single universal binary for all platforms.
|
||||
- Supporting every architecture or OS outside the defined target matrix.
|
||||
- Removing the existing Node/esbuild bundle.
|
||||
|
||||
## Current State (Baseline)
|
||||
|
||||
The current release pipeline:
|
||||
|
||||
- Bundles the CLI into `dist/cli.js` via esbuild.
|
||||
- Uses `scripts/prepare-package.js` to create `dist/package.json`,
|
||||
plus `vendor/`, `locales/`, and `*.sb` assets.
|
||||
- Publishes `dist/` to npm as the primary distribution.
|
||||
- Creates a GitHub Release and attaches only `dist/cli.js`.
|
||||
- Uses `release.yml` for nightly/preview schedules and manual stable releases.
|
||||
|
||||
This spec extends the above pipeline; it does not replace it until the migration
|
||||
phases complete.
|
||||
|
||||
## Target Architecture
|
||||
|
||||
### 1) Build Outputs
|
||||
|
||||
There are two build outputs:
|
||||
|
||||
1. Native binaries (Bun compile) for a target matrix.
|
||||
2. Node-compatible JS bundle for npm fallback (existing `dist/` output).
|
||||
|
||||
Native build output for each target:
|
||||
|
||||
- dist/<name>/bin/<cli> (or .exe on Windows)
|
||||
- dist/<name>/package.json (minimal package metadata)
|
||||
|
||||
Name encodes target:
|
||||
|
||||
- <cli>-linux-x64
|
||||
- <cli>-linux-x64-musl
|
||||
- <cli>-linux-arm64
|
||||
- <cli>-linux-arm64-musl
|
||||
- <cli>-darwin-arm64
|
||||
- <cli>-darwin-x64
|
||||
- <cli>-windows-x64
|
||||
|
||||
### 2) npm Distribution (JS Fallback)
|
||||
|
||||
Keep npm as a pure JS/TS CLI package that runs under Node/Bun. Do not ship or
|
||||
auto-install native binaries through npm.
|
||||
|
||||
Implications:
|
||||
|
||||
- npm install always uses the JS implementation.
|
||||
- No optionalDependencies for platform binaries.
|
||||
- No postinstall symlink logic.
|
||||
- No node shim that searches for a native binary.
|
||||
|
||||
### 3) GitHub Release Distribution (Primary)
|
||||
|
||||
Native binaries are distributed only via GitHub Releases and the curl installer:
|
||||
|
||||
- Archive each platform binary into a tar.gz (Linux) or zip (macOS/Windows).
|
||||
- Attach archives to the GitHub Release.
|
||||
- Provide a shell installer that detects target and downloads the correct archive.
|
||||
|
||||
## Detailed Implementation
|
||||
|
||||
### A) Target Matrix
|
||||
|
||||
Define a target matrix that includes OS, arch, and libc variants.
|
||||
|
||||
Target list (fixed set):
|
||||
|
||||
- darwin arm64
|
||||
- darwin x64
|
||||
- linux arm64 (glibc)
|
||||
- linux x64 (glibc)
|
||||
- linux arm64 musl
|
||||
- linux x64 musl
|
||||
- win32 x64
|
||||
|
||||
### B) Build Scripts
|
||||
|
||||
1. Native build script (new, e.g. `scripts/build-native.ts`)
|
||||
Responsibilities:
|
||||
|
||||
- Remove native build output directory (keep npm `dist/` intact).
|
||||
- For each target:
|
||||
- Compute a target name.
|
||||
- Compile using `Bun.build({ compile: { target: ... } })`.
|
||||
- Write the binary to `dist/<name>/bin/<cli>`.
|
||||
- Write a minimal `package.json` into `dist/<name>/`.
|
||||
|
||||
2. npm fallback build (existing)
|
||||
Responsibilities:
|
||||
|
||||
- `npm run bundle` produces `dist/cli.js`.
|
||||
- `npm run prepare:package` creates `dist/package.json` and copies assets.
|
||||
|
||||
Key details:
|
||||
|
||||
- Use Bun.build with compile.target = <bun-target> (e.g. bun-linux-x64).
|
||||
- Include any extra worker/runtime files in entrypoints.
|
||||
- Use define or execArgv to inject version/channel metadata.
|
||||
- Use "windows" in archive naming even though the OS is "win32" internally.
|
||||
|
||||
Build-time considerations:
|
||||
|
||||
- Preinstall platform-specific native deps for bundling (example: bun install --os="_" --cpu="_" for dependencies with native bindings).
|
||||
- Include worker assets in the compile entrypoints and embed their paths via define constants.
|
||||
- Use platform-specific bunfs root paths when resolving embedded worker files.
|
||||
- Set runtime execArgv flags for user-agent/version and system CA usage.
|
||||
|
||||
Target name example:
|
||||
<cli>-<os>-<arch>[-musl]
|
||||
|
||||
Minimal package.json example:
|
||||
{
|
||||
"name": "<cli>-linux-x64",
|
||||
"version": "<version>",
|
||||
"os": ["linux"],
|
||||
"cpu": ["x64"]
|
||||
}
|
||||
|
||||
### C) Publish Script (new, optional)
|
||||
|
||||
Responsibilities:
|
||||
|
||||
1. Run the native build script.
|
||||
2. Smoke test a local binary (`dist/<host>/bin/<cli> --version`).
|
||||
3. Create GitHub Release archives.
|
||||
4. Optionally build and push Docker image.
|
||||
5. Publish npm package (JS-only fallback) as a separate step or pipeline.
|
||||
|
||||
Note: npm publishing is now independent of native binary publishing. It should not reference platform binaries.
|
||||
|
||||
### D) GitHub Release Installer (install)
|
||||
|
||||
A bash installer that:
|
||||
|
||||
1. Detects OS and arch.
|
||||
2. Handles Rosetta (macOS) and musl detection (Alpine, ldd).
|
||||
3. Builds target name and downloads from GitHub Releases.
|
||||
4. Extracts to ~/.<cli>/bin.
|
||||
5. Adds PATH unless --no-modify-path.
|
||||
|
||||
Supports:
|
||||
|
||||
- --version <version>
|
||||
- --binary <path>
|
||||
- --no-modify-path
|
||||
|
||||
Installer details to include:
|
||||
|
||||
- Require tar for Linux and unzip for macOS/Windows archives.
|
||||
- Use "windows" in asset naming, not "win32".
|
||||
- Prefer arm64 when macOS is running under Rosetta.
|
||||
|
||||
## CI/CD Flow (Dual Pipeline)
|
||||
|
||||
Release pipeline (native binaries):
|
||||
|
||||
1. Bump version.
|
||||
2. Build binaries for the full target matrix.
|
||||
3. Smoke test the host binary.
|
||||
4. Create GitHub release assets.
|
||||
5. Mark release as final (if draft).
|
||||
|
||||
Release pipeline (npm fallback):
|
||||
|
||||
1. Bump version (same tag).
|
||||
2. Publish the JS-only npm package.
|
||||
|
||||
Release orchestration details to consider:
|
||||
|
||||
- Update all package.json version fields in the repo.
|
||||
- Update any extension metadata or download URLs that embed version strings.
|
||||
- Tag the release and create a GitHub Release draft that includes the binary assets.
|
||||
|
||||
### Workflow Mapping to Current Code
|
||||
|
||||
The existing `release.yml` workflow remains the orchestrator:
|
||||
|
||||
- Use `scripts/get-release-version.js` for version/tag selection.
|
||||
- Keep tests and integration checks as-is.
|
||||
- Add a native build matrix job that produces archives and uploads them to
|
||||
the GitHub Release.
|
||||
- Keep the npm publish step from `dist/` as the fallback.
|
||||
- Ensure the same `RELEASE_TAG` is used for both native and npm outputs.
|
||||
|
||||
## Edge Cases and Pitfalls
|
||||
|
||||
- musl: Alpine requires musl binaries.
|
||||
- Rosetta: macOS under Rosetta should prefer arm64 when available.
|
||||
- npm fallback: ensure JS implementation is functional without native helpers.
|
||||
- Path precedence: binary install should appear earlier in PATH than npm global bin if you want native to win by default.
|
||||
- Archive prerequisites: users need tar/unzip depending on OS.
|
||||
|
||||
## Testing Plan
|
||||
|
||||
- Build all targets in CI.
|
||||
- Run dist/<host>/bin/<cli> --version.
|
||||
- npm install locally and verify CLI invocation.
|
||||
- Run installer script on each OS or VM.
|
||||
- Validate musl builds on Alpine.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Phase 1: Add native builds without changing npm
|
||||
|
||||
- [ ] Define target matrix with musl variants.
|
||||
- [ ] Add native build script for Bun compile per target.
|
||||
- [ ] Generate per-target package.json.
|
||||
- [ ] Produce per-target archives and upload to GitHub Releases.
|
||||
- [ ] Keep existing npm bundle publish unchanged.
|
||||
|
||||
Phase 2: Installer and docs
|
||||
|
||||
- [ ] Add curl installer for GitHub Releases.
|
||||
- [ ] Document recommended install paths (native first).
|
||||
- [ ] Add smoke tests for installer output.
|
||||
|
||||
Phase 3: Default install guidance and cleanup
|
||||
|
||||
- [ ] Update docs to recommend native install where possible.
|
||||
- [ ] Decide whether npm stays equal or fallback-only in user docs.
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] Keep `npm run bundle` + `npm run prepare:package` for JS fallback.
|
||||
- [ ] Add `scripts/build-native.ts` for Bun compile targets.
|
||||
- [ ] Add archive creation and asset upload in `release.yml`.
|
||||
- [ ] Add an installer script with OS/arch/musl detection.
|
||||
- [ ] Ensure tag/version parity across native and npm releases.
|
||||
Reference in New Issue
Block a user