Merge remote-tracking branch 'upstream/main'
# Conflicts: # openless-all/app/src/pages/QaPanel.tsx
This commit is contained in:
commit
8aefb4fea7
17 files changed
+172
-24
No files matched your search
@@ -187,6 +187,33 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: bash scripts/build-mac.sh
|
||||
|
||||
# ── Windows:先 build OpenLessIme.dll(x64+x86),再跑 tauri bundle。
|
||||
# openless-ime.wxs 用 $(env.OPENLESS_IME_DLL_X64) / _X86 拿绝对路径,
|
||||
# 跨 candle/light cwd 都能 resolve(Tauri wix bundler cwd 不固定)。
|
||||
- name: Build Windows IME native DLLs
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: pwsh
|
||||
working-directory: 'openless-all/app'
|
||||
run: |
|
||||
$appRoot = (Resolve-Path .).Path
|
||||
foreach ($t in @(
|
||||
@{ Platform = 'x64'; Folder = 'x64'; EnvName = 'OPENLESS_IME_DLL_X64' },
|
||||
@{ Platform = 'Win32'; Folder = 'x86'; EnvName = 'OPENLESS_IME_DLL_X86' }
|
||||
)) {
|
||||
$out = Join-Path $appRoot "src-tauri\target\windows-ime-msvc\$($t.Folder)\Release"
|
||||
$obj = Join-Path $appRoot "src-tauri\target\windows-ime-msvc\obj\$($t.Folder)\Release"
|
||||
./scripts/windows-ime-build.ps1 -Configuration Release -Platform $t.Platform -OutputDirectory $out -IntermediateDirectory $obj
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "OpenLessIme $($t.Platform) build failed with exit $LASTEXITCODE"
|
||||
}
|
||||
$dll = (Resolve-Path (Join-Path $out 'OpenLessIme.dll')).Path
|
||||
if (-not (Test-Path $dll)) {
|
||||
throw "OpenLessIme.dll not produced at $dll"
|
||||
}
|
||||
"$($t.EnvName)=$dll" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
Write-Host "[ok] built $dll (exported $($t.EnvName))"
|
||||
}
|
||||
|
||||
# ── Windows:仅 Windows 跑通用 tauri build(Linux 用下方专用 step,避免重复构建)──
|
||||
- name: Build (Windows)
|
||||
if: matrix.platform == 'windows-latest'
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openless-app",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openless-app",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-autostart": "^2.5.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "openless-app",
|
||||
"private": true,
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -3281,7 +3281,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openless"
|
||||
version = "1.2.13"
|
||||
version = "1.2.14"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "openless"
|
||||
version = "1.2.13"
|
||||
version = "1.2.14"
|
||||
description = "OpenLess — local voice input that types where your cursor is"
|
||||
authors = ["OpenLess"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenLess",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.14",
|
||||
"identifier": "com.openless.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
<Directory Id="OpenLessImeDirectory" Name="windows-ime">
|
||||
<Directory Id="OpenLessImeX64Directory" Name="x64">
|
||||
<Component Id="OpenLessImeDllX64Component" Guid="{B787DE6F-DF71-4D68-9DE8-04D3665A26F3}" Win64="yes">
|
||||
<File Id="OpenLessImeDllX64" Name="OpenLessIme.dll" Source="src-tauri\target\windows-ime-msvc\x64\Release\OpenLessIme.dll" KeyPath="yes" />
|
||||
<File Id="OpenLessImeDllX64" Name="OpenLessIme.dll" Source="$(env.OPENLESS_IME_DLL_X64)" KeyPath="yes" />
|
||||
</Component>
|
||||
</Directory>
|
||||
<Directory Id="OpenLessImeX86Directory" Name="x86">
|
||||
<Component Id="OpenLessImeDllX86Component" Guid="{A2E61B9A-4034-4ABF-A42F-964A9B1F08BB}" Win64="no">
|
||||
<File Id="OpenLessImeDllX86" Name="OpenLessIme.dll" Source="src-tauri\target\windows-ime-msvc\x86\Release\OpenLessIme.dll" KeyPath="yes" />
|
||||
<File Id="OpenLessImeDllX86" Name="OpenLessIme.dll" Source="$(env.OPENLESS_IME_DLL_X86)" KeyPath="yes" />
|
||||
</Component>
|
||||
</Directory>
|
||||
</Directory>
|
||||
|
||||
@@ -260,6 +260,14 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
两列内部各自的 overflow:auto 才能独立滚动 */}
|
||||
<div
|
||||
key={displayTab}
|
||||
// issue #243:所有 tab 都允许 overflow:auto,让窗口被压缩 / 文案
|
||||
// 变长时仍可触达底部内容(Codex P1:之前 overview 用 hidden
|
||||
// 会让缩窗后 Recent 卡彻底不可见)。
|
||||
// - Overview 借 Overview.tsx 内部 flex 把底部行 grow 到撑满,
|
||||
// 正常尺寸下内容刚好占满 → 浏览器自动不显示 scrollbar;
|
||||
// 真挤不下了才 fallback 出细滚动条。
|
||||
// - 其他 tab 同样走细滚动条。
|
||||
className="ol-thinscroll"
|
||||
style={{
|
||||
flex: 1, minHeight: 0,
|
||||
overflow: 'auto',
|
||||
|
||||
@@ -164,7 +164,7 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
|
||||
</div>
|
||||
) : (
|
||||
// personalize / about 短内容:单一 scroll wrapper,超出时本块滚动。
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '10px 28px 28px' }}>
|
||||
<div className="ol-thinscroll" style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '10px 28px 28px' }}>
|
||||
{section === 'personalize' && <PersonalizeSection />}
|
||||
{section === 'about' && <AboutMini />}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { renderQaMarkdown } from './qaMarkdown';
|
||||
|
||||
function assertIncludes(text: string, expected: string, name: string) {
|
||||
if (!text.includes(expected)) {
|
||||
throw new Error(`${name}: expected to include "${expected}", got "${text}"`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotIncludes(text: string, expected: string, name: string) {
|
||||
if (text.includes(expected)) {
|
||||
throw new Error(`${name}: expected not to include "${expected}", got "${text}"`);
|
||||
}
|
||||
}
|
||||
|
||||
const htmlEscaped = renderQaMarkdown('<img src=x onerror=alert(1)><script>alert(1)</script>');
|
||||
assertIncludes(htmlEscaped, '<img src=x onerror=alert(1)>', 'raw html should be escaped');
|
||||
assertNotIncludes(htmlEscaped, '<script>', 'script tag should not be rendered');
|
||||
assertNotIncludes(htmlEscaped, '<img src=x onerror=alert(1)>', 'raw html img should not become live dom tag');
|
||||
|
||||
const badHref = renderQaMarkdown('[xss](javascript:alert(1))');
|
||||
assertNotIncludes(badHref, 'href="javascript:alert(1)"', 'javascript href should be dropped');
|
||||
|
||||
const goodMarkdown = renderQaMarkdown('**bold**\n\n- a\n- b\n\n`code`\n\n[ok](https://example.com)');
|
||||
assertIncludes(goodMarkdown, '<strong>bold</strong>', 'bold markdown should render');
|
||||
assertIncludes(goodMarkdown, '<li>a</li>', 'list markdown should render');
|
||||
assertIncludes(goodMarkdown, '<code>code</code>', 'code markdown should render');
|
||||
assertIncludes(goodMarkdown, 'href="https://example.com"', 'safe link should render');
|
||||
|
||||
const safeQueryLink = renderQaMarkdown('[ok](https://example.com?a=1&b=2)');
|
||||
assertIncludes(safeQueryLink, 'href="https://example.com?a=1&b=2"', 'safe query link should keep a single escaped ampersand');
|
||||
|
||||
const codeSnippet = renderQaMarkdown('`<div class=\"x\">`');
|
||||
assertIncludes(codeSnippet, '<div class="x">', 'inline code should stay single-escaped');
|
||||
assertNotIncludes(codeSnippet, '&lt;', 'inline code should not be double-escaped');
|
||||
@@ -0,0 +1,65 @@
|
||||
import { marked } from 'marked';
|
||||
|
||||
const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
|
||||
|
||||
function escapeHtml(raw: string): string {
|
||||
return raw
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(raw: string): string {
|
||||
return raw
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function normalizeHref(href: string | null | undefined): string | null {
|
||||
if (!href) return null;
|
||||
const trimmed = href.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.startsWith('#')) return trimmed;
|
||||
try {
|
||||
const url = new URL(trimmed, 'https://openless.local/');
|
||||
if (!SAFE_LINK_PROTOCOLS.has(url.protocol)) return null;
|
||||
return trimmed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const QA_MARKDOWN_RENDERER = new marked.Renderer();
|
||||
QA_MARKDOWN_RENDERER.html = (html: string) => escapeHtml(html);
|
||||
QA_MARKDOWN_RENDERER.link = (href: string | null, title: string | null, text: string) => {
|
||||
const safeHref = normalizeHref(decodeHtmlEntities(href ?? ''));
|
||||
if (!safeHref) return `<span>${text}</span>`;
|
||||
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
|
||||
return `<a href="${escapeHtml(safeHref)}"${titleAttr} target="_blank" rel="noreferrer noopener">${text}</a>`;
|
||||
};
|
||||
QA_MARKDOWN_RENDERER.image = (href: string | null, title: string | null, text: string) => {
|
||||
const safeHref = normalizeHref(decodeHtmlEntities(href ?? ''));
|
||||
if (!safeHref) return '';
|
||||
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
|
||||
const alt = escapeHtml(text || '');
|
||||
return `<img src="${escapeHtml(safeHref)}" alt="${alt}"${titleAttr} loading="lazy" />`;
|
||||
};
|
||||
|
||||
export function renderQaPlainText(raw: string): string {
|
||||
return `<pre>${escapeHtml(raw)}</pre>`;
|
||||
}
|
||||
|
||||
export function renderQaMarkdown(markdown: string): string {
|
||||
// 保留 markdown 语义(尤其代码块),但把 raw HTML token 转义为纯文本,避免注入。
|
||||
return marked.parse(markdown, {
|
||||
async: false,
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
renderer: QA_MARKDOWN_RENDERER,
|
||||
}) as string;
|
||||
}
|
||||
@@ -126,7 +126,7 @@ export function History() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: 6 }}>
|
||||
<div className="ol-thinscroll" style={{ flex: 1, overflow: 'auto', padding: 6 }}>
|
||||
{loading && <div style={{ padding: 16, fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div style={{ padding: 16, fontSize: 12, color: 'var(--ol-ink-4)' }}>
|
||||
@@ -164,7 +164,7 @@ export function History() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding={20} style={{ overflow: 'auto' }}>
|
||||
<Card padding={20} className="ol-thinscroll" style={{ overflow: 'auto' }}>
|
||||
{item ? (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
|
||||
@@ -94,8 +94,11 @@ export function Overview({ onOpenHistory }: OverviewProps) {
|
||||
<Metric icon="bolt" label={t('overview.metricTotal')} value={String(history.length)} trend={t('overview.metricTotalTrend')} accent />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.4fr', gap: 12 }}>
|
||||
<Card padding={18}>
|
||||
{/* 底部一行 = flex:1 撑满剩余高度(父 wrapper 是 display:flex/column)。
|
||||
只有「最近识别」内部允许滚动;其他卡片按内容自然高度,不破裂底部圆角。
|
||||
issue #243 follow-up:去掉外层 overflow 后底部圆角被裁的视觉问题。 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.4fr', gap: 12, flex: 1, minHeight: 0 }}>
|
||||
<Card padding={18} style={{ display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-2)' }}>{t('overview.weekTitle')}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>{t('overview.weekUnit')}</span>
|
||||
@@ -106,12 +109,12 @@ export function Overview({ onOpenHistory }: OverviewProps) {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding={0}>
|
||||
<div style={{ padding: '14px 18px', borderBottom: '0.5px solid var(--ol-line)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Card padding={0} style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '14px 18px', borderBottom: '0.5px solid var(--ol-line)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-2)' }}>{t('overview.recentTitle')}</span>
|
||||
<Btn size="sm" variant="ghost" onClick={onOpenHistory}>{t('overview.recentAll')}</Btn>
|
||||
</div>
|
||||
<div>
|
||||
<div className="ol-thinscroll" style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||
{history.length === 0 && (
|
||||
<div style={{ padding: 24, textAlign: 'center', fontSize: 12, color: 'var(--ol-ink-4)' }}>
|
||||
{t('overview.recentEmpty', { trigger: getHotkeyBindingLabel(hotkey) })}
|
||||
|
||||
@@ -12,15 +12,13 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { marked } from 'marked';
|
||||
import { getSettings, isTauri, qaWindowDismiss, qaWindowPin } from '../lib/ipc';
|
||||
import type { QaChatMessage, QaStatePayload, UserPreferences } from '../lib/types';
|
||||
import { getHotkeyBindingLabel } from '../lib/hotkey';
|
||||
import { renderQaMarkdown, renderQaPlainText } from '../lib/qaMarkdown';
|
||||
|
||||
const SELECTION_PREVIEW_MAX = 60;
|
||||
|
||||
marked.setOptions({ gfm: true, breaks: true });
|
||||
|
||||
type Status = 'idle' | 'recording' | 'thinking' | 'error';
|
||||
|
||||
export function QaPanel() {
|
||||
@@ -405,10 +403,10 @@ function MessageRow({ message }: { message: QaChatMessage }) {
|
||||
const html = useMemo(() => {
|
||||
if (message.role !== 'assistant') return '';
|
||||
try {
|
||||
return marked.parse(message.content, { async: false }) as string;
|
||||
return renderQaMarkdown(message.content);
|
||||
} catch (error) {
|
||||
console.error('[qa] failed to render markdown', error);
|
||||
return message.content;
|
||||
return renderQaPlainText(String(message.content ?? ''));
|
||||
}
|
||||
}, [message.content, message.role]);
|
||||
|
||||
@@ -445,10 +443,10 @@ function MessageRow({ message }: { message: QaChatMessage }) {
|
||||
function StreamingAssistantBubble({ markdown }: { markdown: string }) {
|
||||
const html = useMemo(() => {
|
||||
try {
|
||||
return marked.parse(markdown, { async: false }) as string;
|
||||
return renderQaMarkdown(markdown);
|
||||
} catch (error) {
|
||||
console.error('[qa] failed to render streaming markdown', error);
|
||||
return markdown;
|
||||
return renderQaPlainText(String(markdown ?? ''));
|
||||
}
|
||||
}, [markdown]);
|
||||
return (
|
||||
|
||||
@@ -117,6 +117,7 @@ export function Settings({ embedded = false, initialSection = 'recording' }: Set
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={embedded ? 'ol-thinscroll' : undefined}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
@@ -32,11 +32,13 @@ interface CardProps {
|
||||
style?: CSSProperties;
|
||||
padding?: number;
|
||||
glassy?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Card({ children, style, padding = 18, glassy = false }: CardProps) {
|
||||
export function Card({ children, style, padding = 18, glassy = false, className }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
background: glassy ? 'rgba(255,255,255,0.55)' : 'var(--ol-surface)',
|
||||
backdropFilter: glassy ? 'blur(20px) saturate(160%)' : undefined,
|
||||
|
||||
@@ -78,6 +78,16 @@ body {
|
||||
.ol-noscrollbar::-webkit-scrollbar { display: none; }
|
||||
.ol-noscrollbar { scrollbar-width: none; }
|
||||
|
||||
/* Thin scrollbar — 细而可见,保留"可以滚"的可见性,但不抢眼。issue #243 */
|
||||
.ol-thinscroll::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
.ol-thinscroll::-webkit-scrollbar-track { background: transparent; }
|
||||
.ol-thinscroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.ol-thinscroll::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, 0.28); }
|
||||
.ol-thinscroll { scrollbar-width: thin; scrollbar-color: rgba(0, 0, 0, 0.18) transparent; }
|
||||
|
||||
/* Glass surface */
|
||||
.ol-glass {
|
||||
background: var(--ol-glass-bg);
|
||||
|
||||
Reference in new issue
Block a user