diff --git a/docs/feature-checklist.md b/docs/feature-checklist.md index 44f01b7..ced1990 100644 --- a/docs/feature-checklist.md +++ b/docs/feature-checklist.md @@ -125,7 +125,7 @@ Flagship feature. C++ services + `dao://dao-agent` WebUI + vendor runtime. | ☐ | Agent web tools search/fetch tiering | `src/dao/.../resources/agent/web_search/`, Settings Dao page patches, `dao_agent_ui.cc` | — | Provider built-in search is preferred; Auto mode uses configured Jina Search before DuckDuckGo HTML; DuckDuckGo anomaly/verification pages report that accurately instead of `HTML structure changed`; `fetch_url` still falls back from Jina Reader to browser fetch | | ☐ | Dream scheduler and Agent settings controls | `src/dao/.../agent/dao_dream_service.*`, Settings Dao page patches, `dream_bridge.ts` | — | Dream remains off by default; enabling requires memory; nightly/catch-up/manual runs honor idle/time/date gates and show status/history on `dao://dream` | | ☐ | Dream material privacy and excluded-domain filtering | `src/dao/.../agent/dao_dream_material_collector.*`, `dao_dream_domain_utils.*`, `dao_pref_names.*` | — | Excluded domains are normalized and removed before titles/search queries/debug material leave C++; stats do not leak excluded domain names | -| ☐ | Dream one-minute recap, history, rerun, sharing, and habit feedback | `dao_dream_app.ts`, `dao_dream_runner.ts`, `dao_dream_service.cc`, `dao_share_image.ts`, `dao_agent_ui.cc` | — | `dao://dream` loads up to 371 daily reports for the 53-week activity heatmap plus 53 weekly reports for the shared 14-item history rail; structured summaries, measured per-period foreground rhythm (with legacy model-estimate fallback), uncapped aggregate counts, themes, stats, and memory candidates render; candidates affect memory only after confirmation and rejection preserves existing memory; legacy markdown-only reports derive useful recap content; daily/weekly selection, rerun replacement/failure preservation, share image, full-report disclosure, domain exclusion, debug view, and feedback actions work | +| ☐ | Dream one-minute recap, history, rerun, sharing, and habit feedback | `dao_dream_app.ts`, `dao_dream_runner.ts`, `dao_dream_service.cc`, `dao_share_image.ts`, `dao_agent_ui.cc` | — | `dao://dream` loads up to 371 daily reports for the 53-week activity heatmap plus 53 weekly reports for the shared 14-item history rail; report cells show localized date and active-duration tooltips on pointer hover and keyboard focus, legacy reports without duration show the unavailable state, and pointer leave, blur, or heatmap scrolling dismisses the tooltip; structured summaries, measured per-period foreground rhythm (with legacy model-estimate fallback), uncapped aggregate counts, themes, stats, and memory candidates render; candidates affect memory only after confirmation and rejection preserves existing memory; legacy markdown-only reports derive useful recap content; daily/weekly selection, rerun replacement/failure preservation, share image, full-report disclosure, domain exclusion, debug view, and feedback actions work | ## 4. Picture-in-Picture Enhancements diff --git a/docs/features.md b/docs/features.md index 81b6b4a..62e22d2 100644 --- a/docs/features.md +++ b/docs/features.md @@ -274,7 +274,8 @@ The stack includes: **LLM tool calling**, **long-term memory** (SQLite + FTS5), and an optional debug view of the exact LLM input (`dao.dream_debug`). - **`dao://dream` one-minute recap** — responsive two-column report with a 53-week real-report activity heatmap, compact daily-and-weekly history rail, - summary card, + localized date-and-active-duration tooltips for report-bearing heatmap cells + on pointer hover and keyboard focus, summary card, measured foreground-focus rhythm, topic cards, aggregate counts, memory-candidate actions, and a folded full report. Existing rerun, image sharing, source-domain exclusion, diff --git a/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_app.test.ts b/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_app.test.ts index 1b1e8d6..3a74859 100644 --- a/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_app.test.ts +++ b/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_app.test.ts @@ -35,6 +35,7 @@ vi.mock('../dao_share_image.js', () => ({ vi.mock('../i18n/i18n.js', () => ({ initI18n: vi.fn(async () => undefined), + currentLocale: () => 'zh-CN', t: (key: string, vars?: Record) => { const templates: Record = { 'chat.dream.card_date': 'About {date}', @@ -76,6 +77,8 @@ vi.mock('../i18n/i18n.js', () => ({ 'dream.page.activity_less': 'Less', 'dream.page.activity_more': 'More', 'dream.page.activity_label': 'Daily Dream report activity', + 'dream.page.activity_tooltip': '{date} · {duration}', + 'dream.page.activity_duration_unavailable': 'Duration unavailable', 'dream.page.weekly_badge': 'Weekly', 'dream.page.weekly_eyebrow': 'Weekly Dream Recap', 'dream.page.weekly_period': '{start} – {end}', @@ -118,15 +121,29 @@ vi.mock('../vendor/pi_runtime_bundle.js', () => ({ import '../dao_dream_app.js'; +const dreamAppCtor = customElements.get('dao-dream-app') as + CustomElementConstructor & { + invokeLifecycleCallbacksForTesting?: boolean; + }; +dreamAppCtor.invokeLifecycleCallbacksForTesting = true; + type TestDreamApp = HTMLElement & {updateComplete: Promise}; +type TestDreamReport = {dreamDate: string}; type TestDreamAppPrototype = { + reports_: TestDreamReport[]; renderActivityHeatmap_: () => ReturnType; + renderActivityHeatmapCell_: ( + dateKey: string, label: string, report: TestDreamReport|null, + level: number, column: number, row: number) => ReturnType; + renderActivityTooltip_: () => ReturnType; + hideActivityTooltip_: () => void; }; const dreamAppPrototype = (customElements.get('dao-dream-app') as CustomElementConstructor) .prototype as unknown as TestDreamAppPrototype; let restoreActivityHeatmap: () => void; +let useSingleCellActivityHeatmap: (dateKey: string, label: string) => void; function report( dreamDate: string, @@ -248,6 +265,24 @@ function expectIconOnlyCopyButton( expect(button!.querySelector('svg[aria-hidden="true"]')).toBeTruthy(); } +function countTemplateMarkers(value: unknown, marker: string): number { + if (Array.isArray(value)) { + return value.reduce( + (count, item) => count + countTemplateMarkers(item, marker), 0); + } + if (typeof value !== 'object' || value === null || + !('strings' in value) || !('values' in value)) { + return 0; + } + const template = value as { + strings: readonly string[]; + values: readonly unknown[]; + }; + const ownMarkers = template.strings.join('').split(marker).length - 1; + return ownMarkers + template.values.reduce( + (count, item) => count + countTemplateMarkers(item, marker), 0); +} + describe('dao-dream-app routing', () => { beforeEach(() => { document.body.innerHTML = ''; @@ -269,6 +304,22 @@ describe('dao-dream-app routing', () => {
`); restoreActivityHeatmap = () => activityHeatmapSpy.mockRestore(); + useSingleCellActivityHeatmap = (dateKey: string, label: string) => { + activityHeatmapSpy.mockImplementation(function( + this: TestDreamAppPrototype) { + const report = + this.reports_.find(item => item.dreamDate === dateKey) || null; + return html` +
+
this.hideActivityTooltip_()}> + ${this.renderActivityHeatmapCell_( + dateKey, label, report, report ? 1 : 0, 1, 1)} +
+
+ ${this.renderActivityTooltip_()}`; + }); + }; }); afterEach(() => { @@ -288,9 +339,86 @@ describe('dao-dream-app routing', () => { expect(bridgeMocks.callNative).toHaveBeenCalledWith( 'getDreamReports', {limit: 371}); expect(el.shadowRoot!.textContent).toContain('2026-06-12'); - expect(el.shadowRoot!.textContent).toContain('Thu, Jun 11'); + expect(el.shadowRoot!.textContent).toContain('6月11日周四'); }); + it('opens the activity heatmap at the newest dates', async () => { + vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(760); + vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(220); + bridgeMocks.callNative.mockResolvedValueOnce([report('2026-06-12')]); + + const el = await mountDreamApp('/'); + const heatmap = + el.shadowRoot!.querySelector('.heatmap-scroll'); + + expect(heatmap).toBeTruthy(); + expect(heatmap!.scrollLeft).toBe(540); + }); + + it('shows and hides localized active duration for a heatmap cell', + async () => { + useSingleCellActivityHeatmap('2026-06-19', '6月19日周五'); + bridgeMocks.callNative.mockResolvedValueOnce([ + report('2026-06-19', '[]', recapMaterialStats()), + ]); + + const el = await mountDreamApp('/'); + const cell = el.shadowRoot!.querySelector( + '.heat-cell[aria-label="6月19日周五"]'); + expect(cell).toBeTruthy(); + + cell!.dispatchEvent(new Event('pointerenter')); + await el.updateComplete; + + const tooltip = el.shadowRoot!.querySelector( + '[role="tooltip"]'); + expect(tooltip?.textContent).toContain('6月19日'); + expect(tooltip?.textContent).toContain('3小时 54分钟'); + expect(tooltip?.closest('.activity-heatmap')).toBeNull(); + expect(el.shadowRoot! + .querySelector( + '.heat-cell[aria-label="6月19日周五"]') + ?.getAttribute('aria-describedby')) + .toBe('dream-activity-tooltip'); + + cell!.dispatchEvent(new Event('pointerleave')); + await el.updateComplete; + expect(el.shadowRoot!.querySelector('[role="tooltip"]')).toBeNull(); + + el.shadowRoot! + .querySelector( + '.heat-cell[aria-label="6月19日周五"]')! + .dispatchEvent(new Event('pointerenter')); + await el.updateComplete; + expect(el.shadowRoot!.querySelector('[role="tooltip"]')).toBeTruthy(); + + el.shadowRoot!.querySelector('.heatmap-scroll')! + .dispatchEvent(new Event('scroll')); + await el.updateComplete; + expect(el.shadowRoot!.querySelector('[role="tooltip"]')).toBeNull(); + }); + + it('shows unavailable duration for a legacy heatmap report on focus', + async () => { + useSingleCellActivityHeatmap('2026-06-19', '6月19日周五'); + bridgeMocks.callNative.mockResolvedValueOnce([report('2026-06-19')]); + + const el = await mountDreamApp('/'); + const cell = el.shadowRoot!.querySelector( + '.heat-cell[aria-label="6月19日周五"]'); + expect(cell).toBeTruthy(); + + cell!.dispatchEvent(new Event('focus')); + await el.updateComplete; + + expect(el.shadowRoot!.querySelector('[role="tooltip"]')?.textContent) + .toContain('Duration unavailable'); + + cell!.dispatchEvent(new Event('blur')); + await el.updateComplete; + expect(el.shadowRoot!.querySelector('[role="tooltip"]')).toBeNull(); + }); + it('loads dream history for dao://dream/history', async () => { bridgeMocks.callNative.mockResolvedValueOnce([report('2026-06-10')]); @@ -378,7 +506,6 @@ describe('dao-dream-app routing', () => { it('renders the selected one-minute recap design from structured data', async () => { - restoreActivityHeatmap(); bridgeMocks.callNative.mockResolvedValueOnce([ report('2026-06-19', habitCandidates(), recapMaterialStats()), report('2026-06-18'), @@ -388,14 +515,13 @@ describe('dao-dream-app routing', () => { const root = el.shadowRoot!; expect(root.querySelector('.activity-heatmap')).toBeTruthy(); - expect(root.querySelectorAll('.heat-cell').length).toBeGreaterThan(350); expect(root.querySelectorAll('.history-item')).toHaveLength(2); expect(root.querySelector('.recap-summary')?.textContent).toContain( 'Afternoon focus shifted'); expect(root.querySelectorAll('.rhythm-slot')).toHaveLength(4); expect(root.querySelector( '.rhythm-slot[data-peak="true"]')?.textContent) - .toContain('125 min'); + .toContain('2小时 5分钟'); expect(root.querySelectorAll('.theme-card')).toHaveLength(1); expect(root.querySelector('.theme-card')?.textContent) .toContain('Rust async programming'); @@ -409,6 +535,18 @@ describe('dao-dream-app routing', () => { expect(root.querySelector('.memory-candidates')).toBeTruthy(); }); + it('builds a full year of activity heatmap cells without mounting them', + () => { + restoreActivityHeatmap(); + const el = document.createElement('dao-dream-app') as + TestDreamApp & TestDreamAppPrototype; + + const template = el.renderActivityHeatmap_(); + + expect(countTemplateMarkers(template, 'class="heat-cell"')) + .toBeGreaterThan(350); + }); + it('derives a concise recap fallback from legacy markdown', async () => { bridgeMocks.callNative.mockResolvedValueOnce([{ ...report('2026-06-19'), @@ -424,13 +562,46 @@ describe('dao-dream-app routing', () => { .toContain('Main thread'); }); + it('uses legacy report content instead of its generic heading in history', + async () => { + bridgeMocks.callNative.mockResolvedValueOnce([{ + ...report('2026-06-19'), + reportMarkdown: + '## 昨天的主线\n完成了发布流程整理,并验证了关键配置。', + }]); + + const el = await mountDreamApp('/'); + const historySummary = + el.shadowRoot!.querySelector('.history-kind')?.textContent || ''; + + expect(historySummary).toContain( + '完成了发布流程整理,并验证了关键配置。'); + expect(historySummary).not.toContain('昨天的主线'); + }); + + it('keeps a valid structured theme when recap summary is empty', async () => { + const stats = JSON.parse(recapMaterialStats()); + stats.recap.summary = ''; + bridgeMocks.callNative.mockResolvedValueOnce([{ + ...report('2026-06-19', '[]', JSON.stringify(stats)), + reportMarkdown: '## 昨天的主线\n旧版正文不应覆盖结构化主题。', + }]); + + const el = await mountDreamApp('/'); + const historySummary = + el.shadowRoot!.querySelector('.history-kind')?.textContent || ''; + + expect(historySummary).toContain('Rust async programming'); + expect(historySummary).not.toContain('旧版正文'); + }); + it('uses measured foreground buckets instead of model-estimated rhythm', async () => { const stats = JSON.parse(recapMaterialStats()); stats.foreground_seconds_by_bucket = { morning: 600, afternoon: 7200, - evening: 1800, + evening: 7500, night: 0, }; stats.recap.time_buckets = { @@ -449,8 +620,8 @@ describe('dao-dream-app routing', () => { expect(slots.map(slot => slot.textContent)).toEqual([ expect.stringContaining('10 min'), - expect.stringContaining('120 min'), - expect.stringContaining('30 min'), + expect.stringContaining('2小时'), + expect.stringContaining('2小时 5分钟'), expect.stringContaining('0 min'), ]); }); diff --git a/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_runner.test.ts b/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_runner.test.ts index 40427d0..4e0dbbf 100644 --- a/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_runner.test.ts +++ b/src/dao/browser/ui/webui/resources/agent/__tests__/dao_dream_runner.test.ts @@ -7,6 +7,12 @@ import {beforeEach, describe, expect, it, vi} from 'vitest'; const callLLMStreaming = vi.fn(); const recordApiCall = vi.fn(); const addWebUIListener = vi.fn(); +const i18nMocks = vi.hoisted(() => ({ + initialized: false, + locale: 'zh-CN', + initI18n: vi.fn(), + currentLocale: vi.fn(), +})); vi.mock('../agent_bridge.js', () => ({ addWebUIListener: (...args: unknown[]) => addWebUIListener(...args), @@ -33,7 +39,8 @@ vi.mock('../llm_config.js', () => ({ }), })); vi.mock('../i18n/i18n.js', () => ({ - currentLocale: () => 'zh-CN', + initI18n: () => i18nMocks.initI18n(), + currentLocale: () => i18nMocks.currentLocale(), })); import {extractJson, runDream} from '../dao_dream_runner.js'; @@ -98,6 +105,15 @@ describe('runDream', () => { beforeEach(() => { callLLMStreaming.mockReset(); recordApiCall.mockReset(); + i18nMocks.initialized = false; + i18nMocks.locale = 'zh-CN'; + i18nMocks.initI18n.mockReset(); + i18nMocks.initI18n.mockImplementation(async () => { + i18nMocks.initialized = true; + }); + i18nMocks.currentLocale.mockReset(); + i18nMocks.currentLocale.mockImplementation( + () => i18nMocks.initialized ? i18nMocks.locale : 'en'); }); it('parses a valid response and caps confidence at 0.8', async () => { @@ -139,7 +155,8 @@ describe('runDream', () => { expect(recordApiCall).toHaveBeenCalledWith(11, 7, 2, 6); }); - it('asks the model to keep user-facing text in the current locale', async () => { + it('injects the resolved locale into the report prompts', async () => { + i18nMocks.locale = 'fr'; respondWith(VALID); await runDream('2026-06-11', {}); @@ -149,9 +166,13 @@ describe('runDream', () => { }>; const systemPrompt = messages[0]!.content; + expect(messages[0]!.content).toContain('Required output locale: fr'); + expect(messages[0]!.content).not.toContain('zh-CN'); + expect(messages[1]!.content).toContain('Locale: fr'); expect(systemPrompt).toContain( 'All user-facing report text, habit values, evidence, and questions'); - expect(systemPrompt).toContain('For zh-CN, use Simplified Chinese'); + expect(systemPrompt).not.toContain( + 'recent questions clearly use another language'); expect(systemPrompt).not.toContain('Habit keys and values in English'); }); diff --git a/src/dao/browser/ui/webui/resources/agent/__tests__/dao_weekly_dream_runner.test.ts b/src/dao/browser/ui/webui/resources/agent/__tests__/dao_weekly_dream_runner.test.ts index 3b2ef58..c95e1c5 100644 --- a/src/dao/browser/ui/webui/resources/agent/__tests__/dao_weekly_dream_runner.test.ts +++ b/src/dao/browser/ui/webui/resources/agent/__tests__/dao_weekly_dream_runner.test.ts @@ -6,6 +6,12 @@ import {beforeEach, describe, expect, it, vi} from 'vitest'; const callLLMStreaming = vi.fn(); const recordApiCall = vi.fn(); +const i18nMocks = vi.hoisted(() => ({ + initialized: false, + locale: 'zh-CN', + initI18n: vi.fn(), + currentLocale: vi.fn(), +})); vi.mock('../agent_bridge.js', () => ({ addWebUIListener: vi.fn(), @@ -32,7 +38,8 @@ vi.mock('../llm_config.js', () => ({ }), })); vi.mock('../i18n/i18n.js', () => ({ - currentLocale: () => 'zh-CN', + initI18n: () => i18nMocks.initI18n(), + currentLocale: () => i18nMocks.currentLocale(), })); import { @@ -303,10 +310,20 @@ describe('runWeeklyDream', () => { beforeEach(() => { callLLMStreaming.mockReset(); recordApiCall.mockReset(); + i18nMocks.initialized = false; + i18nMocks.locale = 'zh-CN'; + i18nMocks.initI18n.mockReset(); + i18nMocks.initI18n.mockImplementation(async () => { + i18nMocks.initialized = true; + }); + i18nMocks.currentLocale.mockReset(); + i18nMocks.currentLocale.mockImplementation( + () => i18nMocks.initialized ? i18nMocks.locale : 'en'); }); - it('uses locale and the locked safety instructions with an empty tool list', + it('waits for locale and uses locked safety instructions with no tools', async () => { + i18nMocks.locale = 'fr'; respondWith(JSON.stringify(validOutput())); await runWeeklyDream( @@ -319,6 +336,8 @@ describe('runWeeklyDream', () => { content: string; }>; const systemPrompt = messages[0]!.content.replace(/\s+/g, ' '); + expect(systemPrompt).toContain('Required output locale: fr'); + expect(systemPrompt).not.toContain('zh-CN'); expect(systemPrompt).toContain('untrusted evidence'); expect(systemPrompt).toContain( 'Browsing or search activity alone cannot prove completion'); @@ -330,7 +349,7 @@ describe('runWeeklyDream', () => { expect(systemPrompt).toContain('no tool calls'); expect(systemPrompt).toContain( 'no prebuilt Agent instruction'); - expect(messages[1]!.content).toContain('Locale: zh-CN'); + expect(messages[1]!.content).toContain('Locale: fr'); expect(messages[1]!.content).toContain( 'Period: 2026-07-06 to 2026-07-13'); }); diff --git a/src/dao/browser/ui/webui/resources/agent/dao_dream_app.ts b/src/dao/browser/ui/webui/resources/agent/dao_dream_app.ts index b9cdb7a..2cf18de 100644 --- a/src/dao/browser/ui/webui/resources/agent/dao_dream_app.ts +++ b/src/dao/browser/ui/webui/resources/agent/dao_dream_app.ts @@ -5,7 +5,7 @@ import {CrLitElement, html, css, nothing} from '//resources/lit/v3_0/lit.rollup.js'; import {callNative, callNativeArgs} from './dream_bridge.js'; -import {initI18n, t} from './i18n/i18n.js'; +import {currentLocale, initI18n, t} from './i18n/i18n.js'; import {renderDaoMarkdown} from './dao_markdown.js'; import { copyPngBlobToClipboard, @@ -44,9 +44,18 @@ interface DreamMaterialStats { searchQueries: number; conversationSessions: number; sourceDomains: string[]; + hasStructuredRecap: boolean; + hasDurationData: boolean; recap: DreamRecap; } +interface ActivityTooltipState { + dateKey: string; + text: string; + left: number; + top: number; +} + interface DailyDreamReportData { reportKind: 'daily'; id: number; @@ -118,6 +127,7 @@ export class DaoDreamApp extends CrLitElement { dreamExcludedDomains_: {type: Array, state: true}, dreamExclusionAdding_: {type: Boolean, state: true}, dreamExclusionError_: {type: String, state: true}, + activityTooltip_: {type: Object, state: true}, }; } @@ -133,6 +143,7 @@ export class DaoDreamApp extends CrLitElement { declare private dreamExcludedDomains_: string[]; declare private dreamExclusionAdding_: boolean; declare private dreamExclusionError_: string; + declare private activityTooltip_: ActivityTooltipState|null; constructor() { super(); @@ -148,6 +159,7 @@ export class DaoDreamApp extends CrLitElement { this.dreamExcludedDomains_ = []; this.dreamExclusionAdding_ = false; this.dreamExclusionError_ = ''; + this.activityTooltip_ = null; } static override get styles() { @@ -373,39 +385,6 @@ export class DaoDreamApp extends CrLitElement { min-width: 0; } - .history-item { - display: block; - width: 100%; - height: auto; - min-height: 36px; - margin: 0 0 6px; - padding: 8px 10px; - border-color: transparent; - background: transparent; - text-align: left; - } - - .history-item:hover, - .history-item.selected { - border-color: rgba(70, 120, 190, 0.22); - background: rgba(70, 120, 190, 0.08); - } - - .history-date { - display: block; - font-size: 13px; - font-weight: 650; - line-height: 1.3; - } - - .history-kind { - display: block; - margin-top: 2px; - color: rgba(30, 20, 40, 0.48); - font-size: 11px; - line-height: 1.3; - } - .markdown { font-size: 15px; line-height: 1.72; @@ -727,6 +706,23 @@ export class DaoDreamApp extends CrLitElement { outline-offset: 1px; } + .activity-tooltip { + position: fixed; + z-index: 10; + max-width: min(240px, calc(100vw - 24px)); + padding: 6px 9px; + border: 1px solid rgba(var(--dream-accent), 0.22); + border-radius: 7px; + background: rgba(30, 20, 40, 0.92); + box-shadow: 0 6px 18px rgba(var(--dream-ink), 0.16); + color: white; + font-size: 11px; + line-height: 1.35; + pointer-events: none; + transform: translate(-50%, calc(-100% - 7px)); + white-space: nowrap; + } + .heatmap-legend { display: flex; align-items: center; @@ -784,12 +780,13 @@ export class DaoDreamApp extends CrLitElement { } .history-item:hover { - background: rgba(var(--dream-accent), 0.06); + border-color: rgba(var(--dream-accent), 0.2); + background: rgba(var(--dream-accent), 0.1); } .history-item[data-selected="true"] { - border-color: rgba(var(--dream-accent), 0.22); - background: rgba(var(--dream-accent), 0.1); + border-color: rgba(var(--dream-accent), 0.28); + background: rgba(var(--dream-accent), 0.14); } .history-item:focus-visible { @@ -1380,6 +1377,21 @@ export class DaoDreamApp extends CrLitElement { void this.loadPage_(); } + override updated(changedProperties: Map) { + if (changedProperties.has('loading_') && !this.loading_) { + this.scrollHeatmapToLatest_(); + } + } + + private scrollHeatmapToLatest_() { + const heatmap = + this.shadowRoot?.querySelector('.heatmap-scroll'); + if (!heatmap) { + return; + } + heatmap.scrollLeft = Math.max(0, heatmap.scrollWidth - heatmap.clientWidth); + } + private currentRoute_(): 'today'|'history' { const path = window.location.pathname.replace(/\/+$/, ''); return path === '/today' ? 'today' : 'history'; @@ -1544,6 +1556,12 @@ export class DaoDreamApp extends CrLitElement { const hasMeasuredBuckets = measuredBuckets !== null && ['morning', 'afternoon', 'evening', 'night'].some( key => typeof measuredBuckets[key] === 'number'); + const hasRecapBuckets = [ + 'morning_minutes', + 'afternoon_minutes', + 'evening_minutes', + 'night_minutes', + ].some(key => typeof rawBuckets[key] === 'number'); const bucketMinutes = (name: string, recapName: string) => { if (hasMeasuredBuckets) { return this.boundedNumber_( @@ -1578,6 +1596,9 @@ export class DaoDreamApp extends CrLitElement { }); } } + const recapSummary = typeof recap['summary'] === 'string' ? + recap['summary'].trim() : ''; + const hasStructuredRecap = Boolean(recapSummary || themes.length > 0); if (themes.length === 0) { sections.slice(0, 3).forEach((section, index) => themes.push({ name: section.title || t('dream.page.theme_fallback'), @@ -1590,14 +1611,14 @@ export class DaoDreamApp extends CrLitElement { const sourceDomains = Array.isArray(parsed['source_domains']) ? parsed['source_domains'].filter( (item): item is string => typeof item === 'string') : []; - const recapSummary = typeof recap['summary'] === 'string' ? - recap['summary'].trim() : ''; return { historyDomains: this.boundedNumber_(parsed['history_domains'], 10000), searchQueries: this.boundedNumber_(parsed['search_queries'], 10000), conversationSessions: this.boundedNumber_(parsed['conversation_sessions'], 10000), sourceDomains, + hasStructuredRecap, + hasDurationData: hasMeasuredBuckets || hasRecapBuckets, recap: { summary: recapSummary || sections[0]?.body || reportMarkdown.trim(), timeBuckets: { @@ -1994,7 +2015,7 @@ export class DaoDreamApp extends CrLitElement { private formatDreamDate_(value: string): string { const date = this.parseDreamDate_(value); - return date ? new Intl.DateTimeFormat(undefined, { + return date ? new Intl.DateTimeFormat(currentLocale(), { month: 'short', day: 'numeric', weekday: 'short', @@ -2014,6 +2035,66 @@ export class DaoDreamApp extends CrLitElement { return signals >= 16 ? 4 : signals >= 9 ? 3 : signals >= 3 ? 2 : 1; } + private showActivityTooltip_(event: Event, report: DailyDreamReportData) { + const cell = event.currentTarget as HTMLElement; + const rect = cell.getBoundingClientRect(); + const buckets = report.stats.recap.timeBuckets; + const duration = report.stats.hasDurationData ? + this.formatDuration_( + buckets.morning + buckets.afternoon + buckets.evening + + buckets.night) : + t('dream.page.activity_duration_unavailable'); + this.activityTooltip_ = { + dateKey: report.dreamDate, + text: t('dream.page.activity_tooltip', { + date: this.formatDreamDate_(report.dreamDate), + duration, + }), + left: rect.left + rect.width / 2, + top: rect.top, + }; + } + + private hideActivityTooltip_() { + this.activityTooltip_ = null; + } + + private renderActivityHeatmapCell_( + dateKey: string, label: string, report: DailyDreamReportData|null, + level: number, column: number, row: number) { + return html` + `; + } + + private renderActivityTooltip_() { + return this.activityTooltip_ ? html` + ` : nothing; + } + private renderActivityHeatmap_() { const reportsByDate = new Map( this.reports_.map(report => [report.dreamDate, report])); @@ -2043,23 +2124,15 @@ export class DaoDreamApp extends CrLitElement { const column = Math.floor(index / 7) + 1; if (date.getMonth() !== previousMonth && date.getDate() <= 7) { monthLabels.push({ - label: new Intl.DateTimeFormat(undefined, {month: 'short'}) + label: new Intl.DateTimeFormat(currentLocale(), {month: 'short'}) .format(date), column, }); previousMonth = date.getMonth(); } const label = this.formatDreamDate_(dateKey); - cells.push(html` - `); + cells.push(this.renderActivityHeatmapCell_( + dateKey, label, report || null, level, column, index % 7 + 1)); } return html`
@@ -2069,7 +2142,8 @@ export class DaoDreamApp extends CrLitElement { count: this.reports_.length, })} -
+
this.hideActivityTooltip_()}>
${monthLabels.map(month => html` ${month.label}`)} @@ -2085,7 +2159,8 @@ export class DaoDreamApp extends CrLitElement { `)} ${t('dream.page.activity_more')}
-
`; + + ${this.renderActivityTooltip_()}`; } private renderThemeIcon_(index: number) { @@ -2117,8 +2192,24 @@ export class DaoDreamApp extends CrLitElement { `; } - private formatMinutes_(minutes: number) { - return t('dream.page.minutes', {count: minutes}); + private formatDuration_(minutes: number) { + const roundedMinutes = Math.max(0, Math.round(minutes)); + if (roundedMinutes < 60) { + return t('dream.page.minutes', {count: roundedMinutes}); + } + const hours = Math.floor(roundedMinutes / 60); + const remainingMinutes = roundedMinutes % 60; + const formatUnit = (value: number, unit: 'hour'|'minute') => + new Intl.NumberFormat(currentLocale(), { + style: 'unit', + unit, + unitDisplay: 'short', + }).format(value); + const formattedHours = formatUnit(hours, 'hour'); + if (remainingMinutes === 0) { + return formattedHours; + } + return `${formattedHours} ${formatUnit(remainingMinutes, 'minute')}`; } private renderRhythm_(report: DailyDreamReportData) { @@ -2149,7 +2240,7 @@ export class DaoDreamApp extends CrLitElement { style=${`height:${Math.round(value / peak * 100)}%`}>
${label} - ${this.formatMinutes_(value)} + ${this.formatDuration_(value)}
`)} @@ -2206,6 +2297,16 @@ export class DaoDreamApp extends CrLitElement { `; } + private historySummary_(report: DailyDreamReportData) { + if (!report.stats.hasStructuredRecap) { + return report.stats.recap.summary || + this.triggerKindLabel_(report.triggerKind); + } + return report.stats.recap.themes[0]?.name || + report.stats.recap.summary || + this.triggerKindLabel_(report.triggerKind); + } + private renderHistoryList_() { const reports = this.historyReports_(); const selectedKey = this.report_ ? @@ -2242,8 +2343,7 @@ export class DaoDreamApp extends CrLitElement { ${weekly ? report.headline : - report.stats.recap.themes[0]?.name || - this.triggerKindLabel_(report.triggerKind)} + this.historySummary_(report)} ${weekly ? html` diff --git a/src/dao/browser/ui/webui/resources/agent/dao_dream_runner.ts b/src/dao/browser/ui/webui/resources/agent/dao_dream_runner.ts index e2a1dd6..dea8c45 100644 --- a/src/dao/browser/ui/webui/resources/agent/dao_dream_runner.ts +++ b/src/dao/browser/ui/webui/resources/agent/dao_dream_runner.ts @@ -8,7 +8,7 @@ import {recordApiCall} from './agent_bridge.js'; import type {ChatMessage, UsageInfo} from './agent_bridge.js'; -import {currentLocale} from './i18n/i18n.js'; +import {currentLocale, initI18n} from './i18n/i18n.js'; import {getCostRatesForConfig} from './llm_cost.js'; import {getActiveLLMConfig} from './llm_config.js'; @@ -104,10 +104,9 @@ shape: } Rules: -- Use the Locale from the user message as the default output language. If the - user's recent questions clearly use another language, follow that current - language habit instead. For zh-CN, use Simplified Chinese and Chinese - punctuation. +- Use the required output locale injected below for every user-facing string. + It is authoritative; source material in another language must not change + the output language. - All user-facing report text, habit values, evidence, and questions must use the user's current locale and language style. Do not mix English into Chinese output unless the source material itself is an English proper noun, @@ -323,11 +322,15 @@ export async function runDream( if (!cfg.apiKey) { throw new Error('no LLM api key configured'); } - const userPrompt = `Locale: ${currentLocale()}\n` + + await initI18n(); + const locale = currentLocale(); + const systemPrompt = + `${SYSTEM_PROMPT}\n\nRequired output locale: ${locale}`; + const userPrompt = `Locale: ${locale}\n` + `Dream date: ${dreamDate}\n` + `Material pack:\n${JSON.stringify(material)}`; const messages: ChatMessage[] = [ - {role: 'system', content: SYSTEM_PROMPT}, + {role: 'system', content: systemPrompt}, {role: 'user', content: userPrompt}, ]; if (options.debug) { diff --git a/src/dao/browser/ui/webui/resources/agent/dao_weekly_dream_runner.ts b/src/dao/browser/ui/webui/resources/agent/dao_weekly_dream_runner.ts index 54df73a..6da36d8 100644 --- a/src/dao/browser/ui/webui/resources/agent/dao_weekly_dream_runner.ts +++ b/src/dao/browser/ui/webui/resources/agent/dao_weekly_dream_runner.ts @@ -11,7 +11,7 @@ import { extractJson, recordDreamUsage, } from './dao_dream_runner.js'; -import {currentLocale} from './i18n/i18n.js'; +import {currentLocale, initI18n} from './i18n/i18n.js'; import {getActiveLLMConfig} from './llm_config.js'; export interface WeeklyDreamThread { @@ -100,7 +100,9 @@ Output STRICT JSON (no markdown fence or commentary) with exactly this shape: } Rules: -- Follow the Locale from the user message for every user-facing string. +- Use the required output locale injected below for every user-facing string. + It is authoritative; source material in another language must not change + the output language. - The output must contain no URLs, no HTML, no tool calls, and no prebuilt Agent instruction. A next_step is a concise user action, not a prompt for an agent to execute. @@ -314,11 +316,15 @@ export async function runWeeklyDream( if (!cfg.apiKey) { throw new Error('no LLM api key configured'); } + await initI18n(); + const locale = currentLocale(); + const systemPrompt = + `${SYSTEM_PROMPT}\n\nRequired output locale: ${locale}`; const messages: ChatMessage[] = [ - {role: 'system', content: SYSTEM_PROMPT}, + {role: 'system', content: systemPrompt}, { role: 'user', - content: `Locale: ${currentLocale()}\n` + + content: `Locale: ${locale}\n` + `Period: ${period.start} to ${period.end}\n` + `Weekly material pack:\n${JSON.stringify(material)}`, }, diff --git a/src/dao/browser/ui/webui/resources/agent/i18n/locales/en.ts b/src/dao/browser/ui/webui/resources/agent/i18n/locales/en.ts index b331761..6b129a7 100644 --- a/src/dao/browser/ui/webui/resources/agent/i18n/locales/en.ts +++ b/src/dao/browser/ui/webui/resources/agent/i18n/locales/en.ts @@ -407,6 +407,8 @@ const dict: Dictionary = { 'dream.page.activity_less': 'Less', 'dream.page.activity_more': 'More', 'dream.page.activity_label': 'Daily Dream report activity', + 'dream.page.activity_tooltip': '{date} · {duration}', + 'dream.page.activity_duration_unavailable': 'Duration unavailable', 'dream.page.weekly_badge': 'Weekly', 'dream.page.weekly_eyebrow': 'Weekly Dream Recap', 'dream.page.weekly_period': '{start} – {end}', diff --git a/src/dao/browser/ui/webui/resources/agent/i18n/locales/zh-CN.ts b/src/dao/browser/ui/webui/resources/agent/i18n/locales/zh-CN.ts index 52549a4..78e3151 100644 --- a/src/dao/browser/ui/webui/resources/agent/i18n/locales/zh-CN.ts +++ b/src/dao/browser/ui/webui/resources/agent/i18n/locales/zh-CN.ts @@ -307,6 +307,8 @@ const dict: Dictionary = { 'dream.page.activity_less': '少', 'dream.page.activity_more': '多', 'dream.page.activity_label': '过去一年每日梦境报告活跃度', + 'dream.page.activity_tooltip': '{date} · {duration}', + 'dream.page.activity_duration_unavailable': '暂无时长数据', 'dream.page.weekly_badge': '每周', 'dream.page.weekly_eyebrow': '每周梦境回顾', 'dream.page.weekly_period': '{start} 至 {end}',